# Mirrors an issue's triage state onto the "Auto-Triage" Projects v2 board. # # Reactive and decoupled from the triage bot: it watches label changes and, # whenever a `bot:repro` (trigger) or `triage/*` (state) label is added or # removed, recomputes the issue's canonical triage state from its CURRENT # labels and sets the board's "Triage State" single-select field. The triage # workflows just move labels as they always have; this never writes back to # them. # # It also keeps board membership in sync with the issue's open/closed state: # closing an issue ARCHIVES its card (it leaves the active board views but # stays recoverable in the project's archive, since triage is done once the # issue is closed); reopening unarchives and re-syncs it if it still carries # triage labels. # # Auth: mints an emdashbot App installation token (same APP_ID / APP_PRIVATE_KEY # secrets the other bot workflows use). The App needs organization "Projects: # Read and write" for the project mutations. name: Triage project sync on: issues: types: [labeled, unlabeled, closed, reopened] workflow_dispatch: # one-shot backfill of issues already carrying triage labels concurrency: # Serialize per issue so rapid reproducing -> reproduced transitions don't race. group: triage-project-sync-${{ github.event.issue.number || github.run_id }} cancel-in-progress: false permissions: contents: read jobs: sync: runs-on: ubuntu-latest # Skip PRs (issues.labeled fires for PRs too); always run the manual backfill. if: github.event_name == 'workflow_dispatch' || github.event.issue.pull_request == null steps: - name: Generate app token id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} # owner + repositories: org-scoped for Projects, but limited to this # one repo. permission-*: least privilege, just what the sync needs # (org Projects write; issues read for the workflow_dispatch backfill). owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} permission-organization-projects: write permission-issues: read - name: Sync triage state to project uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.app-token.outputs.token }} script: | const PROJECT_NUMBER = 3; const FIELD_NAME = "Triage State"; const owner = context.repo.owner; const repo = context.repo.repo; // Triage label -> board option name. const STATE_BY_LABEL = { "bot:repro": "Queued", "triage/reproducing": "Reproducing", "triage/reproduced": "Reproduced", "triage/by-design": "By design", "triage/awaiting-reporter": "Awaiting reporter", "triage/verified": "Verified", "triage/not-reproduced": "Not reproduced", "triage/failed": "Failed", "triage/skipped": "Skipped", }; // When several are present, the most advanced/terminal one wins. const PRECEDENCE = [ "triage/verified", "triage/awaiting-reporter", "triage/reproduced", "triage/by-design", "triage/not-reproduced", "triage/failed", "triage/skipped", "triage/reproducing", "bot:repro", ]; const pickState = (labels) => { for (const l of PRECEDENCE) if (labels.includes(l)) return STATE_BY_LABEL[l]; return null; }; const labelNames = (iss) => (iss.labels || []).map((l) => (typeof l === "string" ? l : l.name)); // Resolve the project, the single-select field, and its options. const projData = await github.graphql( `query($owner:String!, $num:Int!, $field:String!) { organization(login:$owner) { projectV2(number:$num) { id field(name:$field) { ... on ProjectV2SingleSelectField { id options { id name } } } } } }`, { owner, num: PROJECT_NUMBER, field: FIELD_NAME }, ); const project = projData.organization.projectV2; if (!project || !project.field) { core.setFailed(`Project #${PROJECT_NUMBER} or field "${FIELD_NAME}" not found`); return; } const fieldId = project.field.id; const optionId = (name) => project.field.options.find((o) => o.name === name)?.id; // Archive an issue's card on THIS board, if present. An issue can // belong to several projects; match on the project NODE ID, not the // number -- Projects v2 numbers are per-owner, so a foreign project // could also be #3 and a number match would target the wrong item. // Archiving is reversible (unlike delete) and hides the card from // active board views. Idempotent -- returns false when the issue // has no card here, and re-archiving an archived item is a no-op. const archiveCard = async (number) => { const data = await github.graphql( `query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { issue(number:$number) { projectItems(first:100) { nodes { id project { id } } } } } }`, { owner, repo, number }, ); const nodes = data.repository?.issue?.projectItems?.nodes || []; const item = nodes.find((n) => n.project?.id === project.id); if (!item) { // first:100 isn't paginated; warn if we hit the cap so a silent // miss on an issue attached to 100+ projects is at least visible. if (nodes.length === 100) { core.warning(`#${number}: on 100+ projects; could not confirm board membership`); } return false; } await github.graphql( `mutation($project:ID!, $item:ID!) { archiveProjectV2Item(input:{ projectId:$project, itemId:$item }) { item { id } } }`, { project: project.id, item: item.id }, ); return true; }; // Add the issue to the board (idempotent) and set its Triage State. const upsertState = async (iss) => { const stateName = pickState(labelNames(iss)); if (!stateName) { core.info(`#${iss.number}: no triage label; skipping`); return; } const optId = optionId(stateName); if (!optId) { core.warning(`#${iss.number}: no board option named "${stateName}"`); return; } // Idempotent: returns the existing item if the issue is already added. const added = await github.graphql( `mutation($project:ID!, $content:ID!) { addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } } }`, { project: project.id, content: iss.node_id }, ); const itemId = added.addProjectV2ItemById.item.id; // addProjectV2ItemById returns an EXISTING item as-is -- including // when it was archived on close -- so unarchive to bring a // reopened issue's card back onto the active board. No-op for // items that are already active. await github.graphql( `mutation($project:ID!, $item:ID!) { unarchiveProjectV2Item(input:{ projectId:$project, itemId:$item }) { item { id } } }`, { project: project.id, item: itemId }, ); await github.graphql( `mutation($project:ID!, $item:ID!, $field:ID!, $opt:String!) { updateProjectV2ItemFieldValue(input:{ projectId:$project, itemId:$item, fieldId:$field, value:{ singleSelectOptionId:$opt } }) { projectV2Item { id } } }`, { project: project.id, item: itemId, field: fieldId, opt: optId }, ); core.info(`#${iss.number} -> ${stateName}`); }; // Build the work list. let issues = []; if (context.eventName === "workflow_dispatch") { const all = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: "all", per_page: 100, }); issues = all.filter( (iss) => !iss.pull_request && labelNames(iss).some((l) => l === "bot:repro" || l.startsWith("triage/")), ); } else { const iss = context.payload.issue; if (!iss || iss.pull_request) return; issues = [iss]; } // Per-issue try/catch so the bulk backfill reconciles every issue // even if one fails (e.g. a transient GraphQL error). Single-issue // event runs still fail loudly via setFailed below, since the next // event would otherwise be the only chance to recover. let failures = 0; for (const iss of issues) { try { // Closed issues never belong on the board: triage is finished // once the issue is closed (however it was closed). Handling it // here -- rather than only on the `closed` event -- means label // changes on an already-closed issue, and the dispatch backfill, // also reconcile, so the board self-heals to "open triaged // issues only". Reopening hits the else-branch with state "open" // and is re-added below if it still carries triage labels. if ((iss.state || "").toLowerCase() === "closed") { const archived = await archiveCard(iss.number); core.info(`#${iss.number}: closed; ${archived ? "archived" : "not on board"}`); continue; } await upsertState(iss); } catch (e) { failures++; core.warning(`#${iss.number}: sync failed: ${e.message}`); } } if (failures > 0 && context.eventName !== "workflow_dispatch") { core.setFailed(`${failures} issue(s) failed to sync`); }