diff --git a/.github/scripts/extract-workflow-js.js b/.github/scripts/extract-workflow-js.js new file mode 100644 index 00000000000..105e7c39c60 --- /dev/null +++ b/.github/scripts/extract-workflow-js.js @@ -0,0 +1,95 @@ +// Extract a named, inlined JavaScript block from a GitHub workflow YAML and +// load it as a module. +// +// Workflows sometimes inline JS into a `script:` literal (e.g. so a reusable +// workflow needs no checkout when called cross-repo). To keep that JS unit- +// testable, wrap the block in sentinel comments that NAME it: +// +// // ----- extract-js::begin ----- +// ...the functions... +// // ----- extract-js::end ----- +// +// `load({workflow, block})` reads that block, dedents it (the inverse of the +// indent it carries inside the `script:` literal, so the result is byte- +// identical to standalone source), appends `module.exports` for every top-level +// declaration, writes a temp file, and requires it. Tests then exercise exactly +// what the workflow runs. Nothing here is specific to any one workflow or block. +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +// Repo root, so callers can pass a repo-relative workflow path. +const REPO_ROOT = path.join(__dirname, "..", ".."); + +function markerPair(block) { + if (!block || !/^[\w.-]+$/.test(block)) + throw new Error(`invalid block name: ${JSON.stringify(block)}`); + return { begin: `extract-js:${block}:begin`, end: `extract-js:${block}:end` }; +} + +// The dedented JavaScript source of the named block within `yamlText`. +function extractSource(yamlText, block) { + const { begin, end } = markerPair(block); + const bi = yamlText.indexOf(begin); + const ei = yamlText.indexOf(end); + if (bi < 0 || ei < 0 || ei < bi) + throw new Error(`extract-js markers for block "${block}" not found`); + const between = yamlText.slice(yamlText.indexOf("\n", bi) + 1, yamlText.lastIndexOf("\n", ei) + 1); + const lines = between.split("\n"); + let min = Infinity; + for (const l of lines) { + if (l.trim() === "") continue; + const indent = l.length - l.replace(/^\s+/, "").length; + if (indent < min) min = indent; + } + if (!isFinite(min)) min = 0; + return lines.map((l) => l.slice(min)).join("\n").replace(/\s+$/, "") + "\n"; +} + +function resolveWorkflow(workflow) { + if (!workflow) throw new Error("workflow path is required"); + return path.isAbsolute(workflow) ? workflow : path.join(REPO_ROOT, workflow); +} + +// `extractSource` + a `module.exports` of every top-level function/const, +// ready to write to a .js file. +function buildModule({ workflow, block }) { + const src = extractSource(fs.readFileSync(resolveWorkflow(workflow), "utf8"), block); + const names = new Set(); + const re = /^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|^const\s+([A-Za-z_$][\w$]*)\s*=/gm; + let m; + while ((m = re.exec(src))) names.add(m[1] || m[2]); + return `"use strict";\n${src}\nmodule.exports = { ${[...names].join(", ")} };\n`; +} + +// Build, write to a unique temp file, and require it. +function load({ workflow, block }) { + const code = buildModule({ workflow, block }); + const tmp = path.join(os.tmpdir(), `wf-extract-${block}-${process.pid}-${Date.now()}.js`); + fs.writeFileSync(tmp, code); + try { + return require(tmp); + } finally { + fs.unlinkSync(tmp); + } +} + +module.exports = { extractSource, buildModule, load }; + +// CLI: `node extract-workflow-js.js [out.js]` +if (require.main === module) { + const [workflow, block, out] = process.argv.slice(2); + if (!workflow || !block) { + console.error("usage: extract-workflow-js.js [out.js]"); + process.exit(2); + } + const code = buildModule({ workflow, block }); + if (out) { + fs.writeFileSync(out, code); + console.log(`wrote ${out}`); + } else { + process.stdout.write(code); + } +} diff --git a/.github/scripts/pr-assign.test.js b/.github/scripts/pr-assign.test.js new file mode 100644 index 00000000000..878c6234d49 --- /dev/null +++ b/.github/scripts/pr-assign.test.js @@ -0,0 +1,91 @@ +// Unit tests for the assignee/reviewer selection (the JS port of +// select_assignee_and_reviewers), run against the copy INLINED in +// pr-board-sync.yml (the single source of truth), extracted at run time. +// No deps; run with: node .github/scripts/pr-assign.test.js +// Mirrors TestSelectAssigneeAndReviewers in the slang-skills test_pr_sweep.py. +"use strict"; + +const assert = require("node:assert"); +const { selectAssigneeAndReviewers } = require("./extract-workflow-js.js").load({ + workflow: ".github/workflows/pr-board-sync.yml", + block: "assignment", +}); + +const OWNERS = new Set(["owner1", "owner2"]); +const COLLAB = new Set(["owner1", "owner2", "dev1", "dev2"]); // owners are also collaborators +const MAINT = "maintainer"; + +function select(issue, committers, extra) { + return selectAssigneeAndReviewers({ + issueAssignees: issue, committersBySignal: committers, + owners: OWNERS, collaborators: COLLAB, author: "author", maintainer: MAINT, + ...(extra || {}), + }); +} + +const tests = []; +const test = (name, fn) => tests.push([name, fn]); + +test("issue assignee wins", () => { + const { assignee, reviewers } = select(["owner2"], ["owner1", "dev1"]); + assert.strictEqual(assignee, "owner2"); + assert.ok(reviewers.includes("owner2")); +}); + +test("commit-signal owner when no issue", () => { + const { assignee } = select([], ["owner1", "owner2"]); + assert.strictEqual(assignee, "owner1"); +}); + +test("maintainer fallback", () => { + const { assignee, reviewers } = select([], ["dev1"]); + assert.strictEqual(assignee, "maintainer"); + assert.deepStrictEqual(reviewers, ["maintainer", "dev1"]); +}); + +test("extra reviewer is top collaborator-not-owner", () => { + const { assignee, reviewers } = select([], ["dev2", "owner1", "dev1"]); + assert.strictEqual(assignee, "owner1"); + assert.deepStrictEqual(reviewers, ["owner1", "dev2"]); +}); + +test("no collaborator committer means only assignee", () => { + const { reviewers } = select([], ["owner1", "owner2"]); + assert.deepStrictEqual(reviewers, ["owner1"]); +}); + +test("author never requested as reviewer", () => { + const { assignee, reviewers } = selectAssigneeAndReviewers({ + issueAssignees: [], committersBySignal: ["dev1"], owners: OWNERS, + collaborators: COLLAB, author: "maintainer", maintainer: "maintainer", + }); + assert.strictEqual(assignee, "maintainer"); + assert.deepStrictEqual(reviewers, ["dev1"]); // author(maintainer) excluded +}); + +test("real existing reviewer blocks adding", () => { + const { assignee, reviewers } = select([], ["dev2", "owner1"], { + existingReviewers: ["dave"], ignoredReviewers: new Set(["bmillsNV"]), + }); + assert.strictEqual(assignee, "owner1"); + assert.deepStrictEqual(reviewers, []); +}); + +test("ignored and bot reviewers do not count as existing", () => { + const { assignee, reviewers } = select([], ["dev2", "owner1"], { + existingReviewers: ["bmillsNV", "copilot[bot]"], + botAuthors: ["nv-slang-bot"], ignoredReviewers: new Set(["bmillsNV"]), + }); + assert.strictEqual(assignee, "owner1"); + assert.deepStrictEqual(reviewers, ["owner1", "dev2"]); +}); + +(async () => { + let passed = 0, failed = 0; + for (const [name, fn] of tests) { + try { await fn(); passed++; } + catch (e) { failed++; console.error(`FAIL: ${name}\n ${(e && e.stack) || e}`); } + } + console.log(`${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +})(); diff --git a/.github/scripts/pr-signal.test.js b/.github/scripts/pr-signal.test.js new file mode 100644 index 00000000000..3d67ca91364 --- /dev/null +++ b/.github/scripts/pr-signal.test.js @@ -0,0 +1,185 @@ +// Unit tests for the committer-signal ranking (the JS port of pr_signal.py), +// run against the copy INLINED in pr-board-sync.yml (the single source of +// truth), extracted at run time. No deps; run with: +// node .github/scripts/pr-signal.test.js +// Mirrors the pure-helper and cache tests in the slang-skills test_pr_sweep.py. +"use strict"; + +const assert = require("node:assert"); +const s = require("./extract-workflow-js.js").load({ + workflow: ".github/workflows/pr-board-sync.yml", + block: "assignment", +}); + +const tests = []; +const test = (name, fn) => tests.push([name, fn]); +const approx = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} !~= ${b}`); + +// --- classification -------------------------------------------------------- +test("classify_is_bot variants", () => { + const bots = ["nv-slang-bot", "slang-coworker-nanoclaw"]; + assert.ok(s.classifyIsBot("nv-slang-bot", bots)); + assert.ok(s.classifyIsBot("nv-slang-bot[bot]", bots)); + assert.ok(s.classifyIsBot("app/nv-slang-bot", bots)); + assert.ok(s.classifyIsBot("slang-coworker-nanoclaw[bot]", bots)); + assert.ok(!s.classifyIsBot("some-human", bots)); +}); + +test("is_bot_login", () => { + const bots = ["nv-slang-bot"]; + assert.ok(s.isBotLogin("github-actions[bot]", bots)); + assert.ok(s.isBotLogin("nv-slang-bot", bots)); + assert.ok(s.isBotLogin("", bots)); + assert.ok(!s.isBotLogin("alice", bots)); +}); + +// --- attribution ----------------------------------------------------------- +const BOTS = ["nv-slang-bot"]; +test("attribute: human author credited", () => + assert.strictEqual(s.attributeCommit("alice", null, "author", BOTS), "alice")); +test("attribute: author-self uses approver", () => + assert.strictEqual(s.attributeCommit("author", "carol", "author", BOTS), "carol")); +test("attribute: author-self no approver dropped", () => + assert.strictEqual(s.attributeCommit("author", null, "author", BOTS), null)); +test("attribute: author-self approver-is-author dropped", () => + assert.strictEqual(s.attributeCommit("author", "author", "author", BOTS), null)); +test("attribute: bot author uses approver", () => + assert.strictEqual(s.attributeCommit("nv-slang-bot", "carol", "author", BOTS), "carol")); +test("attribute: unmapped author uses approver", () => + assert.strictEqual(s.attributeCommit("", "carol", "author", BOTS), "carol")); +test("attribute: bot author no approver dropped", () => + assert.strictEqual(s.attributeCommit("nv-slang-bot", null, "author", BOTS), null)); +test("attribute: bot author approver-is-pr-author dropped", () => + assert.strictEqual(s.attributeCommit("nv-slang-bot", "author", "author", BOTS), null)); +test("attribute: bot author approver-is-bot dropped", () => + assert.strictEqual(s.attributeCommit("nv-slang-bot", "nv-slang-bot", "author", BOTS), null)); + +// --- weighting ------------------------------------------------------------- +const TABLE = [["source/slang/**", 3.0], ["source/**", 2.0], ["tests/**", 1.0]]; +test("multiplier most-specific wins", () => { + assert.strictEqual(s.matchFileMultiplier("source/slang/x.cpp", TABLE, 1.0), 3.0); + assert.strictEqual(s.matchFileMultiplier("source/core/x.cpp", TABLE, 1.0), 2.0); + assert.strictEqual(s.matchFileMultiplier("tests/x.slang", TABLE, 1.0), 1.0); + assert.strictEqual(s.matchFileMultiplier("README.md", TABLE, 1.0), 1.0); +}); + +test("per_file_signals", () => { + const sig = s.perFileSignals({ "source/slang/a.cpp": 10.0, "tests/b.slang": 10.0 }, TABLE, 1.0); + assert.deepStrictEqual(sig, { "source/slang/a.cpp": 30.0, "tests/b.slang": 10.0 }); +}); + +test("top_k_weights normalizes", () => { + const w = s.topKWeights({ a: 30.0, b: 10.0 }, 10); + approx(w.a, 0.75); approx(w.b, 0.25); +}); +test("top_k_weights limits", () => { + const w = s.topKWeights({ a: 3.0, b: 2.0, c: 1.0 }, 2); + assert.deepStrictEqual(new Set(Object.keys(w)), new Set(["a", "b"])); +}); +test("top_k_weights empty when no signal", () => + assert.deepStrictEqual(s.topKWeights({ a: 0.0 }, 5), {})); + +test("overall_signal sums weight*loc", () => { + const overall = s.overallSignal( + { f1: 0.75, f2: 0.25 }, { f1: { alice: 40.0, bob: 10.0 }, f2: { bob: 20.0 } }); + approx(overall.alice, 30.0); + approx(overall.bob, 0.75 * 10 + 0.25 * 20); +}); + +test("rank_logins descending", () => + assert.deepStrictEqual(s.rankLogins({ alice: 30.0, bob: 12.5 }), ["alice", "bob"])); + +// --- tiebreak bookkeeping -------------------------------------------------- +test("needs_tiebreak close", () => + assert.ok(s.needsTiebreak({ a: 10.0, b: 8.0 }, ["a", "b"], 1.5))); +test("no tiebreak clear winner", () => + assert.ok(!s.needsTiebreak({ a: 20.0, b: 8.0 }, ["a", "b"], 1.5))); +test("no tiebreak single candidate", () => + assert.ok(!s.needsTiebreak({ a: 5.0 }, ["a"], 1.5))); +test("no tiebreak second zero", () => + assert.ok(!s.needsTiebreak({ a: 5.0, b: 0.0 }, ["a", "b"], 1.5))); + +test("merge_refined reorders top-n keeps tail", () => + assert.deepStrictEqual( + s.mergeRefined(["a", "b", "c", "d"], 2, { a: 1.0, b: 5.0 }), ["b", "a", "c", "d"])); +test("merge_refined all finalists", () => + assert.deepStrictEqual(s.mergeRefined(["a", "b"], 2, { a: 1.0, b: 2.0 }), ["b", "a"])); + +// --- cache behavior (fake gh) ---------------------------------------------- +function countingGh() { + const gh = { graphqlCalls: 0, commitCalls: 0 }; + gh.graphql = async (query) => { + gh.graphqlCalls++; + const target = {}; + let i = 0; + while (query.includes(`f${i}:`)) i++; + for (let j = 0; j < Math.max(i, 1); j++) { + target[`f${j}`] = { nodes: [{ + oid: `sha${j}`, additions: 10, deletions: 2, + author: { user: { login: "alice" } }, + associatedPullRequests: { nodes: [] }, + }] }; + } + return { repository: { defaultBranchRef: { target } } }; + }; + gh.commitFiles = async () => { + gh.commitCalls++; + return [{ filename: "a.cpp", additions: 5, deletions: 1 }]; + }; + return gh; +} + +test("file history cached across calls", async () => { + const gh = countingGh(); + const cache = new Map(); + await s.fetchFileHistories(gh, "o/r", ["a.cpp", "b.cpp"], "S", 5, cache); + assert.strictEqual(gh.graphqlCalls, 1); + await s.fetchFileHistories(gh, "o/r", ["a.cpp", "b.cpp"], "S", 5, cache); + assert.strictEqual(gh.graphqlCalls, 1); // fully cached + await s.fetchFileHistories(gh, "o/r", ["a.cpp", "c.cpp"], "S", 5, cache); + assert.strictEqual(gh.graphqlCalls, 2); // only the uncached path +}); + +test("file history keyed by repo", async () => { + const gh = countingGh(); + const cache = new Map(); + await s.fetchFileHistories(gh, "o/r1", ["a.cpp"], "S", 5, cache); + await s.fetchFileHistories(gh, "o/r2", ["a.cpp"], "S", 5, cache); + assert.strictEqual(gh.graphqlCalls, 2); // same path, different repo -> not shared +}); + +test("commit file loc cached by repo+sha", async () => { + const gh = countingGh(); + const cache = new Map(); + assert.strictEqual(await s.commitFileLoc(gh, "o/r", "deadbeef", "a.cpp", cache), 5.0); + await s.commitFileLoc(gh, "o/r", "deadbeef", "a.cpp", cache); // cached + assert.strictEqual(gh.commitCalls, 1); + await s.commitFileLoc(gh, "o/r", "cafef00d", "a.cpp", cache); // new sha + assert.strictEqual(gh.commitCalls, 2); +}); + +// --- end-to-end ranking (fake gh) ------------------------------------------ +test("compute_committer_ranking returns ranked logins", async () => { + const gh = countingGh(); + const ranked = await s.computeCommitterRanking(gh, "o/r", "author", { + locByFile: { "source/slang/a.cpp": 10.0 }, + fileMultipliers: TABLE, + defaultMultiplier: 1.0, + topFiles: 10, + commits: 5, + horizonDays: 180, + botAuthors: BOTS, + since: "S", + }); + assert.deepStrictEqual(ranked, ["alice"]); // only alice authored the history +}); + +(async () => { + let passed = 0, failed = 0; + for (const [name, fn] of tests) { + try { await fn(); passed++; } + catch (e) { failed++; console.error(`FAIL: ${name}\n ${(e && e.stack) || e}`); } + } + console.log(`${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +})(); diff --git a/.github/workflows/add-pr-to-project.yml b/.github/workflows/add-pr-to-project.yml deleted file mode 100644 index c16f04e254a..00000000000 --- a/.github/workflows/add-pr-to-project.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: Add PR to Project Board - -# Adds new PRs to the shader-slang project board and sets the "Source" field -# to "Internal" (org member) or "Community" (everyone else). -# Also tags any bot-created PRs as "Bot". -# -# Uses pull_request_target so secrets are available for fork PRs. -# This workflow only reads PR metadata and makes API calls — it never -# checks out or executes PR code. - -on: - pull_request_target: - types: [opened] - -jobs: - add-to-project: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Check if PR author is in shader-slang org - id: check_org - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - github-token: ${{ secrets.SLANGBOT_MEMBERS_READONLY }} - script: | - const user = context.payload.pull_request.user; - const username = user.login; - - let source; - if (user.type === 'Bot') { - source = 'Bot'; - } else { - let isOrgMember = false; - - try { - await github.rest.orgs.checkMembershipForUser({ - org: 'shader-slang', - username, - }); - isOrgMember = true; - } catch (error) { - if (error.status !== 404) { - throw error; - } - } - source = isOrgMember ? 'Internal' : 'Community'; - - console.log(`PR author: ${username}`); - console.log(`Is org member: ${isOrgMember}`); - } - - core.setOutput('source', source); - - - name: Add PR to project board and set Source field - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - SOURCE: ${{ steps.check_org.outputs.source }} - with: - github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} - script: | - // Project and field IDs for: - // https://github.com/orgs/shader-slang/projects/13 - // - // To re-query these IDs, run: - // gh api graphql -f query='{ organization(login: "shader-slang") { - // projectV2(number: 13) { id fields(first: 20) { nodes { - // ... on ProjectV2SingleSelectField { id name options { id name } } - // } } } } }' - const PROJECT_ID = 'PVT_kwDOAb2kZs4BSJKy'; - const SOURCE_FIELD_ID = 'PVTSSF_lADOAb2kZs4BSJKyzg_w_AM'; - const SOURCE_OPTIONS = { - 'Internal': '407f2ea2', - 'Community': '31a0b1ae', - 'Bot': 'e3f25255', - }; - - const source = process.env.SOURCE; - const optionId = SOURCE_OPTIONS[source]; - if (!optionId) { - throw new Error(`Unknown source value: ${source}`); - } - const prNodeId = context.payload.pull_request.node_id; - - // Add the PR to the project (idempotent if already present) - const addResult = await github.graphql(` - mutation($projectId: ID!, $contentId: ID!) { - addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { - item { id } - } - } - `, { - projectId: PROJECT_ID, - contentId: prNodeId, - }); - - const itemId = addResult?.addProjectV2ItemById?.item?.id; - if (!itemId) { - throw new Error('Failed to add PR to project: no item ID returned'); - } - console.log(`Added PR to project, item ID: ${itemId}`); - - // Set the Source field - const updateResult = await github.graphql(` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId, - itemId: $itemId, - fieldId: $fieldId, - value: { singleSelectOptionId: $optionId } - }) { - projectV2Item { id } - } - } - `, { - projectId: PROJECT_ID, - itemId: itemId, - fieldId: SOURCE_FIELD_ID, - optionId: optionId, - }); - - if (!updateResult?.updateProjectV2ItemFieldValue?.projectV2Item?.id) { - throw new Error('Failed to set Source field: unexpected response'); - } - - console.log(`Set Source field to: ${source}`); diff --git a/.github/workflows/check-workflow-scripts.yml b/.github/workflows/check-workflow-scripts.yml new file mode 100644 index 00000000000..6fa528f7ae0 --- /dev/null +++ b/.github/workflows/check-workflow-scripts.yml @@ -0,0 +1,49 @@ +name: Check Workflow Scripts + +# Lightweight unit tests for the JavaScript used by our GitHub workflows +# (committer-signal ranking + assignee/reviewer selection inlined into +# pr-board-sync.yml, extracted at test time). This is the single home for +# workflow-JS test coverage: any new .github/scripts/*.test.js is picked up +# automatically by the run step below -- no edit here needed. +# +# `paths` intentionally covers ALL of .github/workflows and .github/scripts so a +# change to any workflow (a test may extract inlined JS from one) or any script +# re-runs the tests. The job is a ~1s no-setup Node run, so running it on +# unrelated workflow edits is cheap and avoids the footgun of having to remember +# to list each tested workflow here. + +on: + pull_request: + branches: [master] + types: [opened, synchronize, reopened, ready_for_review] + paths: + - ".github/scripts/**" + - ".github/workflows/**" + merge_group: + types: [checks_requested] + +jobs: + check-workflow-scripts: + if: github.event_name != 'pull_request' || github.event.pull_request.draft != true + runs-on: ubuntu-24.04 + name: Unit-test workflow JavaScript + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + + - name: Run all workflow-script unit tests + run: | + node --version + status=0 + shopt -s nullglob + found=0 + for t in .github/scripts/*.test.js; do + found=1 + echo "::group::$t" + node "$t" || status=1 + echo "::endgroup::" + done + if [ "$found" -eq 0 ]; then + echo "No .github/scripts/*.test.js found" + exit 1 + fi + exit $status diff --git a/.github/workflows/pr-board-sync.yml b/.github/workflows/pr-board-sync.yml new file mode 100644 index 00000000000..ab6d0515b6e --- /dev/null +++ b/.github/workflows/pr-board-sync.yml @@ -0,0 +1,1296 @@ +name: PR Board Sync (reusable) + +# Reusable workflow that drives an open PR one event-step toward its correct +# state on the shared "Slang PR Tracking" ProjectsV2 board: +# - adds the PR to the board (idempotent), +# - classifies + sets its Source (Internal / Community / Bot), +# - advances its Status (In Review / Revising / Snagged / Approved / Done) +# on the event-driven happy path, +# - backfills the assignee + reviewers (an Internal PR's author; otherwise a +# linked-issue owner, else the top committer-signal owner, else the +# maintainer) and requests reviewers, and +# - unrequests auto-assigned reviewers that cannot approve (e.g. bmillsNV). +# +# Status state machine (two variants that differ ONLY on a CI failure): +# - In Review - default for an open PR; also re-set whenever a new commit +# arrives (the commit has no review feedback yet). A Bot PR's +# draft lands here too, so a human owner can see/shepherd it +# (unless a reviewer requested changes, which wins -> Revising). +# - Revising - the author is still working: a human draft, or a reviewer +# requested changes (not yet superseded by a newer commit). A Bot +# PR's CI failure also lands here. +# - Snagged - needs a human's attention: a human PR's CI failed, or an +# approved PR has green CI but is not in the merge queue (it fell +# out, or needs a human to enqueue/merge it). +# - Approved - not a draft, already has an approving review, and is still +# waiting on something automated: CI is pending, or it is sitting +# in the merge queue. +# - Done - the PR is closed (merged or otherwise); terminal. +# Bot vs. human: on a CI failure a Bot PR goes to Revising (the bot fixes it +# itself); a human PR (Internal/Community) goes to Snagged. The merge-queue drop +# is Snagged for both, since a human assignee always resolves that. +# +# This is the event-driven half of the slang-pr-maintenance state machine. It +# replicates the sweep's full assignee/reviewer selection -- committer-signal +# ranking + selection, faithful ports of scripts/pr_signal.py and pr_common.py +# inlined into the Reconcile step (so this reusable workflow needs no checkout +# when called from another repo). That inlined block is the single source of +# truth; .github/scripts/{pr-signal,pr-assign}.test.js extract it from this YAML +# (via extract-workflow-js.js) and unit-test it. A periodic sweep (run on a +# cadence; see pr-sweep-nightly.yml / mode: sweep) remains the idempotent safety +# net for what events cannot deliver -- missed webhooks or failed runs, a +# manually linked issue or post-hoc issue assignment, board drift, repos not yet +# onboarded -- and reconciles the whole board. +# +# Cross-repo reuse: any shader-slang repo adds a thin caller workflow that +# declares the PR event triggers and calls this one, mapping the single required +# secret explicitly (no `secrets: inherit`, and no `permissions:` block needed — +# this workflow uses the PAT for everything, not the GITHUB_TOKEN): +# +# jobs: +# board-sync: +# uses: shader-slang/slang/.github/workflows/pr-board-sync.yml@master +# secrets: +# SLANG_PR_BOT_TOKEN: ${{ secrets.SLANG_PR_BOT_TOKEN }} +# +# All shader-slang repos feed the SAME board, so every input below already +# defaults to the shared board's values; callers pass only the one secret. +# +# Required secret (a shader-slang **org-level** secret, shared org-wide): +# SLANG_PR_BOT_TOKEN - a dedicated bot PAT used for EVERY GitHub call this +# workflow makes (so growing its capabilities never changes the caller +# contract — you widen this PAT's scopes centrally, callers are untouched). +# Scopes needed: org Projects read+write (board) and Members read; repo +# Contents read, Pull requests write, Issues write, Metadata read. Reading a +# collaborator's permission needs repo read; on any read error the Source +# classification fails safe to Community. +# +# Safety: every job runs only on PR metadata / API calls and never checks out or +# executes PR code, so `pull_request_target` (secrets available for fork PRs) is +# safe here. This is also why the privileged `workflow_run` path (below) is safe. +# +# Triggers and fork secrets (PUBLIC repo): callers drive this via privileged +# events that DO receive the secret for fork PRs -- `pull_request_target` (PR +# lifecycle/commits), `check_suite` (CI), `workflow_run` (the fork-review relay), +# and `schedule` (the sweep). `pull_request_review` only receives the secret for +# ORIGIN PRs; a fork review runs the fork head with no secret, so the caller +# routes fork reviews through an unprivileged bridge whose completion fires a +# privileged `workflow_run` here (see the dispatch's `workflow_run` branch). When +# no token is delivered, every privileged step is gated off (HAS_TOKEN) and the +# run skips cleanly; the sweep is the backstop. + +on: + workflow_call: + inputs: + mode: + description: "event (default): reconcile the single PR from the triggering event. sweep: reconcile every open PR in this repo (for a scheduled cadence run), the periodic backstop for events the per-event path cannot see." + type: string + default: "event" + project_id: + description: "ProjectsV2 board node id (the 'Slang PR Tracking' board)." + type: string + default: "PVT_kwDOAb2kZs4BSJKy" + status_field: + description: "Single-select field name for the board Status." + type: string + default: "Status" + status_inreview: + type: string + default: "In Review" + status_revising: + type: string + default: "Revising" + status_snagged: + type: string + default: "Snagged" + status_approved: + type: string + default: "Approved" + status_done: + type: string + default: "Done" + source_field: + description: "Single-select field name for the PR Source." + type: string + default: "Source" + source_internal: + type: string + default: "Internal" + source_community: + type: string + default: "Community" + source_bot: + type: string + default: "Bot" + bot_authors: + description: "Comma-separated logins always treated as bots." + type: string + default: "nv-slang-bot,slang-coworker-nanoclaw,Copilot,copilot-swe-agent" + ignored_reviewers: + description: "Comma-separated auto-assigned reviewers that cannot approve; unrequested on the PR." + type: string + default: "bmillsNV" + owners_team: + description: "org/team-slug whose members may be inherited as a Community PR's assignee from a linked issue." + type: string + default: "shader-slang/pr-owners" + bot_owners_team: + description: "org/team-slug whose members may be inherited as a Bot PR's assignee from a linked issue." + type: string + default: "shader-slang/bot-pr-owners" + maintainer_team: + description: "org/team-slug tracking the current maintainer(s); the fallback assignee when a non-Internal PR has no linked-issue or committer-signal owner." + type: string + default: "shader-slang/slang-maintainer" + fallback_assignee: + description: "Last-resort assignee for a non-Internal PR when maintainer_team is empty/unset/unreadable and no linked-issue or committer-signal owner is found, so PRs are never left unassigned. Never requested as a reviewer (it is typically an ignored non-approver). Empty disables this fallback (the PR is left to the sweep)." + type: string + default: "bmillsNV" + secrets: + SLANG_PR_BOT_TOKEN: + description: "Dedicated bot PAT used for every GitHub call this workflow makes (org Projects RW + Members read; repo Contents read, Pull requests write, Issues write, Metadata read)." + required: true + +# Every step authenticates with SLANG_PR_BOT_TOKEN, so the GITHUB_TOKEN is unused; +# deny it entirely. (Callers therefore need no permissions block.) +permissions: {} + +jobs: + board-sync: + runs-on: ubuntu-latest + # Serialize events for the same PR so two near-simultaneous events (e.g. + # opened + a fast synchronize) cannot double-write; cancel-in-progress: false + # lets the in-flight run finish and queues the next. A check_suite keys on its + # head SHA rather than a PR number, so it can still overlap a PR-keyed event -- + # benign, since the assignment pick is deterministic and the Status writes are + # idempotent recomputes. + concurrency: + group: pr-board-sync-${{ github.event.pull_request.number || github.event.check_suite.head_sha || github.run_id }} + cancel-in-progress: false + # HAS_TOKEN is false when SLANG_PR_BOT_TOKEN is not delivered to the run. On a + # PUBLIC repo, GitHub withholds secrets from fork-PR head-context events + # (pull_request_review / pull_request); it also fails to resolve for a repo + # that has not configured the secret. github-script hard-errors on an empty + # github-token, so every privileged step is gated on this and skips cleanly -- + # the nightly sweep, a later pull_request_target event, or (for fork reviews) + # the workflow_run path reconciles instead. pull_request_target, check_suite, + # workflow_run, and schedule are privileged and DO receive the secret for forks. + env: + HAS_TOKEN: ${{ secrets.SLANG_PR_BOT_TOKEN != '' }} + steps: + # --- No-token breadcrumb: explain a skipped run ------------------------- + - name: Note skipped (no token) + if: ${{ env.HAS_TOKEN != 'true' }} + run: | + echo "Skipping board sync: SLANG_PR_BOT_TOKEN is not available to this run." + echo "(Fork-PR head-context event on a public repo, or unconfigured repo.)" + echo "The nightly sweep / a later pull_request_target / the workflow_run path will reconcile." + + # --- Classify Source (repo write-access read; opened/reopened only) ------ + - name: Classify PR Source + id: classify + if: >- + env.HAS_TOKEN == 'true' && + github.event_name == 'pull_request_target' && + (github.event.action == 'opened' || github.event.action == 'reopened') + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + BOT_AUTHORS: ${{ inputs.bot_authors }} + SOURCE_INTERNAL: ${{ inputs.source_internal }} + SOURCE_COMMUNITY: ${{ inputs.source_community }} + SOURCE_BOT: ${{ inputs.source_bot }} + with: + github-token: ${{ secrets.SLANG_PR_BOT_TOKEN }} + script: | + const user = context.payload.pull_request.user; + const login = user.login; + const botAuthors = (process.env.BOT_AUTHORS || "") + .split(",").map(s => s.trim().toLowerCase()).filter(Boolean); + const isBot = user.type === "Bot" || + botAuthors.includes((login || "").toLowerCase()); + + let source; + if (isBot) { + source = process.env.SOURCE_BOT; + } else { + // Internal iff the author has write (push) access to this repo, i.e. + // they can approve workflow runs and approve/merge PRs. Everyone else + // -- org members without write here, and fork contributors -- is + // Community. Mirrors pr_sweep.py classify_source (collaborators with + // permissions.push == true). On any read error, fail safe to Community. + let canWrite = false; + try { + const resp = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: login, + }); + canWrite = resp.data?.user?.permissions?.push === true; + } catch (error) { + core.warning( + `could not read ${login} permission (${error.status}); treating as Community`); + } + source = canWrite + ? process.env.SOURCE_INTERNAL + : process.env.SOURCE_COMMUNITY; + console.log(`PR author ${login}; write access: ${canWrite}`); + } + core.setOutput("source", source); + + # --- Board writes: add + Source + Status (ProjectsV2 PAT) --------------- + - name: Reconcile board (add, Source, Status) + if: ${{ env.HAS_TOKEN == 'true' }} + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + MODE: ${{ inputs.mode }} + PROJECT_ID: ${{ inputs.project_id }} + STATUS_FIELD: ${{ inputs.status_field }} + STATUS_INREVIEW: ${{ inputs.status_inreview }} + STATUS_REVISING: ${{ inputs.status_revising }} + STATUS_SNAGGED: ${{ inputs.status_snagged }} + STATUS_APPROVED: ${{ inputs.status_approved }} + STATUS_DONE: ${{ inputs.status_done }} + SOURCE_FIELD: ${{ inputs.source_field }} + SOURCE_VALUE: ${{ steps.classify.outputs.source }} + SOURCE_INTERNAL: ${{ inputs.source_internal }} + SOURCE_COMMUNITY: ${{ inputs.source_community }} + SOURCE_BOT: ${{ inputs.source_bot }} + BOT_AUTHORS: ${{ inputs.bot_authors }} + IGNORED_REVIEWERS: ${{ inputs.ignored_reviewers }} + OWNERS_TEAM: ${{ inputs.owners_team }} + BOT_OWNERS_TEAM: ${{ inputs.bot_owners_team }} + MAINTAINER_TEAM: ${{ inputs.maintainer_team }} + FALLBACK_ASSIGNEE: ${{ inputs.fallback_assignee }} + with: + github-token: ${{ secrets.SLANG_PR_BOT_TOKEN }} + script: | + const PROJECT_ID = process.env.PROJECT_ID; + const STATUS = { + field: process.env.STATUS_FIELD, + inreview: process.env.STATUS_INREVIEW, + revising: process.env.STATUS_REVISING, + snagged: process.env.STATUS_SNAGGED, + approved: process.env.STATUS_APPROVED, + done: process.env.STATUS_DONE, + }; + const SOURCE_FIELD = process.env.SOURCE_FIELD; + const SOURCE_INTERNAL = process.env.SOURCE_INTERNAL; + const SOURCE_COMMUNITY = process.env.SOURCE_COMMUNITY; + const SWEEP = process.env.MODE === "sweep"; + const BOT_AUTHORS = (process.env.BOT_AUTHORS || "") + .split(",").map(s => s.trim().toLowerCase()).filter(Boolean); + const IGNORED = new Set((process.env.IGNORED_REVIEWERS || "") + .split(",").map(s => s.trim().toLowerCase()).filter(Boolean)); + const OWNERS_TEAM = process.env.OWNERS_TEAM || ""; + const BOT_OWNERS_TEAM = process.env.BOT_OWNERS_TEAM || ""; + const MAINTAINER_TEAM = process.env.MAINTAINER_TEAM || ""; + const FALLBACK_ASSIGNEE = process.env.FALLBACK_ASSIGNEE || ""; + + // Committer-signal ranking + assignee/reviewer selection, inlined so + // this reusable workflow needs NO checkout when called cross-repo + // (a checkout in a reusable workflow lands the CALLER's repo, not this + // one). This block is a faithful copy of scripts/pr_signal.py and + // select_assignee_and_reviewers in pr_common.py. It is the single + // source of truth: the unit tests (.github/scripts/pr-signal.test.js, + // pr-assign.test.js) extract THIS block from the YAML at run time via + // extract-workflow-js.js, so a change here is what gets tested. Keep + // it behaviorally identical to the cadence sweep's Python. + // ----- extract-js:assignment:begin ----- + const DEFAULT_SIGNAL_FINALISTS = 6; // top candidates re-scored by true per-file LOC + const DEFAULT_SIGNAL_CLEAR_MARGIN = 1.5; // skip the tiebreak when #1 leads #2 by this factor + + // --- Run-scoped cache ------------------------------------------------------ + // Holds ONLY raw, PR-independent source data so a file's history or a commit's + // per-file stats are fetched once per run no matter how many PRs touch them. + // Per-PR weighting/attribution is always recomputed. + function makeCache() { + return { + fileHistory: new Map(), // `${repo}\x00${path}` -> cheap-pass commit rows + commitFiles: new Map(), // `${repo}\x00${sha}` -> {path: max(add, del)} + }; + } + + // --- Pure helpers ---------------------------------------------------------- + + // True when an author should be treated as a bot (matches 'name', 'name[bot]', + // or 'app/name' against the configured bot authors). + function classifyIsBot(author, botAuthors) { + const a = (author || "").toLowerCase(); + for (let b of botAuthors || []) { + b = (b || "").trim().toLowerCase(); + if (!b) continue; + const base = a.replace("[bot]", "").replace("app/", ""); + if (base === b || a === b) return true; + } + return false; + } + + // A login that should never be credited with committer signal: any GitHub App + // ([bot] suffix), an unmapped/empty login, or a configured bot author. + function isBotLogin(login, botAuthors) { + if (!login) return true; + return login.toLowerCase().endsWith("[bot]") || classifyIsBot(login, botAuthors); + } + + // Who a past commit's signal counts for. Prefer the commit author; but when the + // author is a bot, unmapped, OR the current PR's author, fall back to the last + // approver of the PR that introduced the commit. The PR author and bots are + // never credited, so the commit is dropped if no eligible approver remains. + function attributeCommit(authorLogin, lastApprover, prAuthor, botAuthors) { + const author = authorLogin || ""; + if (author && !isBotLogin(author, botAuthors) + && author.toLowerCase() !== prAuthor.toLowerCase()) { + return author; + } + if (!lastApprover || lastApprover.toLowerCase() === prAuthor.toLowerCase() + || isBotLogin(lastApprover, botAuthors)) { + return null; + } + return lastApprover; + } + + // Translate an fnmatch-style glob to an anchored RegExp (matches Python's + // fnmatch: `*` and `**` match any chars incl. '/'; `?` matches one char). + function _globToRegExp(glob) { + let re = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { + re += ".*"; + } else if (c === "?") { + re += "."; + } else if (c === "[") { + let j = i + 1; + if (j < glob.length && (glob[j] === "!" || glob[j] === "^")) j++; + if (j < glob.length && glob[j] === "]") j++; + while (j < glob.length && glob[j] !== "]") j++; + if (j >= glob.length) { + re += "\\["; // unterminated class -> literal '[' + } else { + let inner = glob.slice(i + 1, j).replace(/\\/g, "\\\\"); + if (inner[0] === "!") inner = "^" + inner.slice(1); + re += "[" + inner + "]"; + i = j; + } + } else { + re += c.replace(/[.+^${}()|\\\/]/g, "\\$&"); + } + } + return new RegExp("^" + re + "$", "s"); + } + + // Multiplier for `path`: the longest (most specific) matching directory glob + // wins; `default` if none match. + function matchFileMultiplier(path, table, dflt) { + let bestLen = -1; + let bestMult = dflt; + for (const [glob, mult] of table) { + if (_globToRegExp(glob).test(path) && glob.length > bestLen) { + bestLen = glob.length; + bestMult = mult; + } + } + return bestMult; + } + + // Per-file signal = LOC * file_multiplier, for every changed file. + function perFileSignals(locByFile, table, dflt) { + const out = {}; + for (const [path, loc] of Object.entries(locByFile)) { + out[path] = loc * matchFileMultiplier(path, table, dflt); + } + return out; + } + + // Keep the top-`k` files by signal and normalize them into weights summing to 1. + // Files with non-positive total contribute nothing. + function topKWeights(signals, k) { + const ranked = Object.entries(signals) + .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? 1 : a[0] > b[0] ? -1 : 0)) + .slice(0, k); + let total = 0; + for (const [, s] of ranked) if (s > 0) total += s; + if (total <= 0) return {}; + const out = {}; + for (const [path, s] of ranked) if (s > 0) out[path] = s / total; + return out; + } + + // Per-committer overall signal = sum over files of weight * LOC-in-file. + function overallSignal(fileWeights, locByFileLogin) { + const out = {}; + for (const [path, weight] of Object.entries(fileWeights)) { + const perLogin = locByFileLogin[path] || {}; + for (const [login, loc] of Object.entries(perLogin)) { + out[login] = (out[login] || 0) + weight * loc; + } + } + return out; + } + + // Logins ordered by overall signal (largest first); tie-break by login. + function rankLogins(overall) { + return Object.entries(overall) + .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? 1 : a[0] > b[0] ? -1 : 0)) + .map(([login]) => login); + } + + // Whether the cheap (total-LOC) top two are close enough to warrant the + // per-file-LOC tiebreak. False on a clear winner (or <2 candidates). + function needsTiebreak(cheapOverall, ranked, clearMargin) { + if (ranked.length < 2) return false; + const top = cheapOverall[ranked[0]] || 0; + const second = cheapOverall[ranked[1]] || 0; + if (second <= 0) return false; + return top < clearMargin * second; + } + + // Re-order the top-`n` finalists by their refined (per-file-LOC) score, keeping + // the remaining tail in cheap order. + function mergeRefined(ranked, n, refinedOverall) { + const finalists = ranked.slice(0, n); + finalists.sort((a, b) => { + const sa = refinedOverall[a] || 0; + const sb = refinedOverall[b] || 0; + return (sb - sa) || (a < b ? 1 : a > b ? -1 : 0); + }); + return finalists.concat(ranked.slice(n)); + } + + // --- GraphQL: batched per-file history (cheap pass) ------------------------ + + const _HISTORY_QUERY_HEAD = ` + query($owner: String!, $name: String!, $since: GitTimestamp!, $n: Int!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { target { ... on Commit { + `; + const _HISTORY_QUERY_TAIL = ` + } } } + } + } + `; + function _historyFileField(i, path) { + return ` + f${i}: history(path: ${JSON.stringify(path)}, since: $since, first: $n) { + nodes { + oid + additions + deletions + author { user { login } } + associatedPullRequests(first: 1) { nodes { + reviews(first: 20, states: [APPROVED]) { nodes { + submittedAt + author { login } + } } + } } + } + } + `; + } + + // Login of the latest APPROVED review on the commit's associated PR. + function lastApprover(commitNode) { + const prs = ((commitNode.associatedPullRequests || {}).nodes) || []; + if (!prs.length) return null; + const reviews = ((prs[0].reviews || {}).nodes) || []; + const dated = reviews.filter((r) => r.submittedAt); + if (!dated.length) return null; + dated.sort((a, b) => (a.submittedAt < b.submittedAt ? -1 : a.submittedAt > b.submittedAt ? 1 : 0)); + return ((dated[dated.length - 1].author || {}).login) || null; + } + + // Per path, the last `commits` default-branch commits since `since`, as + // path -> [{oid, author, loc(total), lastApprover}]. A file's default-branch + // history is PR-independent, so results are cached by (repo, path) in + // `historyCache` (run-scoped). Only uncached paths are fetched, in one batched + // query; if all are cached, no request is made. + async function fetchFileHistories(gh, repo, paths, since, commits, historyCache) { + if (!historyCache) historyCache = new Map(); + const key = (p) => `${repo}\x00${p}`; + const uncached = paths.filter((p) => !historyCache.has(key(p))); + if (uncached.length) { + const [owner, name] = repo.split("/", 2); + const fields = uncached.map((path, i) => _historyFileField(i, path)).join(""); + const query = _HISTORY_QUERY_HEAD + fields + _HISTORY_QUERY_TAIL; + const data = await gh.graphql(query, { owner, name, since, n: commits }); + const target = (((((data || {}).repository) || {}).defaultBranchRef) || {}).target || {}; + uncached.forEach((path, i) => { + const nodes = ((target[`f${i}`] || {}).nodes) || []; + const commitList = []; + for (const node of nodes) { + const author = ((((node.author || {}).user) || {}).login) || ""; + const loc = Math.max(node.additions || 0, node.deletions || 0); + commitList.push({ + oid: node.oid || "", + author, + loc, + lastApprover: lastApprover(node), + }); + } + historyCache.set(key(path), commitList); + }); + } + const out = {}; + for (const p of paths) out[p] = historyCache.get(key(p)); + return out; + } + + // max(additions, deletions) that commit `sha` changed in `path`. Caches the + // whole commit's per-file stats by (repo, sha) so a commit touching several + // files — or shared across PRs — is fetched once (run-scoped dedup). + async function commitFileLoc(gh, repo, sha, path, cache) { + const key = `${repo}\x00${sha}`; + let detail = cache.get(key); + if (detail === undefined) { + detail = {}; + const files = (await gh.commitFiles(repo, sha)) || []; + for (const rec of files) { + const filename = rec.filename; + if (filename) detail[filename] = Math.max(rec.additions || 0, rec.deletions || 0); + } + cache.set(key, detail); + } + return detail[path] || 0; + } + + // --- Top-level ranking ----------------------------------------------------- + + // The GraphQL `since` timestamp for the signal horizon. Computed once per run so + // cache keys are stable across PRs. + function horizonSince(horizonDays, now) { + const base = now ? new Date(now) : new Date(); + const t = new Date(base.getTime() - horizonDays * 24 * 60 * 60 * 1000); + return t.toISOString().replace(/\.\d{3}Z$/, "Z"); + } + + // Attribute each file's commit-TOTAL LOC to a login, returning per-file + // (login -> LOC) and (login -> [oid]). The oids let the tiebreak fetch true + // per-file LOC for only the commits that matter. + function _cheapPass(history, prAuthor, botAuthors) { + const cheapLoc = {}; + const oidsByFileLogin = {}; + for (const [path, commitList] of Object.entries(history)) { + const perLogin = {}; + const perOids = {}; + for (const commit of commitList) { + const attributed = attributeCommit( + commit.author, commit.lastApprover, prAuthor, botAuthors); + if (!attributed || commit.loc <= 0) continue; + perLogin[attributed] = (perLogin[attributed] || 0) + commit.loc; + if (commit.oid) (perOids[attributed] = perOids[attributed] || []).push(commit.oid); + } + if (Object.keys(perLogin).length) { + cheapLoc[path] = perLogin; + oidsByFileLogin[path] = perOids; + } + } + return { cheapLoc, oidsByFileLogin }; + } + + // Re-score the finalists with TRUE per-file LOC, deduped via the run-scoped + // commit cache so shared commits are fetched once. + async function _refineFinalists(gh, repo, weights, oidsByFileLogin, finalistSet, cache) { + const refinedLoc = {}; + for (const path of Object.keys(weights)) { + const refinedPerLogin = {}; + const perOids = oidsByFileLogin[path] || {}; + for (const [login, oids] of Object.entries(perOids)) { + if (!finalistSet.has(login)) continue; + let total = 0; + for (const oid of oids) total += await commitFileLoc(gh, repo, oid, path, cache.commitFiles); + if (total > 0) refinedPerLogin[login] = total; + } + if (Object.keys(refinedPerLogin).length) refinedLoc[path] = refinedPerLogin; + } + return refinedLoc; + } + + // Ranked candidate committer logins for the PR (highest signal first), excluding + // the PR author and bots: a cheap total-LOC pass, then a per-file-LOC tiebreak + // over the top `finalists` only when they are close. + async function computeCommitterRanking(gh, repo, prAuthor, opts) { + const { + locByFile, + fileMultipliers, + defaultMultiplier, + topFiles, + commits, + horizonDays, + botAuthors, + finalists = DEFAULT_SIGNAL_FINALISTS, + clearMargin = DEFAULT_SIGNAL_CLEAR_MARGIN, + } = opts; + const cache = opts.cache || makeCache(); + const since = opts.since || horizonSince(horizonDays); + + const weights = topKWeights( + perFileSignals(locByFile, fileMultipliers, defaultMultiplier), topFiles); + if (!Object.keys(weights).length) return []; + + const history = await fetchFileHistories( + gh, repo, Object.keys(weights), since, commits, cache.fileHistory); + const { cheapLoc, oidsByFileLogin } = _cheapPass(history, prAuthor, botAuthors); + const cheapOverall = overallSignal(weights, cheapLoc); + const cheapRanked = rankLogins(cheapOverall); + + if (!needsTiebreak(cheapOverall, cheapRanked, clearMargin)) { + return cheapRanked; // clear winner (or <2 candidates): cheap pass suffices + } + + const finalistSet = new Set(cheapRanked.slice(0, finalists)); + const refinedLoc = await _refineFinalists(gh, repo, weights, oidsByFileLogin, finalistSet, cache); + const refinedOverall = overallSignal(weights, refinedLoc); + return mergeRefined(cheapRanked, finalists, refinedOverall); + } + + function _toSet(x) { + return x instanceof Set ? x : new Set(x || []); + } + + function selectAssigneeAndReviewers(opts) { + const { + issueAssignees = [], + committersBySignal = [], + author, + maintainer, + existingReviewers = [], + botAuthors = [], + } = opts; + const owners = _toSet(opts.owners); + const collaborators = _toSet(opts.collaborators); + const ignoredLower = new Set([..._toSet(opts.ignoredReviewers)].map((r) => r.toLowerCase())); + const excluded = (author || "").toLowerCase(); + + const issueOwnerAssignees = issueAssignees.filter( + (a) => owners.has(a) && a.toLowerCase() !== excluded && !a.toLowerCase().endsWith("[bot]")); + const ownerCommitters = committersBySignal.filter((c) => owners.has(c)); + + let assignee; + if (issueOwnerAssignees.length) assignee = issueOwnerAssignees[0]; + else if (ownerCommitters.length) assignee = ownerCommitters[0]; + else assignee = maintainer; + + // A reviewer who can actually approve already requested? Then add no more. + const realExisting = existingReviewers.filter( + (r) => r && !ignoredLower.has(r.toLowerCase()) + && !classifyIsBot(r, botAuthors) && !r.toLowerCase().endsWith("[bot]")); + if (realExisting.length) return { assignee, reviewers: [] }; + + const reviewers = [assignee]; + const extra = committersBySignal.filter((c) => collaborators.has(c) && !owners.has(c)); + if (extra.length && extra[0] !== assignee) reviewers.push(extra[0]); + + // GitHub rejects requesting review from the PR author; then dedupe (order-preserving). + const seen = new Set(); + const deduped = []; + for (const r of reviewers) { + if (!r || r.toLowerCase() === excluded) continue; + if (!seen.has(r.toLowerCase())) { + seen.add(r.toLowerCase()); + deduped.push(r); + } + } + return { assignee, reviewers: deduped }; + } + // ----- extract-js:assignment:end ----- + + // Committer-signal weighting. MUST match pr_common.py's DEFAULT_* + // (file multipliers, top-files, commit sample, horizon) so the sweep + // and this workflow produce the same ranking. + const FILE_MULTIPLIERS = [ + ["source/slang/**", 3.0], + ["source/**", 2.0], + ["tools/**", 1.5], + ["tests/**", 1.0], + ["docs/**", 0.5], + ]; + const DEFAULT_MULTIPLIER = 1.0; + const SIGNAL_TOP_FILES = 10; + const SIGNAL_COMMITS = 5; + const SIGNAL_HORIZON_DAYS = 180; + const SIGNAL_FINALISTS = 6; + const SIGNAL_CLEAR_MARGIN = 1.5; + // Run-scoped signal caches (raw, PR-independent source data) so a sweep + // fetches each file's history / commit stats once across all PRs, not + // once per PR. `since` is computed once for stable cache keys. Mirrors + // pr_sweep.py's SignalCache + signal_since. + const SIGNAL_CACHE = makeCache(); + const SIGNAL_SINCE = horizonSince(SIGNAL_HORIZON_DAYS); + + // `gh` client the signal modules expect: octokit-shaped graphql plus a + // commitFiles(repo, sha) -> [{filename, additions, deletions}] helper. + const ghAdapter = { + graphql: (query, variables) => github.graphql(query, variables), + commitFiles: async (repo, sha) => { + const [o, n] = repo.split("/"); + const r = await github.rest.repos.getCommit({ owner: o, repo: n, ref: sha }); + return (r.data.files || []).map(f => ({ + filename: f.filename, additions: f.additions, deletions: f.deletions })); + }, + }; + + // --- ProjectsV2 helpers (mirror Applier in pr_sweep.py) ---------- + const fieldCache = {}; + async function field(name) { + if (fieldCache[name]) return fieldCache[name]; + const data = await github.graphql(` + query($project: ID!, $name: String!) { + node(id: $project) { + ... on ProjectV2 { + field(name: $name) { + ... on ProjectV2SingleSelectField { id options { id name } } + } + } + } + }`, { project: PROJECT_ID, name }); + const f = data?.node?.field || {}; + const options = {}; + for (const o of (f.options || [])) options[o.name] = o.id; + fieldCache[name] = { id: f.id, options }; + return fieldCache[name]; + } + + // addProjectV2ItemById is idempotent: returns the existing item id + // when the PR is already on the board. + async function ensureItem(contentId) { + if (!contentId) return null; + const r = await github.graphql(` + mutation($project: ID!, $content: ID!) { + addProjectV2ItemById(input: {projectId: $project, contentId: $content}) { + item { id } + } + }`, { project: PROJECT_ID, content: contentId }); + return r?.addProjectV2ItemById?.item?.id || null; + } + + async function setSingleSelect(itemId, fieldName, optionName) { + if (!itemId || !optionName) return; + const f = await field(fieldName); + const optionId = (f.options || {})[optionName]; + if (!f.id || !optionId) { + core.warning(`field ${fieldName} option ${optionName} not found on board`); + return; + } + await github.graphql(` + mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $project, itemId: $item, fieldId: $field, + value: { singleSelectOptionId: $option } + }) { projectV2Item { id } } + }`, { project: PROJECT_ID, item: itemId, field: f.id, option: optionId }); + console.log(`set ${fieldName} = ${optionName}`); + } + + // Read a single-select field's current value name on a board item + // (e.g. the PR's Source), or null when unset. + async function itemSingleSelect(itemId, fieldName) { + const data = await github.graphql(` + query($item: ID!, $name: String!) { + node(id: $item) { + ... on ProjectV2Item { + fieldValueByName(name: $name) { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + }`, { item: itemId, name: fieldName }); + return data?.node?.fieldValueByName?.name || null; + } + + // --- CI rollup (mirror summarize_ci / ci_state_from_rollup) ------- + function summarizeCi(runs) { + if (!runs.length) return "none"; + let pending = false, failed = false; + for (const r of runs) { + if (r.conclusion === "action_required") return "action_required"; + if (r.status !== "completed") { pending = true; continue; } + if (!["success", "neutral", "skipped"].includes(r.conclusion)) failed = true; + } + if (pending) return "pending"; + if (failed) return "failed"; + return "passed"; + } + + // One query per PR: everything computeTarget needs - draft, author + // (bot), merge-queue membership, CI rollup, and each reviewer's latest + // opinionated review with the commit it was made on (so we can tell + // whether the feedback is on the *current* head commit). + async function prInfo(number) { + const data = await github.graphql(` + query($owner: String!, $name: String!, $n: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $n) { + id isDraft state + author { login __typename } + assignees(first: 10) { nodes { login } } + reviewRequests(first: 20) { nodes { requestedReviewer { __typename ... on User { login } } } } + closingIssuesReferences(first: 10) { nodes { assignees(first: 20) { nodes { login } } } } + files(first: 100) { nodes { path additions deletions } } + mergeQueueEntry { id } + latestOpinionatedReviews(first: 100) { nodes { state commit { oid } } } + commits(last: 1) { nodes { commit { oid statusCheckRollup { + contexts(first: 100) { nodes { + __typename + ... on CheckRun { status conclusion } + ... on StatusContext { state } + } } + } } } } + } + } + }`, { owner: context.repo.owner, name: context.repo.repo, n: number }); + const pr = data?.repository?.pullRequest; + if (!pr) return null; + const headCommit = pr.commits?.nodes?.[0]?.commit || {}; + const contexts = headCommit.statusCheckRollup?.contexts?.nodes || []; + const runs = []; + for (const c of contexts) { + if (c.__typename === "CheckRun") { + runs.push({ + status: (c.status || "").toLowerCase(), + conclusion: (c.conclusion || "").toLowerCase(), + }); + } else { + const state = (c.state || "").toUpperCase(); + if (state === "SUCCESS") runs.push({ status: "completed", conclusion: "success" }); + else if (state === "FAILURE" || state === "ERROR") runs.push({ status: "completed", conclusion: "failure" }); + else runs.push({ status: "in_progress", conclusion: null }); + } + } + const login = (pr.author?.login || "").toLowerCase(); + const isBot = pr.author?.__typename === "Bot" || BOT_AUTHORS.includes(login); + // A reviewer's opinion counts only if it was made on the CURRENT head + // commit; an opinion on an earlier commit was superseded by a newer + // push (so the PR is back to In Review until re-reviewed). This is + // commit-identity, not timestamps - a later comment-only review or a + // late-pushed older commit can't revive stale feedback. + const headOid = headCommit.oid; + const states = (pr.latestOpinionatedReviews?.nodes || []) + .filter(r => r.commit && r.commit.oid === headOid) + .map(r => r.state); + const changeRequested = states.includes("CHANGES_REQUESTED"); + const approved = states.includes("APPROVED") && !changeRequested; + // Assignment inputs: who is assigned, which real reviewers are + // requested, and the assignees of the PR's linked (closing) issues. + const authorLogin = pr.author?.login || ""; + const assignees = (pr.assignees?.nodes || []) + .map(n => n.login).filter(Boolean); + const requestedReviewers = (pr.reviewRequests?.nodes || []) + .map(n => n.requestedReviewer) + .filter(r => r && r.__typename === "User" && r.login) + .map(r => r.login); + const issueAssignees = []; + for (const iss of (pr.closingIssuesReferences?.nodes || [])) { + for (const a of (iss.assignees?.nodes || [])) { + if (a.login && !issueAssignees.includes(a.login)) + issueAssignees.push(a.login); + } + } + // PR's own changed-file LOC (path -> max(add, del)) for the + // committer-signal ranking. + const changedFiles = {}; + for (const f of (pr.files?.nodes || [])) { + if (f.path) + changedFiles[f.path] = Math.max(f.additions || 0, f.deletions || 0); + } + return { + nodeId: pr.id, isDraft: pr.isDraft, isBot, + ci: summarizeCi(runs), approved, changeRequested, + inMergeQueue: pr.mergeQueueEntry != null, + authorLogin, assignees, requestedReviewers, issueAssignees, + changedFiles, + }; + } + + // The Status an OPEN PR should currently carry, from observed state. + // This is the single source of truth for the event machine and MUST + // stay identical to target_status() in scripts/pr_sweep.py so the + // cadence sweep never fights this workflow. Priority (first match wins): + // 1. A current changes-request -> Revising (must revise; ranked above + // the draft rule so even a Bot draft with a changes-request is + // Revising). "Current" = the review was made on the head commit; + // an opinion on an earlier commit is superseded by a newer push. + // 2. Draft -> a human draft is Revising (author still working); a Bot + // draft is In Review, since bot PRs arrive as drafts and a human + // owner must see/shepherd them. (Ranked above CI, so a Bot draft + // with merely failing CI still shows In Review.) + // 3. CI needs approval (action_required, e.g. a fork PR awaiting a + // maintainer to approve the run) -> Snagged (always; a human gates + // it). CI failed -> Revising (Bot, fixes itself) / Snagged (human). + // 4. Approved + not stale: Approved while still waiting on something + // automated (CI pending, or in the merge queue); Snagged once CI + // is green and it is NOT queued (nothing automated left - a human + // must enqueue/merge it; also how a merge-queue drop reads). + // 5. Otherwise -> In Review (awaiting review, CI pending, or a fresh + // commit with no feedback yet). + function computeTarget(info) { + if (info.changeRequested) return STATUS.revising; + if (info.isDraft) return info.isBot ? STATUS.inreview : STATUS.revising; + if (info.ci === "action_required") return STATUS.snagged; + if (info.ci === "failed") return info.isBot ? STATUS.revising : STATUS.snagged; + if (info.approved) { + return (info.ci === "passed" && !info.inMergeQueue) + ? STATUS.snagged : STATUS.approved; + } + return STATUS.inreview; + } + + // Member logins of an org team given as "org/slug" (empty for a bare + // slug or on any read error -- callers then fall through, exactly like + // pr_common.list_team_members). Cached per run. + const teamCache = {}; + async function listTeamMembers(teamSpec) { + if (!teamSpec || !teamSpec.includes("/")) return new Set(); + if (teamCache[teamSpec]) return teamCache[teamSpec]; + const slash = teamSpec.indexOf("/"); + const org = teamSpec.slice(0, slash); + const slug = teamSpec.slice(slash + 1); + let members = new Set(); + try { + const data = await github.paginate( + github.rest.teams.listMembersInOrg, { org, team_slug: slug, per_page: 100 }); + members = new Set(data.map(m => m.login)); + } catch (error) { + core.warning(`could not list team ${teamSpec} (${error.status})`); + } + teamCache[teamSpec] = members; + return members; + } + + // Logins with write+ access to this repo (the extra-reviewer pool), + // mirroring pr_common.collect_repo_collaborators. Empty on access error + // (listing collaborators needs repo admin/push), so the extra reviewer + // is simply skipped -- never a hard failure. Cached per run. + let _collabCache; + async function repoCollaborators() { + if (_collabCache) return _collabCache; + try { + const data = await github.paginate( + github.rest.repos.listCollaborators, + { owner: context.repo.owner, repo: context.repo.repo, + affiliation: "all", per_page: 100 }); + _collabCache = new Set( + data.filter(c => c.permissions && c.permissions.push).map(c => c.login)); + } catch (error) { + core.warning(`could not list collaborators (${error.status}); no extra reviewer`); + _collabCache = new Set(); + } + return _collabCache; + } + + // The fallback assignee for a non-Internal PR that has no linked-issue + // or committer-signal owner: the (sorted-first) member of the + // maintainer team, else FALLBACK_ASSIGNEE (e.g. bmillsNV) when that + // team is unset/empty/unreadable, so a PR is never left unassigned. + // Only "" if both are unset, in which case selectAssigneeAndReviewers + // yields no pick and we leave it to the sweep. Cached per run. + let _maintainer; + async function resolveMaintainer() { + if (_maintainer !== undefined) return _maintainer; + const members = [...await listTeamMembers(MAINTAINER_TEAM)].sort(); + if (members.length > 1) + core.warning( + `maintainer team ${MAINTAINER_TEAM} has ${members.length} members; using ${members[0]}`); + _maintainer = members[0] || FALLBACK_ASSIGNEE; + return _maintainer; + } + + // Backfill a PR's assignee + reviewers, replicating pr_sweep.py's + // select_assignee_and_reviewers in full (issue-owner -> committer-signal + // owner -> maintainer; reviewers = assignee + top dev-not-owner unless a + // real reviewer is already requested). Internal PRs go to the author with + // no requested reviewer. Idempotent: only ever touches an UNASSIGNED PR, + // so re-running on any event is a no-op once an owner is set. + async function reconcileAssignment(info, source, number) { + if (!source) return; // Source unknown -> leave to sweep + if (info.assignees.length) return; // already assigned + if (info.inMergeQueue) return; // far enough along + if (info.isDraft && !info.isBot) return; // human draft: author's "not ready" + + let assignee, reviewers; + if (source === SOURCE_INTERNAL) { + // Internal: the author drives; assign them, request no reviewers. + assignee = info.authorLogin; + reviewers = []; + } else { + // Community/Bot: full committer-signal selection against the + // relevant owners pool, with the maintainer as the final fallback. + const ownersTeam = + (source === process.env.SOURCE_BOT) ? BOT_OWNERS_TEAM : OWNERS_TEAM; + const [owners, collaborators, maintainer] = await Promise.all([ + listTeamMembers(ownersTeam), + repoCollaborators(), + resolveMaintainer(), + ]); + const ranking = await computeCommitterRanking( + ghAdapter, `${context.repo.owner}/${context.repo.repo}`, info.authorLogin, { + locByFile: info.changedFiles, + fileMultipliers: FILE_MULTIPLIERS, + defaultMultiplier: DEFAULT_MULTIPLIER, + topFiles: SIGNAL_TOP_FILES, + commits: SIGNAL_COMMITS, + horizonDays: SIGNAL_HORIZON_DAYS, + botAuthors: BOT_AUTHORS, + finalists: SIGNAL_FINALISTS, + clearMargin: SIGNAL_CLEAR_MARGIN, + cache: SIGNAL_CACHE, + since: SIGNAL_SINCE, + }); + ({ assignee, reviewers } = selectAssigneeAndReviewers({ + issueAssignees: info.issueAssignees, + committersBySignal: ranking, + owners, + collaborators, + author: info.authorLogin, + maintainer, + existingReviewers: info.requestedReviewers, + botAuthors: BOT_AUTHORS, + ignoredReviewers: [...IGNORED], + })); + } + if (!assignee) return; // no owner and no maintainer + + await github.rest.issues.addAssignees({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: number, assignees: [assignee], + }); + console.log(`assigned ${assignee} (source ${source})`); + + // Unrequest auto-assigned non-approvers (e.g. bmillsNV), coupled to + // assignment exactly as the sweep does. + const toRemove = + info.requestedReviewers.filter(r => IGNORED.has(r.toLowerCase())); + if (toRemove.length) { + await github.rest.pulls.removeRequestedReviewers({ + owner: context.repo.owner, repo: context.repo.repo, + pull_number: number, reviewers: toRemove, + }); + } + + // Request the selected reviewers (assignee + top dev-not-owner). The + // selection already drops the author and the case where a real + // reviewer is present. Also never request an ignored non-approver + // (e.g. the bmillsNV fallback assignee), which can legitimately leave + // the reviewer set empty -- that is fine. + const toRequest = reviewers.filter(r => !IGNORED.has(r.toLowerCase())); + if (toRequest.length) { + try { + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, repo: context.repo.repo, + pull_number: number, reviewers: toRequest, + }); + } catch (error) { + core.warning( + `could not request reviewers ${toRequest.join(", ")} (${error.status})`); + } + } + } + + // Classify a PR's Source from observed state, mirroring the + // "Classify PR Source" step and pr_common.classify_source: Bot for a + // bot author, else Internal iff the author has repo write access, else + // Community. Used when the board has no Source yet and no freshly- + // classified value is available (i.e. the sweep, or a non-onboarding + // event). Fails safe to Community on a read error. + async function classifySource(info) { + if (info.isBot) return process.env.SOURCE_BOT; + let canWrite = false; + try { + const resp = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, repo: context.repo.repo, + username: info.authorLogin, + }); + canWrite = resp.data?.user?.permissions?.push === true; + } catch (error) { + core.warning( + `could not read ${info.authorLogin} permission (${error.status}); treating as Community`); + } + return canWrite ? SOURCE_INTERNAL : SOURCE_COMMUNITY; + } + + // Numbers of every open PR in this repo (paginated). The sweep's outer + // enumeration; the per-PR engine below is shared with the event path. + async function listOpenPullRequests() { + const numbers = []; + let cursor = null; + for (;;) { + const data = await github.graphql(` + query($owner: String!, $name: String!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: 100, after: $cursor, + orderBy: {field: UPDATED_AT, direction: DESC}) { + pageInfo { hasNextPage endCursor } + nodes { number } + } + } + }`, { owner: context.repo.owner, name: context.repo.repo, cursor }); + const conn = data?.repository?.pullRequests; + for (const n of (conn?.nodes || [])) numbers.push(n.number); + if (!conn?.pageInfo?.hasNextPage) break; + cursor = conn.pageInfo.endCursor; + } + return numbers; + } + + // Reconcile a PR's board Source + Status to its observed state, then + // backfill its assignee. Used by every event that can change the lattice + // state AND by the sweep, so each just recomputes the whole target rather + // than hard-coding a transition. + async function reconcileStatus(number, nodeId, itemId) { + const info = await prInfo(number); + if (!info) return; + itemId = itemId || await ensureItem(nodeId || info.nodeId); + if (!itemId) return; + // Resolve Source like pr_sweep.py: the board value when set + // (authoritative; honors a manual override), else the freshly- + // classified value (opened/reopened), else classify now (the sweep, or + // an event without the classify step). Backfill the board when it had + // none, so a PR first seen by the sweep gets its Source. is_bot is then + // "Source == Bot". + let resolvedSource = await itemSingleSelect(itemId, SOURCE_FIELD); + if (!resolvedSource) { + resolvedSource = process.env.SOURCE_VALUE || await classifySource(info); + if (resolvedSource) await setSingleSelect(itemId, SOURCE_FIELD, resolvedSource); + } + if (resolvedSource) info.isBot = (resolvedSource === process.env.SOURCE_BOT); + // Only write Status when it actually changes, so a sweep over every PR + // does not rewrite (and re-timeline) unchanged cards every night. + const target = computeTarget(info); + if ((await itemSingleSelect(itemId, STATUS.field)) !== target) { + await setSingleSelect(itemId, STATUS.field, target); + } + // Assignment is best-effort: a failure here (e.g. an unrequestable + // reviewer, or a transient API error in the signal ranking) must not + // undo the Status write or fail the run -- the sweep will reconcile. + try { + await reconcileAssignment(info, resolvedSource, number); + } catch (error) { + core.warning(`assignment failed for #${number}: ${error.message || error}`); + } + } + + // Resolve a commit SHA (and any pre-listed PRs) to its open PR(s) and + // reconcile each. check_suite and workflow_run are both commit-scoped + // and their pull_requests array is empty for fork PRs, so resolve via + // the API too. (One commit can head several PRs, hence a set.) + async function reconcileByCommit(headSha, prNodes) { + const numbers = new Set(); + for (const p of (prNodes || [])) { + if (!p.state || p.state === "open") numbers.add(p.number); + } + if (headSha) { + try { + const assoc = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, repo: context.repo.repo, commit_sha: headSha }); + for (const p of (assoc.data || [])) { + if (p.state === "open") numbers.add(p.number); + } + } catch (error) { + core.warning(`could not resolve PRs for ${headSha} (${error.status})`); + } + } + for (const number of numbers) await reconcileStatus(number, null); + } + + // --- Dispatch on the triggering event ---------------------------- + const ev = context.eventName; + const action = context.payload.action; + + if (SWEEP) { + // Cadence sweep: reconcile every open PR in this repo. The per-PR + // engine (Source/Status/assignment) is shared with the event path; + // only this enumeration differs. The idempotency guards in + // reconcileStatus / reconcileAssignment keep an unchanged PR a no-op. + const numbers = await listOpenPullRequests(); + console.log(`sweep: reconciling ${numbers.length} open PR(s)`); + for (const number of numbers) { + await reconcileStatus(number, null); + } + } else if (ev === "pull_request_target") { + const pr = context.payload.pull_request; + const itemId = await ensureItem(pr.node_id); + if (!itemId) { core.warning("could not add PR to board"); return; } + + if (action === "opened" || action === "reopened") { + // Set the classified Source only when the board has none yet, so + // an existing value (incl. a manual override, e.g. on reopen) is + // authoritative - matching pr_sweep.py, which only backfills Source. + const source = process.env.SOURCE_VALUE; + if (source && !(await itemSingleSelect(itemId, SOURCE_FIELD))) { + await setSingleSelect(itemId, SOURCE_FIELD, source); + } + } + + if (action === "closed") { + // Closed (merged or otherwise) -> Done; terminal, and the only + // state not derived from computeTarget (a closed PR is not open). + await setSingleSelect(itemId, STATUS.field, STATUS.done); + } else if (action === "dequeued") { + // Fell out of the merge queue -> Snagged (a human re-queues/merges). + // The cadence sweep agrees: a dequeue does not change the head + // commit, and a PR needs green CI to have been enqueued, so the + // dropped PR reads approved + green + un-queued, which computeTarget + // also maps to Snagged. (A new commit is the one thing that makes + // head CI pending, but that dequeues via `synchronize` -> In Review.) + await setSingleSelect(itemId, STATUS.field, STATUS.snagged); + } else { + // opened / reopened / edited / synchronize / enqueued / + // ready_for_review / converted_to_draft: recompute the whole target + // from current state. + await reconcileStatus(pr.number, pr.node_id, itemId); + } + } else if (ev === "pull_request_review") { + // Any review (approve / changes-request / dismiss) -> recompute. + const pr = context.payload.pull_request; + const itemId = await ensureItem(pr.node_id); + if (itemId) await reconcileStatus(pr.number, pr.node_id, itemId); + } else if (ev === "check_suite") { + // check_suite is commit-scoped: a CI completion recomputes the + // target, so a green run also recovers a Snagged/Revising PR. Its + // pull_requests array is empty for fork PRs, so resolve by head SHA. + const suite = context.payload.check_suite || {}; + await reconcileByCommit(suite.head_sha, suite.pull_requests); + } else if (ev === "workflow_run") { + // Stage 2 of the fork-review relay (see header): a fork PR's review + // can't get secrets, so an unprivileged caller bridge fires on the + // review and completes; this privileged run resolves the PR from the + // upstream run's head SHA and reconciles. Metadata only -- NEVER add a + // checkout here (the upstream ran fork-controlled context). + const wr = context.payload.workflow_run || {}; + await reconcileByCommit(wr.head_sha, wr.pull_requests); + } + + # --- Unrequest auto-assigned non-approver reviewers -------------------- + # Runs only at onboarding (opened/reopened) to strip an auto-assigned + # non-approver (e.g. bmillsNV) that repo automation requests on a new PR. It + # deliberately does NOT run on review_requested, so a maintainer manually + # requesting that reviewer is never undone. reconcileAssignment also unrequests + # these (coupled to assignment, like the sweep), but only when it assigns an + # owner; this onboarding step still covers the PRs assignment skips (Source + # not yet known, a human draft, merge-queued, or no maintainer resolved). The + # cadence sweep (scripts/pr_sweep.py) remains the backstop for both. + - name: Unrequest ignored reviewers + if: >- + env.HAS_TOKEN == 'true' && + github.event_name == 'pull_request_target' && + (github.event.action == 'opened' || github.event.action == 'reopened') + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + IGNORED_REVIEWERS: ${{ inputs.ignored_reviewers }} + with: + github-token: ${{ secrets.SLANG_PR_BOT_TOKEN }} + script: | + const ignored = (process.env.IGNORED_REVIEWERS || "") + .split(",").map(s => s.trim().toLowerCase()).filter(Boolean); + if (!ignored.length) return; + const pr = context.payload.pull_request; + if (!pr) return; + const requested = (pr.requested_reviewers || []).map(r => r.login); + const toRemove = requested.filter(l => ignored.includes((l || "").toLowerCase())); + if (!toRemove.length) return; + await github.rest.pulls.removeRequestedReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + reviewers: toRemove, + }); + console.log(`unrequested: ${toRemove.join(", ")}`); diff --git a/.github/workflows/pr-maintenance.yml b/.github/workflows/pr-maintenance.yml new file mode 100644 index 00000000000..25047a47a5b --- /dev/null +++ b/.github/workflows/pr-maintenance.yml @@ -0,0 +1,44 @@ +name: PR Maintenance + +# Thin caller for the reusable PR board-sync workflow. Declares the PR event +# triggers (a reusable workflow cannot declare them itself) and delegates all +# logic to pr-board-sync.yml. slang consumes its own reusable workflow so there +# is a single source of truth; other shader-slang repos use the cross-repo form: +# +# uses: shader-slang/slang/.github/workflows/pr-board-sync.yml@master +# +# pull_request_target and check_suite are privileged events that receive the +# secret even for fork PRs, so they run here directly. pull_request_review only +# receives the secret for ORIGIN PRs, so the job below skips it for forks; fork +# reviews are handled by the pr-review-fork-bridge.yml -> pr-review-fork-apply.yml +# (workflow_run) relay. See pr-board-sync.yml for the board IDs and the secret. + +on: + pull_request_target: + types: + [ + opened, + reopened, + edited, + synchronize, + closed, + ready_for_review, + converted_to_draft, + enqueued, + dequeued, + ] + pull_request_review: + types: [submitted] + check_suite: + types: [completed] + +jobs: + board-sync: + # Run directly for everything except a fork PR's review (no secret there); + # fork reviews go through the workflow_run relay instead. + if: >- + github.event_name != 'pull_request_review' || + github.event.pull_request.head.repo.fork == false + uses: ./.github/workflows/pr-board-sync.yml + secrets: + SLANG_PR_BOT_TOKEN: ${{ secrets.SLANG_PR_BOT_TOKEN }} diff --git a/.github/workflows/pr-review-fork-apply.yml b/.github/workflows/pr-review-fork-apply.yml new file mode 100644 index 00000000000..a16c2f55243 --- /dev/null +++ b/.github/workflows/pr-review-fork-apply.yml @@ -0,0 +1,28 @@ +name: PR Review Fork Apply + +# Stage 2 of the fork-review relay for the PR board sync. Triggered by the +# completion of "PR Review Fork Bridge" (pr-review-fork-bridge.yml). +# +# A workflow_run workflow runs in the base repo's PRIVILEGED context with secrets +# even when the upstream run was a fork PR's review -- which is the only way to +# update the board for fork-PR reviews on a public repo. The reusable workflow's +# workflow_run dispatch branch resolves the PR from the upstream run's head SHA +# and reconciles. It does NO checkout (metadata only), so executing it in this +# privileged context is not a pwn-request vector. +# +# (workflow_run only fires for workflow files on the default branch, so this +# activates once the workflow is merged to the default branch.) + +on: + workflow_run: + workflows: ["PR Review Fork Bridge"] + types: [completed] + +jobs: + board-sync: + # Only when the bridge completed successfully (it always should; this guards + # against a cancelled/failed relay run). + if: ${{ github.event.workflow_run.conclusion == 'success' }} + uses: ./.github/workflows/pr-board-sync.yml + secrets: + SLANG_PR_BOT_TOKEN: ${{ secrets.SLANG_PR_BOT_TOKEN }} diff --git a/.github/workflows/pr-review-fork-bridge.yml b/.github/workflows/pr-review-fork-bridge.yml new file mode 100644 index 00000000000..89d84ab1e12 --- /dev/null +++ b/.github/workflows/pr-review-fork-bridge.yml @@ -0,0 +1,29 @@ +name: PR Review Fork Bridge + +# Stage 1 of the fork-review relay for the PR board sync. +# +# On a PUBLIC repo, a pull_request_review on a PR from a FORK runs the fork's +# head with no secrets, so it cannot update the board directly. This workflow is +# that unprivileged stage: for fork-PR reviews only, it does nothing but complete +# successfully, which lets a privileged `workflow_run` workflow +# (pr-review-fork-apply.yml) fire and do the board update with secrets. +# +# It deliberately runs NO board logic and checks out NO code -- it exists solely +# to relay the event. Origin-PR reviews are handled directly by pr-maintenance.yml, +# so this is gated to forks to avoid a redundant relay run. + +on: + pull_request_review: + types: [submitted] + +permissions: {} + +jobs: + bridge: + if: ${{ github.event.pull_request.head.repo.fork == true }} + runs-on: ubuntu-latest + steps: + - name: Relay fork PR review to the privileged apply workflow + run: | + echo "Fork PR #${{ github.event.pull_request.number }} review submitted." + echo "Completing so pr-review-fork-apply.yml (workflow_run) can reconcile with secrets." diff --git a/.github/workflows/pr-sweep-nightly.yml b/.github/workflows/pr-sweep-nightly.yml new file mode 100644 index 00000000000..ec89733680e --- /dev/null +++ b/.github/workflows/pr-sweep-nightly.yml @@ -0,0 +1,22 @@ +name: PR Board Sweep (nightly) + +# Scheduled cadence caller for the reusable board-sync workflow in "sweep" mode: +# reconcile every open PR in this repo onto the shared "Slang PR Tracking" board. +# It is the periodic backstop for what the per-event path (pr-maintenance.yml) +# cannot see -- missed webhooks or failed runs, an issue assigned/linked after +# the fact, board drift -- and shares all per-PR logic with it (only the +# enumeration differs). See .github/workflows/pr-board-sync.yml for the board IDs +# and required secret. + +on: + schedule: + - cron: "0 7 * * *" # nightly ~07:00 UTC (GitHub may delay under load) + workflow_dispatch: {} # allow on-demand runs + +jobs: + board-sweep: + uses: ./.github/workflows/pr-board-sync.yml + with: + mode: sweep + secrets: + SLANG_PR_BOT_TOKEN: ${{ secrets.SLANG_PR_BOT_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9739a44868b..fd159e6bb1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -286,6 +286,8 @@ Once a PR is created against `shader-slang/slang:master`, the PR will be merged When the conditions above are all met, you will have a chance to rewrite the commit message. Since the Slang repo uses the "squash" strategy for merging, multiple commits in your PR will become one commit. By default, GitHub will concatenate all of the commit messages sequentially, but often it is not readable. Please rewrite the final commit message in a way that people can easily understand what the purpose of the commit is. +> **Committers:** PRs are tracked on a shared project board whose status is updated automatically as a PR progresses. The board is an org project visible only to `shader-slang` org members, so outside contributors won't see it — that's expected. If you are a committer assigned to shepherd or review PRs, see [The Slang PR Tracking board](docs/maintainers/pr-review-board.md). + There are two cases where the workflow may fail for reasons that are not directly related to the change: 1. "Breaking change" labeling is missing. diff --git a/docs/maintainers/pr-review-board.md b/docs/maintainers/pr-review-board.md new file mode 100644 index 00000000000..3d7e02b2e5a --- /dev/null +++ b/docs/maintainers/pr-review-board.md @@ -0,0 +1,100 @@ +# The Slang PR Tracking board (for assignees) + +This page is for **maintainers and PR assignees** — the people who get pull +requests routed to them on the shared +[Slang PR Tracking](https://github.com/orgs/shader-slang/projects/13) project +board. It explains what the board's fields mean, what each `Status` tells you, +and when a PR actually needs your attention. (If you're just opening a PR, you +don't need this — see [CONTRIBUTING.md](../../CONTRIBUTING.md).) + +> The board is a `shader-slang` org project, so it is only visible to **org +> members (committers)**. Outside contributors can't see it — that's expected; +> their PRs are still tracked on it by the committers who shepherd them. + +You never set the board fields by hand: they are maintained automatically from +PR activity (open/close, pushes, reviews, CI results, merge-queue changes). Your +job is to **read the state and act when it asks you to**. + +## How a PR lands on your plate: the `Source` field + +Every PR carries a **`Source`** — one of: + +- **Internal** — opened by someone with write access to the repo. The **author + drives it**; they're the assignee, and **they are expected to identify and + request their own reviewer** (no reviewer is auto-requested for Internal PRs). + If you're a newer committer and unsure who to ask, pick someone who has + recently touched the same files, or ask in the team channel. +- **Community** — opened by an outside contributor. A maintainer (you) is + assigned to shepherd it and arrange review. +- **Bot** — opened by an automated coworker. A maintainer is assigned to + shepherd it to ready-for-review and merge. + +Being the assignee on a **Community** or **Bot** PR means you're responsible for +moving it forward (or finding the right reviewer). + +## What each `Status` means for you + +| Status | What it means | Do you act? | +|---|---|---| +| **In Review** | The default for an open PR: awaiting review, CI still running, or a fresh commit not yet reviewed. (A **Bot draft** sits here too, so you can see and shepherd it.) | **Yes** — review it, or make sure a real reviewer is requested. | +| **Revising** | The author is working: a **human draft**, or a reviewer requested changes. (A **Bot** PR's failed CI also lands here — the bot fixes itself.) | **No** — it's on the author/bot until it moves. | +| **Snagged** | Needs a human's attention: a human PR's **CI failed**, **CI is awaiting your approval to run** (fork PRs from new contributors), or the PR is **approved + green but not in the merge queue** (it fell out, or needs someone to enqueue/merge it). | **Yes** — approve the CI run, help fix CI, or enqueue/merge. | +| **Approved** | Not a draft, already has an approving review, and is waiting only on CI or the merge queue. | **No** — automated; nothing to do. | +| **Done** | The PR is closed (merged or otherwise). Terminal. | No. | + +In short: **`In Review` and `Snagged` are the two columns that want you.** +`Revising`/`Approved` are waiting on someone/something else, and `Done` is finished. + +## How the state is decided (priority) + +When several conditions are true at once, the first matching rule wins: + +1. A reviewer's **changes-request** (made on the current commit) → **Revising**. +2. **Draft** → **In Review** (Bot) / **Revising** (human). +3. CI **needs approval** → **Snagged**; CI **failed** → **Revising** (Bot) / + **Snagged** (human). +4. **Approved**: **Snagged** if CI is green and it's not in the merge queue, + otherwise **Approved**. +5. Otherwise → **In Review**. + +Two things worth knowing: + +- A review only counts while it's on the **current** commit. A new push + supersedes earlier feedback, so the PR returns to **In Review** until it's + re-reviewed. +- The **only** difference between the Bot and human flows is what a **CI failure** + does (Bot → `Revising`, human → `Snagged`); everything else is identical. + +## The decision, as a flowchart + +The board does not move a PR along edges between states — it **recomputes** the +status from the PR's current signals on every event. So the priority rules above +are best read as a decision tree (first match wins): + +```mermaid +flowchart TD + ev([PR event - recompute]) --> closed{"Closed?"} + closed -->|yes| done["Done"] + closed -->|no| cr{"Changes requested
on current commit?"} + cr -->|yes| rev["Revising"] + cr -->|no| draft{"Draft?"} + draft -->|"yes (human)"| rev + draft -->|"yes (bot)"| inrev["In Review"] + draft -->|no| ciappr{"CI awaiting approval?"} + ciappr -->|yes| snag["Snagged"] + ciappr -->|no| cifail{"CI failed?"} + cifail -->|"yes (bot)"| rev + cifail -->|"yes (human)"| snag + cifail -->|no| appr{"Approved?"} + appr -->|"yes, green and not queued"| snag + appr -->|"yes, pending or queued"| approved["Approved"] + appr -->|no| inrev +``` + +## Source of truth + +This flowchart is an illustration. The authoritative rules live in the +`computeTarget` function in +[`.github/workflows/pr-board-sync.yml`](../../.github/workflows/pr-board-sync.yml), +which sets the board `Status` from PR events. If the diagram here ever disagrees +with that function, the function is correct — please fix the diagram.