|
| 1 | +# PR-side twin of issue-triage.yml (InsForge `agent-zhang-beihai` pattern, part 2). |
| 2 | +# Enforces the issue-first workflow on community PRs: every non-docs PR must |
| 3 | +# carry a closing link ("Closes #123") to an issue that is ASSIGNED to the PR |
| 4 | +# author (claimed via `/assign`, which issue-triage.yml handles). Violations |
| 5 | +# get a `needs-issue` label + a sticky comment, and — for PRs opened after |
| 6 | +# GATE_SINCE — a failing check, so unclaimed work is visibly not review-ready. |
| 7 | +# PRs opened before GATE_SINCE are grandfathered: nudge only, never a red check. |
| 8 | +# |
| 9 | +# Assignment happens on the ISSUE, so it cannot re-trigger this PR workflow; |
| 10 | +# the comment tells the contributor to edit the PR description or push a |
| 11 | +# commit (`edited` / `synchronize`) to re-run the gate. |
| 12 | +# |
| 13 | +# Runs with NO checkout — metadata-only, so it is safe under pull_request_target |
| 14 | +# (which is required for fork PRs to get a write-capable token). |
| 15 | +# |
| 16 | +# Bot identity: posts as `testsprite-hob[bot]` when the App token works. The |
| 17 | +# App currently has Issues R/W + Metadata only — commenting/labeling a PULL |
| 18 | +# REQUEST needs the "Pull requests: Read & write" App permission (issues-API |
| 19 | +# endpoints are permission-checked by target type). Until an org admin adds |
| 20 | +# that permission and re-approves the installation, every call gracefully falls |
| 21 | +# back to the default GITHUB_TOKEN and posts as github-actions[bot]. |
| 22 | +# Secrets: TESTSPRITE_HOB_* preferred; the ALFHEIM_AGENT_* fallbacks are the |
| 23 | +# original names from before the App was renamed (same App ID + key). |
| 24 | +name: PR triage (issue-link gate) |
| 25 | + |
| 26 | +on: |
| 27 | + pull_request_target: |
| 28 | + types: [opened, edited, reopened, synchronize] |
| 29 | + |
| 30 | +permissions: |
| 31 | + pull-requests: write # add/remove the needs-issue label |
| 32 | + issues: write # create/update the nudge comment (PR comments ride the issues API) |
| 33 | + |
| 34 | +env: |
| 35 | + LABEL: 'needs-issue' |
| 36 | + MARKER: '<!-- hob:needs-issue -->' |
| 37 | + # PRs created before this instant are grandfathered (nudge, no failing check). |
| 38 | + GATE_SINCE: '2026-07-04T00:00:00Z' |
| 39 | + |
| 40 | +jobs: |
| 41 | + gate: |
| 42 | + # Public repo only — this file also lives in the private mirror, where PRs |
| 43 | + # are internal work that never links public issues. Skip bot authors |
| 44 | + # (dependabot etc.), maintainers, and docs-only changes (CONTRIBUTING |
| 45 | + # exempts docs/small fixes from the issue-first ask). |
| 46 | + if: >- |
| 47 | + github.repository == 'TestSprite/testsprite-cli' && |
| 48 | + github.event.pull_request.state == 'open' && |
| 49 | + github.event.pull_request.user.type != 'Bot' && |
| 50 | + !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) && |
| 51 | + !startsWith(github.event.pull_request.title, 'docs') |
| 52 | + runs-on: ubuntu-latest |
| 53 | + steps: |
| 54 | + # Mint an App token so actions post as testsprite-hob[bot]. Until the App + |
| 55 | + # secrets + Pull-requests permission exist this no-ops / gets 403 and the |
| 56 | + # script below falls back to the default token per-call. |
| 57 | + - id: app-token |
| 58 | + continue-on-error: true |
| 59 | + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 |
| 60 | + with: |
| 61 | + app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }} |
| 62 | + private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }} |
| 63 | + |
| 64 | + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1 |
| 65 | + env: |
| 66 | + APP_TOKEN: ${{ steps.app-token.outputs.token }} |
| 67 | + with: |
| 68 | + # `github` client = default GITHUB_TOKEN: used for all reads (the App |
| 69 | + # can't read PRs without the Pull-requests permission) and as the |
| 70 | + # write fallback. App client (when mintable) is preferred for writes. |
| 71 | + script: | |
| 72 | + const { owner, repo } = context.repo; |
| 73 | + const pr = context.payload.pull_request; |
| 74 | + const label = process.env.LABEL; |
| 75 | + const marker = process.env.MARKER; |
| 76 | + const author = pr.user.login; |
| 77 | +
|
| 78 | + const appClient = process.env.APP_TOKEN |
| 79 | + ? require('@actions/github').getOctokit(process.env.APP_TOKEN) |
| 80 | + : null; |
| 81 | + // Prefer the App identity; fall back to github-actions[bot] when the |
| 82 | + // App is absent or lacks the Pull-requests permission (403/404). |
| 83 | + async function write(fn) { |
| 84 | + if (appClient) { |
| 85 | + try { return await fn(appClient.rest); } |
| 86 | + catch (e) { if (e.status !== 403 && e.status !== 404) throw e; } |
| 87 | + } |
| 88 | + return await fn(github.rest); |
| 89 | + } |
| 90 | +
|
| 91 | + // Linked issues: GitHub-computed closing references carry number + |
| 92 | + // assignees in one query. |
| 93 | + const gql = await github.graphql( |
| 94 | + `query($owner:String!,$repo:String!,$num:Int!){ |
| 95 | + repository(owner:$owner,name:$repo){ |
| 96 | + pullRequest(number:$num){ |
| 97 | + closingIssuesReferences(first:10){ |
| 98 | + nodes{ number assignees(first:10){ nodes{ login } } } |
| 99 | + } |
| 100 | + } |
| 101 | + } |
| 102 | + }`, { owner, repo, num: pr.number }); |
| 103 | + const linkedIssues = gql.repository.pullRequest.closingIssuesReferences.nodes |
| 104 | + .map(n => ({ number: n.number, assignees: n.assignees.nodes.map(a => a.login) })); |
| 105 | +
|
| 106 | + // Body-text fallback for the brief window before GitHub computes the |
| 107 | + // link (same-repo bare "#N" references only). |
| 108 | + const bodyRefs = [...(pr.body || '') |
| 109 | + .matchAll(/(close[sd]?|fix(e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)/gi)] |
| 110 | + .map(m => Number(m[3])) |
| 111 | + .filter(n => !linkedIssues.some(i => i.number === n)) |
| 112 | + .slice(0, 5); |
| 113 | + for (const num of bodyRefs) { |
| 114 | + try { |
| 115 | + const { data } = await github.rest.issues.get({ owner, repo, issue_number: num }); |
| 116 | + if (data.pull_request) continue; // "#N" pointed at a PR, not an issue |
| 117 | + linkedIssues.push({ number: num, assignees: (data.assignees || []).map(a => a.login) }); |
| 118 | + } catch (e) { /* unknown number — ignore */ } |
| 119 | + } |
| 120 | +
|
| 121 | + const linked = linkedIssues.length > 0; |
| 122 | + const assignedToAuthor = linkedIssues.some(i => i.assignees.includes(author)); |
| 123 | + const state = assignedToAuthor ? 'ok' : (linked ? 'unassigned' : 'unlinked'); |
| 124 | +
|
| 125 | + const hasLabel = (pr.labels || []).some(l => l.name === label); |
| 126 | + const comments = await github.paginate(github.rest.issues.listComments, |
| 127 | + { owner, repo, issue_number: pr.number, per_page: 100 }); |
| 128 | + const nudge = comments.find(c => (c.body || '').includes(marker)); |
| 129 | + const stateLine = `<!-- hob:state:${state} -->`; |
| 130 | +
|
| 131 | + const contributingUrl = |
| 132 | + `https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md#contribution-model`; |
| 133 | + const rerunHint = 'After fixing it, edit the PR description or push a commit to re-run this check.'; |
| 134 | +
|
| 135 | + let body; |
| 136 | + if (state === 'ok') { |
| 137 | + body = `${marker}\n${stateLine}\n` |
| 138 | + + `✅ This PR is linked to an issue assigned to @${author} — thanks! ` |
| 139 | + + `The \`${label}\` label has been removed.`; |
| 140 | + } else if (state === 'unassigned') { |
| 141 | + const list = linkedIssues.map(i => { |
| 142 | + const holders = i.assignees.filter(a => a !== author); |
| 143 | + return `#${i.number}` + (holders.length ? ` (currently assigned to @${holders.join(', @')})` : ' (unassigned)'); |
| 144 | + }).join(', '); |
| 145 | + body = `${marker}\n${stateLine}\n` |
| 146 | + + `Thanks for the PR, @${author}! It links an issue, but that issue isn't assigned to you yet: ${list}. ` |
| 147 | + + `Per our workflow, **claim the issue first by commenting \`/assign\` on it** (the triage bot assigns you automatically). ` |
| 148 | + + `If it's already assigned to someone else, please coordinate with them or pick another issue — ` |
| 149 | + + `unclaimed-issue PRs are not reviewed. ${rerunHint} ` |
| 150 | + + `See [CONTRIBUTING → Contribution model](${contributingUrl}).`; |
| 151 | + } else { |
| 152 | + body = `${marker}\n${stateLine}\n` |
| 153 | + + `Thanks for the PR, @${author}! A quick note on our workflow: for **features and behavior changes** ` |
| 154 | + + `we require contributors to **open an issue first, claim it by commenting \`/assign\` on the issue, ` |
| 155 | + + `then submit a PR that links it** (e.g. \`Closes #123\`). This PR isn't linked to any issue yet, ` |
| 156 | + + `so it is not review-ready. ${rerunHint} ` |
| 157 | + + `See [CONTRIBUTING → Contribution model](${contributingUrl}).`; |
| 158 | + } |
| 159 | +
|
| 160 | + // Sticky comment: create once, update when the state changes. |
| 161 | + if (!nudge) { |
| 162 | + if (state !== 'ok') { |
| 163 | + await write(rest => rest.issues.createComment( |
| 164 | + { owner, repo, issue_number: pr.number, body })); |
| 165 | + } |
| 166 | + } else if (!nudge.body.includes(stateLine)) { |
| 167 | + await write(rest => rest.issues.updateComment( |
| 168 | + { owner, repo, comment_id: nudge.id, body })); |
| 169 | + } |
| 170 | +
|
| 171 | + // Label tracks the gate state. |
| 172 | + if (state === 'ok' && hasLabel) { |
| 173 | + await write(rest => rest.issues.removeLabel( |
| 174 | + { owner, repo, issue_number: pr.number, name: label })).catch(() => {}); |
| 175 | + } |
| 176 | + if (state !== 'ok' && !hasLabel) { |
| 177 | + await write(rest => rest.issues.addLabels( |
| 178 | + { owner, repo, issue_number: pr.number, labels: [label] })); |
| 179 | + } |
| 180 | +
|
| 181 | + // Hard gate for PRs opened after the cutoff; older PRs are nudged only. |
| 182 | + const gated = new Date(pr.created_at) >= new Date(process.env.GATE_SINCE); |
| 183 | + if (state !== 'ok' && gated) { |
| 184 | + core.setFailed(state === 'unlinked' |
| 185 | + ? 'No closing-linked issue. Open/claim an issue, add "Closes #<n>" to the PR description, then re-run.' |
| 186 | + : 'Linked issue is not assigned to the PR author. Comment /assign on the issue, then re-run.'); |
| 187 | + } else if (state !== 'ok') { |
| 188 | + core.notice(`Grandfathered PR (opened before ${process.env.GATE_SINCE}): gate not enforced, nudge only.`); |
| 189 | + } |
0 commit comments