Mirror workspace activity on terminal tabs #231
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Pull request policy | |
| # Runs against fork pull requests with a write-scoped token via `pull_request_target`. | |
| # It NEVER checks out or executes pull-request code: it is pure GitHub API only, and all | |
| # contributor-controlled data (body, commit messages) is read as data, never interpolated | |
| # into a shell or script via `${{ }}`. | |
| # | |
| # On a pull request event the job is a status check: green when the pull request meets the | |
| # contribution policy, red when it does not. A non-compliant pull request from a non-maintainer | |
| # is labeled `invalid`, with one comment listing every reason. When it later becomes compliant | |
| # the label is cleared. An approving review from a maintainer (write/admin) waives every check, | |
| # so the job re-runs on review submit/dismiss to flip the status as the maintainer's stance changes. | |
| # | |
| # On a daily schedule the job sweeps open `invalid` pull requests: it re-evaluates each one, so a | |
| # pull request made compliant by an issue-side change (a maintainer adding `ready`, or reassigning | |
| # the issue) is cleared rather than closed, and only genuinely still-invalid, inactive ones are | |
| # closed. The verdict is published as the `pull-request-policy` commit status on the head SHA, not | |
| # the implicit job check, so a `pull_request_review` re-run can flip it (review-triggered runs do | |
| # not otherwise attach to the pull request head commit). Require that status in branch protection. | |
| on: | |
| pull_request_target: | |
| types: [opened, edited, reopened, synchronize, ready_for_review] | |
| pull_request_review: | |
| types: [submitted, dismissed] | |
| schedule: | |
| - cron: "17 6 * * *" | |
| workflow_dispatch: | |
| concurrency: | |
| group: pr-policy-${{ github.event.pull_request.number || 'sweep' }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| issues: read | |
| pull-requests: write | |
| statuses: write | |
| jobs: | |
| policy: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const marker = '<!-- supacode-pr-policy -->'; | |
| const staleMarker = '<!-- supacode-pr-stale -->'; | |
| const invalidLabel = 'invalid'; | |
| const statusContext = 'pull-request-policy'; | |
| const staleDays = 3; | |
| const { owner, repo } = context.repo; | |
| // `Co-authored-by:` / `Authored-by:` trailers are matched broadly, since naming a tool | |
| // there is explicit. The git author name is matched narrowly, because agent brands like | |
| // Claude and Gemini are also common human first names: only tool-specific forms count. | |
| const trailerAgent = /\b(claude|anthropic|copilot|chatgpt|openai|gpt-[0-9]|cursor|codex|devin[ -]?ai|aider|windsurf|sourcegraph|google-labs-jules|gemini)\b/i; | |
| const authorNameAgent = /\b(anthropic|copilot|chatgpt|openai|gpt-[0-9]|cursor|codex|devin[ -]?ai|aider|windsurf|sourcegraph|google-labs-jules|claude[ -]?code|gemini[ -]?(?:cli|code))\b/i; | |
| const trailerLine = /^[ \t]*(?:co-authored-by|authored-by):[ \t]*(.+)$/gim; | |
| const hasLabel = (pr, name) => (pr.labels || []).some((l) => l.name.toLowerCase() === name); | |
| async function getComments(number) { | |
| return github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number, per_page: 100 }); | |
| } | |
| // Returns { skip, reasons }. `skip` is true for bots and write-access contributors. | |
| // Throws on an indeterminate permission error so the run fails visibly rather than | |
| // passing or acting on the pull request while unsure of the author's access. | |
| async function evaluate(pr) { | |
| const author = pr.user.login; | |
| // Only allowlisted maintenance bots bypass. Any other bot, including an AI coding bot, | |
| // is enforced like a human so it cannot skip the linked-issue and human-author checks. | |
| const allowlistedBots = ['dependabot[bot]', 'renovate[bot]', 'github-actions[bot]']; | |
| if (allowlistedBots.includes(author)) return { skip: true, reasons: [] }; | |
| try { | |
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: author }); | |
| if (data.permission === 'admin' || data.permission === 'write') return { skip: true, reasons: [] }; | |
| } catch (error) { | |
| if (error.status !== 404) throw error; // 404 means "not a collaborator": enforce. | |
| } | |
| const reasons = []; | |
| // One round-trip fetches both the closing-issue references and the latest opinionated | |
| // review per write-access user (`writersOnly`), so the maintainer-approval waiver below | |
| // costs no extra API calls. | |
| const gql = await github.graphql( | |
| `query($owner:String!, $repo:String!, $number:Int!) { | |
| repository(owner:$owner, name:$repo) { | |
| pullRequest(number:$number) { | |
| closingIssuesReferences(first: 20) { nodes { number repository { owner { login } name } } } | |
| latestOpinionatedReviews(first: 20, writersOnly: true) { nodes { state } } | |
| } | |
| } | |
| }`, | |
| { owner, repo, number: pr.number }, | |
| ); | |
| const pull = gql.repository.pullRequest; | |
| // A maintainer greenlight overrides the policy: once a write/admin collaborator approves, | |
| // they have explicitly vetted the pull request, so every other check is waived. | |
| // `latestOpinionatedReviews` is each reviewer's current stance, so a later rejection or | |
| // dismissal drops the approval and revokes the waiver. | |
| if (pull.latestOpinionatedReviews.nodes.some((r) => r.state === 'APPROVED')) { | |
| return { skip: true, reasons: [] }; | |
| } | |
| // Linked issues. GitHub's closing references are the authority (they ignore keywords | |
| // inside HTML comments and code). The body regex is a fallback only when the reference | |
| // index is empty, and it strips comments, fenced and inline code, and quotes first so a | |
| // pasted `git commit -m "fixes #9"` is not mistaken for a link. | |
| const isSameRepo = (node) => | |
| node.repository.owner.login.toLowerCase() === owner.toLowerCase() && | |
| node.repository.name.toLowerCase() === repo.toLowerCase(); | |
| const candidates = new Set( | |
| pull.closingIssuesReferences.nodes.filter(isSameRepo).map((n) => n.number), | |
| ); | |
| if (candidates.size === 0) { | |
| const text = (pr.body || '') | |
| .replace(/<!--[\s\S]*?-->/g, '') | |
| .replace(/```[\s\S]*?```/g, '') | |
| .replace(/~~~[\s\S]*?~~~/g, '') | |
| .replace(/`[^`]*`/g, '') | |
| .replace(/^\s*>.*$/gm, ''); | |
| const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d{1,7})\b/gi; | |
| let m; | |
| while ((m = re.exec(text)) !== null) candidates.add(Number(m[1])); | |
| } | |
| let anyValidIssue = false; | |
| for (const number of candidates) { | |
| let issue; | |
| try { | |
| ({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: number })); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; // Transient error: fail, never mis-triage. | |
| continue; // Unknown issue number, ignore. | |
| } | |
| if (issue.pull_request || issue.state !== 'open') continue; // Not a real, open issue. | |
| anyValidIssue = true; | |
| const isReady = issue.labels.some((l) => (typeof l === 'string' ? l : l.name).toLowerCase() === 'ready'); | |
| if (!isReady) reasons.push(`Linked issue #${number} is not marked \`ready\` yet. A maintainer adds \`ready\` once the issue is triaged and approved for a pull request.`); | |
| const assignees = issue.assignees || []; | |
| if (assignees.length > 0 && !assignees.some((a) => a.id === pr.user.id)) { | |
| const names = [...new Set(assignees.map((a) => '@' + a.login))].join(', '); | |
| reasons.push(`Linked issue #${number} is assigned to ${names}, so it is reserved for them. Ask a maintainer to reassign it if they have stopped.`); | |
| } | |
| } | |
| if (!anyValidIssue) { | |
| reasons.push('No linked issue. Add a line like `Closes #123` to the description, pointing at an open issue in this repository.'); | |
| } | |
| // AI authorship. A human must be the author of record. Match only the display-name part | |
| // of a trailer (before the email), so a human co-author at `<x@openai.com>` is not flagged. | |
| const commits = await github.paginate(github.rest.pulls.listCommits, { | |
| owner, repo, pull_number: pr.number, per_page: 100, | |
| }); | |
| if (commits.length >= 250) { | |
| core.warning(`#${pr.number}: commit list may be truncated at ${commits.length}; AI-authorship check could miss later commits.`); | |
| } | |
| const aiAuthored = commits.some((c) => { | |
| const message = c.commit.message || ''; | |
| for (const match of message.matchAll(trailerLine)) { | |
| if (trailerAgent.test(match[1].split('<')[0])) return true; | |
| } | |
| return authorNameAgent.test((c.commit.author && c.commit.author.name) || ''); | |
| }); | |
| if (aiAuthored) { | |
| reasons.push('A commit is authored or co-authored by an AI agent. A human must be the author of record: re-author the commits under your own name (reset the author, or drop the `Co-authored-by:` trailer). Using AI tools is welcome, and you are encouraged to disclose them in the description.'); | |
| } | |
| return { skip: false, reasons }; | |
| } | |
| // Label and comment a non-compliant pull request. | |
| async function applyInvalid(pr, reasons, existing) { | |
| if (!hasLabel(pr, invalidLabel)) { | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [invalidLabel] }); | |
| } | |
| const list = reasons.map((reason) => `- ${reason}`).join('\n'); | |
| const body = | |
| `${marker}\n\n` + | |
| "Thanks for the pull request. It doesn't meet the contribution policy yet, so I've labeled it " + | |
| '`invalid`. Please address the following, then push an update:\n\n' + | |
| `${list}\n\n` + | |
| 'Nothing needs to be redone. A pull request left `invalid` is closed automatically after a few ' + | |
| 'days of inactivity. See [CONTRIBUTING.md](https://github.com/supabitapp/supacode/blob/main/CONTRIBUTING.md) for the full flow.'; | |
| if (existing) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); | |
| } else { | |
| await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); | |
| } | |
| } | |
| // Clear our own intervention once a pull request is compliant. Remove the label last so a | |
| // transient failure earlier leaves the marker in place to re-run on the next event. | |
| async function clearIntervention(pr, existing) { | |
| await github.rest.issues.updateComment({ | |
| owner, repo, comment_id: existing.id, | |
| body: `${marker}\n\nThanks, this now meets the contribution policy. I've cleared the \`invalid\` label.`, | |
| }); | |
| if (hasLabel(pr, invalidLabel)) { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: invalidLabel }).catch((error) => { | |
| if (error.status !== 404) throw error; | |
| }); | |
| } | |
| } | |
| // Publish the verdict on the head commit so it lands on the SHA branch protection reads, | |
| // regardless of which event triggered the run. | |
| async function setStatus(pr, state, description) { | |
| await github.rest.repos.createCommitStatus({ | |
| owner, repo, sha: pr.head.sha, state, context: statusContext, description: description.slice(0, 140), | |
| }); | |
| } | |
| if (context.eventName === 'schedule' || context.eventName === 'workflow_dispatch') { | |
| // Daily sweep: re-evaluate every open `invalid` pull request. | |
| const cutoff = Date.now() - staleDays * 24 * 60 * 60 * 1000; | |
| const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100 }); | |
| for (const pr of prs) { | |
| if (!hasLabel(pr, invalidLabel)) continue; | |
| try { | |
| const { skip, reasons } = await evaluate(pr); | |
| const comments = await getComments(pr.number); | |
| const existing = comments.find((c) => c.body && c.body.includes(marker)); | |
| // Now bypassed, approved, or compliant: clear the label and go green. | |
| if (skip || reasons.length === 0) { | |
| if (existing) await clearIntervention(pr, existing); | |
| await setStatus(pr, 'success', 'Meets the contribution policy.'); | |
| continue; | |
| } | |
| // Still non-compliant: close only if inactive past the cutoff. Do not re-comment on | |
| // the not-yet-stale ones, so the sweep never bumps `updated_at` and resets the | |
| // staleness clock. Close before commenting so a failed close is retried next run | |
| // rather than leaving a "closing" comment on a still-open pull request. | |
| if (new Date(pr.updated_at).getTime() > cutoff) continue; | |
| await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' }); | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: pr.number, | |
| body: | |
| `${staleMarker}\n\n` + | |
| `Closing this because it has been labeled \`invalid\` and inactive for more than ${staleDays} days. ` + | |
| 'Nothing is lost: address the points in the policy comment above and reopen it, and the checks ' + | |
| 'will re-run. See [CONTRIBUTING.md](https://github.com/supabitapp/supacode/blob/main/CONTRIBUTING.md).', | |
| }); | |
| core.info(`Closed stale invalid pull request #${pr.number}.`); | |
| } catch (error) { | |
| core.warning(`Sweep skipped #${pr.number}: ${error.status || error.message}`); | |
| } | |
| } | |
| return; | |
| } | |
| // Pull request or review event: resolve the single `pull-request-policy` status check. | |
| const pr = context.payload.pull_request; | |
| const { skip, reasons } = await evaluate(pr); | |
| const comments = await getComments(pr.number); | |
| const existing = comments.find((c) => c.body && c.body.includes(marker)); | |
| // A bypass, a maintainer approval, or a fully compliant pull request all pass: clear any | |
| // prior intervention and go green. | |
| if (skip || reasons.length === 0) { | |
| if (existing) await clearIntervention(pr, existing); | |
| await setStatus(pr, 'success', 'Meets the contribution policy.'); | |
| core.info(skip ? `Passed: ${pr.user.login} is a bot, maintainer, or has an approval.` : 'Compliant: all policy checks pass.'); | |
| return; | |
| } | |
| await applyInvalid(pr, reasons, existing); | |
| await setStatus(pr, 'failure', reasons[0]); | |
| core.notice(`#${pr.number} is invalid: ${reasons[0]}`); |