diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml new file mode 100644 index 0000000..d6fa36b --- /dev/null +++ b/.github/workflows/auto-format.yml @@ -0,0 +1,129 @@ +name: Auto Format + +on: + pull_request_target: + branches: [main] + types: [opened, synchronize] + +permissions: + contents: read + +jobs: + format: + name: Format + # Skip bot-authored commits to avoid infinite loops + if: github.actor != 'emdashbot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Get PR details + id: pr + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const pr = context.payload.pull_request; + const isFork = pr.head.repo.fork || pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`; + core.setOutput('ref', pr.head.ref); + core.setOutput('sha', pr.head.sha); + core.setOutput('number', pr.number.toString()); + core.setOutput('is_fork', isFork.toString()); + core.setOutput('full_name', pr.head.repo.full_name); + + # --- Same-repo PRs: checkout and push directly --- + + - name: Checkout (same-repo) + if: steps.pr.outputs.is_fork == 'false' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ steps.pr.outputs.ref }} + token: ${{ steps.app-token.outputs.token }} + + # --- Fork PRs: checkout the fork at the pinned SHA --- + + - name: Checkout (fork) + if: steps.pr.outputs.is_fork == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ steps.pr.outputs.full_name }} + ref: ${{ steps.pr.outputs.sha }} + persist-credentials: false + + # --- Install formatters directly (no pnpm install, no postinstall scripts) --- + + - name: Setup Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 22 + + - name: Install formatters + run: npm install --no-save --ignore-scripts oxfmt prettier prettier-plugin-astro + + - name: Run formatters + run: | + npx oxfmt --ignore-path .gitignore + npx prettier --write . + + - name: Check for changes + id: diff + run: | + git add -A + if git diff --staged --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + # --- Same-repo: push directly --- + + - name: Commit and push (same-repo) + if: steps.pr.outputs.is_fork == 'false' && steps.diff.outputs.changed == 'true' + run: | + git config user.name "emdashbot[bot]" + git config user.email "emdashbot[bot]@users.noreply.github.com" + git commit -m "style: format" + git push + + # --- Fork: push via git with GIT_ASKPASS --- + + - name: Commit and push (fork) + if: steps.pr.outputs.is_fork == 'true' && steps.diff.outputs.changed == 'true' + id: push-fork + run: | + git config user.name "emdashbot[bot]" + git config user.email "emdashbot[bot]@users.noreply.github.com" + git commit -m "style: format" + # Push to the fork using the app token via GIT_ASKPASS to avoid + # leaking the token in process args or git config + export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh" + printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS" + chmod +x "$GIT_ASKPASS" + git remote add fork "https://x-access-token@github.com/${{ steps.pr.outputs.full_name }}.git" + if git push fork "HEAD:${{ steps.pr.outputs.ref }}"; then + echo "push_failed=false" >> "$GITHUB_OUTPUT" + else + echo "push_failed=true" >> "$GITHUB_OUTPUT" + fi + rm -f "$GIT_ASKPASS" + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Comment on push failure + if: steps.push-fork.outputs.push_failed == 'true' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.pr.outputs.number }}, + body: `Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled.\n\nPlease run the formatter locally:\n\n\`\`\`\nnpx oxfmt --ignore-path .gitignore\nnpx prettier --write .\n\`\`\``, + }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0f47fa..02e6379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: typecheck: name: Typecheck runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -35,6 +36,7 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -51,6 +53,7 @@ jobs: test: name: Tests runs-on: ubuntu-latest + timeout-minutes: 15 services: postgres: image: postgres:17 @@ -82,6 +85,7 @@ jobs: validate-plugins: name: Validate Plugins runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -114,6 +118,7 @@ jobs: test-smoke: name: Smoke Tests runs-on: ubuntu-latest + timeout-minutes: 10 services: postgres: image: postgres:17 @@ -146,6 +151,7 @@ jobs: test-browser: name: Browser Tests runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -171,6 +177,7 @@ jobs: if: always() needs: [test-e2e] runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Check E2E shard results run: | @@ -182,6 +189,7 @@ jobs: test-e2e: name: E2E tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) runs-on: ubuntu-latest + timeout-minutes: 20 strategy: fail-fast: false matrix: diff --git a/.github/workflows/dependabot-approve.yml b/.github/workflows/dependabot-approve.yml new file mode 100644 index 0000000..d01dbd0 --- /dev/null +++ b/.github/workflows/dependabot-approve.yml @@ -0,0 +1,35 @@ +name: Dependabot Auto-Approve + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + approve: + name: Auto-Approve + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444e40b2048 # v2.4.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Auto-approve patch and minor updates + if: steps.metadata.outputs.update-type != 'version-update:semver-major' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + event: 'APPROVE', + body: 'Auto-approved: ${{ steps.metadata.outputs.update-type }} update for ${{ steps.metadata.outputs.dependency-names }}.', + }); diff --git a/.github/workflows/format-command.yml b/.github/workflows/format-command.yml index 4cbfdb9..9560c77 100644 --- a/.github/workflows/format-command.yml +++ b/.github/workflows/format-command.yml @@ -16,6 +16,7 @@ jobs: github.event.comment.author_association == 'COLLABORATOR' ) runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write pull-requests: write @@ -27,7 +28,7 @@ jobs: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - - name: Get PR branch + - name: Get PR details id: pr uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: @@ -38,8 +39,11 @@ jobs: repo: context.repo.repo, pull_number: context.issue.number, }); + const isFork = pr.data.head.repo.fork || pr.data.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`; core.setOutput('ref', pr.data.head.ref); core.setOutput('sha', pr.data.head.sha); + core.setOutput('is_fork', isFork.toString()); + core.setOutput('full_name', pr.data.head.repo.full_name); - name: React to comment uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 @@ -53,47 +57,91 @@ jobs: content: 'eyes', }); - - name: Checkout + - name: Checkout (same-repo) + if: steps.pr.outputs.is_fork == 'false' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ steps.pr.outputs.ref }} token: ${{ steps.app-token.outputs.token }} - - name: Setup pnpm - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - name: Checkout (fork) + if: steps.pr.outputs.is_fork == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ steps.pr.outputs.full_name }} + ref: ${{ steps.pr.outputs.sha }} + persist-credentials: false - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 22 - cache: pnpm - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Install formatters + run: npm install --no-save --ignore-scripts oxfmt prettier prettier-plugin-astro - - name: Run formatter - run: pnpm format - - - name: Commit and push - id: commit + - name: Run formatters + run: | + npx oxfmt --ignore-path .gitignore + npx prettier --write . + + - name: Check for changes + id: diff run: | - git config user.name "emdashbot[bot]" - git config user.email "emdashbot[bot]@users.noreply.github.com" git add -A if git diff --staged --quiet; then echo "changed=false" >> "$GITHUB_OUTPUT" else - git commit -m "style: format" - git push echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: React to comment + - name: Commit and push (same-repo) + if: steps.pr.outputs.is_fork == 'false' && steps.diff.outputs.changed == 'true' + run: | + git config user.name "emdashbot[bot]" + git config user.email "emdashbot[bot]@users.noreply.github.com" + git commit -m "style: format" + git push + + - name: Commit and push (fork) + if: steps.pr.outputs.is_fork == 'true' && steps.diff.outputs.changed == 'true' + id: push-fork + run: | + git config user.name "emdashbot[bot]" + git config user.email "emdashbot[bot]@users.noreply.github.com" + git commit -m "style: format" + export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh" + printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS" + chmod +x "$GIT_ASKPASS" + git remote add fork "https://x-access-token@github.com/${{ steps.pr.outputs.full_name }}.git" + if git push fork "HEAD:${{ steps.pr.outputs.ref }}"; then + echo "push_failed=false" >> "$GITHUB_OUTPUT" + else + echo "push_failed=true" >> "$GITHUB_OUTPUT" + fi + rm -f "$GIT_ASKPASS" + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Comment on push failure + if: steps.push-fork.outputs.push_failed == 'true' uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: github-token: ${{ steps.app-token.outputs.token }} script: | - const changed = '${{ steps.commit.outputs.changed }}' === 'true'; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled.\n\nPlease run the formatter locally:\n\n\`\`\`\nnpx oxfmt --ignore-path .gitignore\nnpx prettier --write .\n\`\`\``, + }); + + - name: React to comment (result) + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const changed = '${{ steps.diff.outputs.changed }}' === 'true'; await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 006f842..55f4c66 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -13,6 +13,7 @@ jobs: format: name: Format runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.github/workflows/pr-compliance.yml b/.github/workflows/pr-compliance.yml index b2870cd..35a04ef 100644 --- a/.github/workflows/pr-compliance.yml +++ b/.github/workflows/pr-compliance.yml @@ -7,56 +7,66 @@ on: permissions: contents: read + pull-requests: write jobs: check-pr: name: Validate PR if: github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' && github.actor != 'emdashbot[bot]' runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Check PR template uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | const body = context.payload.pull_request.body || ''; + const pr = context.payload.pull_request; const errors = []; - // Must have a description (not just the raw template placeholder) - const descriptionSection = body.match(/## What does this PR do\?\s*\n([\s\S]*?)(?=\n## )/); - const description = descriptionSection?.[1]?.replace(//g, '').trim() || ''; - if (!description || description === 'Closes #') { - errors.push('Fill out the "What does this PR do?" section with a description of your change.'); - } + // Check if the PR body looks like it used the template at all + const hasTemplate = body.includes('## What does this PR do?') || body.includes('## Type of change'); - // Must check at least one type - const typeChecks = [ - /- \[x\] Bug fix/i, - /- \[x\] Feature/i, - /- \[x\] Refactor/i, - /- \[x\] Documentation/i, - /- \[x\] Performance/i, - /- \[x\] Tests/i, - /- \[x\] Chore/i, - ]; - const hasType = typeChecks.some(re => re.test(body)); - if (!hasType) { - errors.push('Check at least one "Type of change" checkbox.'); - } - - // If Feature is checked, require a discussion link - const isFeature = /- \[x\] Feature/i.test(body); - if (isFeature) { - const hasDiscussionLink = /github\.com\/emdash-cms\/emdash\/discussions\/\d+/.test(body); - if (!hasDiscussionLink) { - errors.push('Feature PRs require a link to an approved Discussion (https://github.com/emdash-cms/emdash/discussions/categories/ideas). Open a Discussion first, get approval, then link it in the PR.'); + if (!hasTemplate) { + errors.push('This PR does not use the required PR template. Please edit the description to use the [PR template](https://github.com/emdash-cms/emdash/blob/main/.github/PULL_REQUEST_TEMPLATE.md). Copy it into your PR description and fill out all sections.'); + } else { + // Must have a description (not just the raw template placeholder) + const descriptionSection = body.match(/## What does this PR do\?\s*\n([\s\S]*?)(?=\n## )/); + const description = descriptionSection?.[1]?.replace(//g, '').trim() || ''; + if (!description || description === 'Closes #') { + errors.push('Fill out the "What does this PR do?" section with a description of your change.'); } - } - // Must check the "I have read CONTRIBUTING.md" box - const hasReadContributing = /- \[x\] I have read \[CONTRIBUTING\.md\]/i.test(body); - if (!hasReadContributing) { - errors.push('Check the "I have read CONTRIBUTING.md" checkbox.'); + // Must check at least one type + const typeChecks = [ + /- \[x\] Bug fix/i, + /- \[x\] Feature/i, + /- \[x\] Refactor/i, + /- \[x\] Documentation/i, + /- \[x\] Performance/i, + /- \[x\] Tests/i, + /- \[x\] Chore/i, + ]; + const hasType = typeChecks.some(re => re.test(body)); + if (!hasType) { + errors.push('Check at least one "Type of change" checkbox.'); + } + + // If Feature is checked, require a discussion link + const isFeature = /- \[x\] Feature/i.test(body); + if (isFeature) { + const hasDiscussionLink = /github\.com\/emdash-cms\/emdash\/discussions\/\d+/.test(body); + if (!hasDiscussionLink) { + errors.push('Feature PRs require a link to an approved Discussion (https://github.com/emdash-cms/emdash/discussions/categories/ideas). Open a Discussion first, get approval, then link it in the PR.'); + } + } + + // Must check the "I have read CONTRIBUTING.md" box + const hasReadContributing = /- \[x\] I have read \[CONTRIBUTING\.md\]/i.test(body); + if (!hasReadContributing) { + errors.push('Check the "I have read CONTRIBUTING.md" checkbox.'); + } } if (errors.length > 0) { @@ -70,5 +80,58 @@ jobs: 'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for the full contribution policy.', ].join('\n'); + // Leave a comment so the author sees the errors directly + // Find and update existing bot comment, or create a new one + const marker = ''; + const commentBody = `${marker}\n${message}`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + }); + + const existing = comments.find(c => + c.user?.login === 'github-actions[bot]' && + c.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody, + }); + } + core.setFailed(message); + } else { + // If passing now, remove any previous failure comment + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + }); + + const existing = comments.find(c => + c.user?.login === 'github-actions[bot]' && + c.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } } diff --git a/.github/workflows/pr-sweep.yml b/.github/workflows/pr-sweep.yml new file mode 100644 index 0000000..9702c58 --- /dev/null +++ b/.github/workflows/pr-sweep.yml @@ -0,0 +1,288 @@ +name: PR Sweep + +on: + schedule: + # Every 6 hours + - cron: "0 */6 * * *" + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + sweep: + name: Sweep Open PRs + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Sweep PRs + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const now = new Date(); + const DAY = 24 * 60 * 60 * 1000; + + // Label color map for auto-creation + const labelColors = { + 'needs-approval': 'fbca04', + 'needs-rebase': 'e11d48', + 'stale': 'ededed', + 'overlap': 'c5def5', + }; + + // Ensure labels exist + const existingLabels = new Set(); + for await (const response of github.paginate.iterator( + github.rest.issues.listLabelsForRepo, + { owner, repo, per_page: 100 } + )) { + for (const label of response.data) { + existingLabels.add(label.name); + } + } + for (const [name, color] of Object.entries(labelColors)) { + if (!existingLabels.has(name)) { + await github.rest.issues.createLabel({ owner, repo, name, color }); + } + } + + // Get all open PRs + const prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + + core.info(`Sweeping ${prs.length} open PRs`); + + // Build a file->PR map for overlap detection + const fileToPRs = new Map(); + + for (const pr of prs) { + const prLabels = new Set(pr.labels.map(l => l.name)); + const toAdd = []; + const toRemove = []; + + // --- needs-approval: PR has no CI check runs --- + // (CLA runs via pull_request_target so it always runs) + try { + const { data: checkRuns } = await github.rest.checks.listForRef({ + owner, + repo, + ref: pr.head.sha, + per_page: 100, + }); + // Filter to only CI-related checks (not CLA, not PR Triage) + const ciChecks = checkRuns.check_runs.filter(c => + !c.name.includes('CLA') && + !c.name.includes('Label') && + !c.name.includes('Triage') && + !c.name.includes('Validate PR') + ); + const prAge = now - new Date(pr.created_at); + + if (ciChecks.length === 0 && prAge > 5 * 60 * 1000) { + // No CI checks after 5 minutes -- likely needs approval + if (!prLabels.has('needs-approval')) toAdd.push('needs-approval'); + } else { + if (prLabels.has('needs-approval')) toRemove.push('needs-approval'); + } + } catch { + // Ignore check run lookup failures + } + + // --- needs-rebase: PR has merge conflicts --- + // mergeable is not included in the list endpoint; fetch individually + try { + const { data: fullPr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + }); + if (fullPr.mergeable === false) { + if (!prLabels.has('needs-rebase')) toAdd.push('needs-rebase'); + } else if (fullPr.mergeable === true) { + if (prLabels.has('needs-rebase')) toRemove.push('needs-rebase'); + } + // mergeable===null means GitHub is still computing; skip + } catch { + // Ignore merge status lookup failures + } + + // --- Stale detection --- + const lastActivity = new Date(pr.updated_at); + const daysSinceActivity = (now - lastActivity) / DAY; + + if (daysSinceActivity > 21) { + // 21 days with no activity -- close it + if (!prLabels.has('stale')) { + // Should already be labeled stale from 14-day mark, but just in case + toAdd.push('stale'); + } + + const marker = ''; + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: `${marker}\nThis PR has been inactive for 21 days and is being closed automatically. If you'd like to continue working on it, feel free to reopen it.`, + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); + + core.info(`#${pr.number}: closed (stale, ${Math.floor(daysSinceActivity)} days inactive)`); + continue; // Skip further processing for closed PRs + } else if (daysSinceActivity > 14) { + // 14 days -- label as stale and warn + if (!prLabels.has('stale')) { + toAdd.push('stale'); + + const marker = ''; + // Check if we already left a stale warning + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: pr.number, + per_page: 10, + }); + const hasWarning = comments.some(c => + c.user?.login === 'github-actions[bot]' && c.body?.includes(marker) + ); + + if (!hasWarning) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: `${marker}\nThis PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.\n\nIf you're still working on this, please push an update or leave a comment.`, + }); + } + } + } else { + // Active -- remove stale label if present + if (prLabels.has('stale')) toRemove.push('stale'); + } + + // --- Collect files for overlap detection --- + try { + const { data: files } = await github.rest.pulls.listFiles({ + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + for (const file of files) { + if (!fileToPRs.has(file.filename)) { + fileToPRs.set(file.filename, []); + } + fileToPRs.get(file.filename).push(pr.number); + } + } catch { + // Ignore file listing failures + } + + // --- Apply label changes --- + if (toAdd.length > 0) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: toAdd, + }); + } + for (const label of toRemove) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr.number, + name: label, + }); + } catch { + // Label might not exist on the PR + } + } + + if (toAdd.length > 0 || toRemove.length > 0) { + core.info(`#${pr.number}: +[${toAdd.join(',')}] -[${toRemove.join(',')}]`); + } + } + + // --- Overlap detection --- + // Find files changed by multiple PRs + const overlaps = new Map(); // pr number -> set of overlapping PR numbers + for (const [file, prNumbers] of fileToPRs) { + if (prNumbers.length > 1) { + for (const prNum of prNumbers) { + if (!overlaps.has(prNum)) overlaps.set(prNum, new Set()); + for (const other of prNumbers) { + if (other !== prNum) overlaps.get(prNum).add(other); + } + } + } + } + + // Label PRs with significant overlap (3+ shared files with another PR) + for (const [prNum, otherPRs] of overlaps) { + // Count shared files per overlapping PR + const sharedFileCounts = new Map(); + for (const [file, prNumbers] of fileToPRs) { + if (prNumbers.includes(prNum)) { + for (const other of prNumbers) { + if (other !== prNum) { + sharedFileCounts.set(other, (sharedFileCounts.get(other) || 0) + 1); + } + } + } + } + + const significantOverlaps = [...sharedFileCounts.entries()] + .filter(([_, count]) => count >= 3) + .map(([otherPR, count]) => `#${otherPR} (${count} shared files)`); + + if (significantOverlaps.length > 0) { + const pr = prs.find(p => p.number === prNum); + const prLabels = new Set(pr?.labels.map(l => l.name) || []); + + if (!prLabels.has('overlap')) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNum, + labels: ['overlap'], + }); + + // Leave a comment about the overlap (only once) + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: prNum, + per_page: 20, + }); + const hasNotice = comments.some(c => + c.user?.login === 'github-actions[bot]' && c.body?.includes(marker) + ); + + if (!hasNotice) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNum, + body: `${marker}\n## Overlapping PRs\n\nThis PR modifies files that are also changed by other open PRs:\n\n${significantOverlaps.map(s => `- ${s}`).join('\n')}\n\nThis may cause merge conflicts or duplicated work. A maintainer will coordinate.`, + }); + } + + core.info(`#${prNum}: overlap with ${significantOverlaps.join(', ')}`); + } + } + } diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml new file mode 100644 index 0000000..2c567df --- /dev/null +++ b/.github/workflows/pr-triage.yml @@ -0,0 +1,240 @@ +name: PR Triage + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + name: Label PR + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Triage PR + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const pr = context.payload.pull_request; + const labels = new Set(); + + // --- Bot detection --- + const botLogins = ['dependabot[bot]', 'renovate[bot]', 'emdashbot[bot]']; + if (botLogins.includes(pr.user.login)) { + labels.add('bot'); + } + + // --- Size labels --- + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }); + + const linesChanged = files.reduce((sum, f) => sum + f.additions + f.deletions, 0); + const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL']; + let sizeLabel; + if (linesChanged < 10) sizeLabel = 'size/XS'; + else if (linesChanged < 50) sizeLabel = 'size/S'; + else if (linesChanged < 200) sizeLabel = 'size/M'; + else if (linesChanged < 500) sizeLabel = 'size/L'; + else sizeLabel = 'size/XL'; + labels.add(sizeLabel); + + // --- Area labels --- + const areaMap = { + 'area/core': (f) => f.startsWith('packages/core/'), + 'area/admin': (f) => f.startsWith('packages/admin/'), + 'area/plugins': (f) => f.startsWith('packages/plugins/'), + 'area/docs': (f) => f.startsWith('docs/'), + 'area/templates': (f) => f.startsWith('templates/'), + 'area/ci': (f) => f.startsWith('.github/'), + 'area/auth': (f) => f.startsWith('packages/auth/'), + 'area/cloudflare': (f) => f.startsWith('packages/cloudflare/'), + }; + + for (const file of files) { + for (const [label, matcher] of Object.entries(areaMap)) { + if (matcher(file.filename)) { + labels.add(label); + } + } + } + + // --- Merge conflict detection --- + // mergeable is available on the full PR object (may need a separate fetch + // since the webhook payload sometimes has mergeable=null while computing) + try { + const { data: fullPr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + }); + if (fullPr.mergeable === false) { + labels.add('needs-rebase'); + } + } catch { + // Ignore -- mergeable state may not be computed yet + } + + // --- CLA status --- + // The CLA assistant sets a commit status named "license/cla". + // Check the latest status for this PR's head SHA. + try { + const { data: statuses } = await github.rest.repos.listCommitStatusesForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: pr.head.sha, + }); + const claStatus = statuses.find(s => s.context === 'license/cla'); + if (claStatus) { + if (claStatus.state === 'success') { + labels.add('cla: signed'); + } else { + labels.add('cla: needed'); + } + } else { + // CLA check hasn't run yet -- mark as pending + labels.add('cla: needed'); + } + } catch { + // If we can't read statuses, don't add CLA labels + } + + // --- Ensure all labels exist, then apply --- + const labelColors = { + 'bot': 'ededed', + 'size/XS': '3cbf00', + 'size/S': '5dba3f', + 'size/M': 'fbca04', + 'size/L': 'ee9b00', + 'size/XL': 'd93f0b', + 'area/core': '0052cc', + 'area/admin': '7057ff', + 'area/plugins': '008672', + 'area/docs': '0075ca', + 'area/templates': 'bfdadc', + 'area/ci': '000000', + 'area/auth': 'd4c5f9', + 'area/cloudflare': 'f9a825', + 'cla: signed': '0e8a16', + 'cla: needed': 'b60205', + 'needs-rebase': 'e11d48', + }; + + // Get existing labels on the repo + const existingLabels = new Set(); + for await (const response of github.paginate.iterator( + github.rest.issues.listLabelsForRepo, + { owner: context.repo.owner, repo: context.repo.repo, per_page: 100 } + )) { + for (const label of response.data) { + existingLabels.add(label.name); + } + } + + // Create any missing labels + for (const label of labels) { + if (!existingLabels.has(label) && labelColors[label]) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + color: labelColors[label], + }); + } + } + + // Get current labels on the PR to remove stale ones + const currentLabels = new Set(pr.labels.map(l => l.name)); + + // Remove stale size labels (only one size label at a time) + for (const sl of sizeLabels) { + if (sl !== sizeLabel && currentLabels.has(sl)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: sl, + }); + } + } + + // Remove stale CLA labels + const claLabels = ['cla: signed', 'cla: needed']; + for (const cl of claLabels) { + if (!labels.has(cl) && currentLabels.has(cl)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: cl, + }); + } + } + + // Remove needs-rebase if PR is now mergeable + if (!labels.has('needs-rebase') && currentLabels.has('needs-rebase')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: 'needs-rebase', + }); + } + + // Add new labels + const toAdd = [...labels].filter(l => !currentLabels.has(l)); + if (toAdd.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: toAdd, + }); + } + + core.info(`Applied labels: ${[...labels].join(', ')}`); + + // --- Scope guard: comment on oversized PRs --- + // Only on open (not synchronize) to avoid spamming on every push + if (context.payload.action === 'opened') { + const warnings = []; + + if (linesChanged > 500) { + warnings.push(`This PR changes **${linesChanged.toLocaleString()} lines** across **${files.length} files**. Large PRs are harder to review and more likely to be closed without review.`); + } else if (files.length > 20) { + warnings.push(`This PR touches **${files.length} files**. PRs with a broad scope are harder to review. Please confirm the scope hasn't drifted beyond the intended change.`); + } + + // Check for cross-area changes (touching multiple packages) + const areas = Object.keys(areaMap).filter(a => labels.has(a)); + if (areas.length > 3) { + warnings.push(`This PR spans ${areas.length} different areas (${areas.join(', ')}). Consider breaking it into smaller, focused PRs.`); + } + + if (warnings.length > 0) { + const marker = ''; + const body = [ + marker, + '## Scope check', + '', + ...warnings, + '', + 'If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.', + '', + 'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for contribution guidelines.', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body, + }); + } + } diff --git a/AGENTS.md b/AGENTS.md index 725fbc8..21b5548 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,10 +28,10 @@ This is a pre-release project. Do not add backwards compatibility or legacy patt Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR. Key rules: +- **You MUST use the PR template.** Every PR must include the PR template with all sections filled out. The template is loaded automatically when you create a PR via the GitHub UI. If you create a PR via the API or CLI, copy the template from `.github/PULL_REQUEST_TEMPLATE.md` into the PR body. **PRs that do not use the template will be closed automatically by CI.** - **Features require a prior approved Discussion.** Do not open a feature PR without one. It will be closed. Open a [Discussion](https://github.com/emdash-cms/emdash/discussions/categories/ideas) in the Ideas category first. - **Bug fixes and docs** can be PRed directly. -- **Fill out the PR template completely.** Every section. Check every applicable checkbox. PRs with empty or skipped templates will be closed. -- **Check the AI disclosure box** in the PR template if any part of the code was AI-generated. +- **Check every applicable checkbox** in the PR template, including the "I have read CONTRIBUTING.md" box and the AI disclosure box if any part of the code was AI-generated. - **Do not make bulk/spray changes** (e.g., "fix all lint warnings", "add types everywhere", "improve error handling across codebase"). If you see a systemic issue, open a Discussion. - **Do not touch code outside the scope of your change.** No drive-by refactors, no "while I'm here" improvements, no added comments or logging in unrelated files. - **All CI checks must pass.** Typecheck, lint, format, and tests. No exceptions.