From 77ee32863d0e69295850927513da2a8ff4303d26 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 19 Oct 2026 11:15:18 -0700 Subject: [PATCH 1/2] ci: move linked issues across the backlog board from PR events Opening or pushing to a PR moves its linked issues to In review, and merging it into a release branch moves them to Pending release. A release PR into the default branch gets a Closes line for every issue its branch picked up, so GitHub closes them when the release merges. Issues only ever move forward. Adds a pull request template whose Closes line feeds the automation. --- .github/pull_request_template.md | 22 +++ .github/scripts/issue-status.js | 243 +++++++++++++++++++++++++++++ .github/workflows/issue-status.yml | 41 +++++ 3 files changed, 306 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .github/scripts/issue-status.js create mode 100644 .github/workflows/issue-status.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5ca95c65 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +## Summary + + + +Closes # + + + +## Tests + + + +## Checklist + +- [ ] Targets the open patch branch if this is a small, non-breaking change, or the cycle's `release-..0` + branch otherwise; never `main` +- [ ] API schema change? A matching `mavedb-ui` PR regenerates `src/schema/openapi.d.ts` +- [ ] Docs and instruction files touched by this change are updated diff --git a/.github/scripts/issue-status.js b/.github/scripts/issue-status.js new file mode 100644 index 00000000..cd068290 --- /dev/null +++ b/.github/scripts/issue-status.js @@ -0,0 +1,243 @@ +/** + * Moves issues across the MaveDB Backlog project as the pull requests linked to them progress. + * + * - A PR is opened, pushed to or marked ready: its linked issues go to In review (In progress while it's a draft). + * - A PR merges into a release branch: its linked issues go to Pending release, once no other open PR still links them. + * - A release branch's PR into the default branch is opened or pushed to: its description gets a "Closes" line for every + * open issue linked from a PR merged into that branch since its last release, so GitHub closes them when the release + * merges. The board's "Item closed" workflow then sets Done. + * + * Release branches are release-* in mavedb-api and mavedb-ui, and the long-lived mavedb-dev in dcd_mapping2. The + * pattern covers every repo because issues are linked across repos, and each repo carries an identical copy of this + * file. + * + * Issues only move forward, so the release PR linking an issue that is already Pending release leaves it there. Issues + * that aren't on the board are skipped, not added: adding one fires the board's "Item added to project" workflow, which + * would race this script's status change. + * + * Called from .github/workflows/issue-status.yml through actions/github-script, with a GitHub App token that can write + * to the org's projects. + */ + +const PROJECT_OWNER = 'VariantEffect'; +const PROJECT_NUMBER = 4; + +// Board order, left to right. Matched by suffix so the option emoji can change without breaking this. +const ORDER = ['New', 'Backlog', 'Ready', 'In progress', 'Waiting/Blocked', 'In review', 'Pending release', 'Done']; + +const RELEASE_SECTION_START = ''; +const RELEASE_SECTION_END = ''; + +const rank = (statusName) => (statusName ? ORDER.findIndex((label) => statusName.endsWith(label)) : -1); +const isReleaseBranch = (ref) => /^(release-.+|mavedb-dev)$/.test(ref); + +async function loadProject(github) { + const { organization } = await github.graphql( + `query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + field(name: "Status") { ... on ProjectV2SingleSelectField { id options { id name } } } + } + } + }`, + { owner: PROJECT_OWNER, number: PROJECT_NUMBER }, + ); + const project = organization.projectV2; + const options = Object.fromEntries( + ORDER.map((label) => { + const option = project.field.options.find((o) => o.name.endsWith(label)); + if (!option) throw new Error(`The project's Status field has no "${label}" option.`); + return [label, option.id]; + }), + ); + return { id: project.id, fieldId: project.field.id, options }; +} + +async function linkedIssues(github, owner, repo, number) { + const { repository } = await github.graphql( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 50) { + nodes { + id number state + repository { nameWithOwner } + projectItems(first: 20) { + nodes { + id + project { id } + status: fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } } + } + } + closedByPullRequestsReferences(first: 20, includeClosedPrs: false) { + nodes { number headRefName repository { nameWithOwner } } + } + } + } + } + } + }`, + { owner, repo, number }, + ); + return repository.pullRequest.closingIssuesReferences.nodes.filter((issue) => issue.state === 'OPEN'); +} + +async function moveIssue(github, core, project, issue, target) { + const label = `${issue.repository.nameWithOwner}#${issue.number}`; + const item = issue.projectItems.nodes.find((i) => i.project.id === project.id); + if (!item) { + core.notice(`${label} isn't on the project; skipping.`); + return; + } + const current = item.status?.name; + if (rank(current) >= ORDER.indexOf(target)) { + core.info(`${label} is already at "${current}"; leaving it.`); + 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: item.id, field: project.fieldId, option: project.options[target] }, + ); + core.info(`${label}: "${current ?? 'no status'}" -> "${target}".`); +} + +async function markInReview(github, core, project, owner, repo, pr) { + const target = pr.draft ? 'In progress' : 'In review'; + for (const issue of await linkedIssues(github, owner, repo, pr.number)) { + await moveIssue(github, core, project, issue, target); + } +} + +// An issue split across several PRs, possibly in different repos, waits for the last of them. Release PRs link every +// issue in the release, so they don't count. +function stillOpenElsewhere(core, issue) { + const open = issue.closedByPullRequestsReferences.nodes.filter((p) => !isReleaseBranch(p.headRefName)); + if (open.length) { + const refs = open.map((p) => `${p.repository.nameWithOwner}#${p.number}`).join(', '); + core.info(`${issue.repository.nameWithOwner}#${issue.number} is still linked from open ${refs}; leaving it.`); + } + return open.length > 0; +} + +async function markPendingRelease(github, core, project, owner, repo, pr) { + // By the time "closed" fires, the merged PR no longer counts as open. + for (const issue of await linkedIssues(github, owner, repo, pr.number)) { + if (!stillOpenElsewhere(core, issue)) await moveIssue(github, core, project, issue, 'Pending release'); + } +} + +// Long-lived release branches (mavedb-dev) keep their history, so only PRs merged since the branch last shipped count. +async function lastReleasedAt(github, owner, repo, pr) { + const { search } = await github.graphql( + `query($query: String!) { + search(type: ISSUE, query: $query, first: 1) { nodes { ... on PullRequest { mergedAt } } } + }`, + { query: `repo:${owner}/${repo} is:pr is:merged head:${pr.head.ref} base:${pr.base.ref} sort:created-desc` }, + ); + return search.nodes[0]?.mergedAt ?? null; +} + +async function updateReleaseDescription(github, core, owner, repo, pr) { + const since = await lastReleasedAt(github, owner, repo, pr); + const query = `repo:${owner}/${repo} is:pr is:merged base:${pr.head.ref}` + (since ? ` merged:>${since}` : ''); + const issues = new Map(); + let cursor = null; + do { + const { search } = await github.graphql( + `query($query: String!, $cursor: String) { + search(type: ISSUE, query: $query, first: 50, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + ... on PullRequest { + closingIssuesReferences(first: 50) { + nodes { + number state + repository { nameWithOwner } + closedByPullRequestsReferences(first: 20, includeClosedPrs: false) { + nodes { number headRefName repository { nameWithOwner } } + } + } + } + } + } + } + }`, + { query, cursor }, + ); + for (const merged of search.nodes) { + for (const issue of merged.closingIssuesReferences.nodes) { + if (issue.state !== 'OPEN' || stillOpenElsewhere(core, issue)) continue; + const sameRepo = issue.repository.nameWithOwner === `${owner}/${repo}`; + const ref = sameRepo ? `#${issue.number}` : `${issue.repository.nameWithOwner}#${issue.number}`; + issues.set(ref, issue); + } + } + cursor = search.pageInfo.hasNextPage ? search.pageInfo.endCursor : null; + } while (cursor); + + const refs = [...issues.keys()].sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); + const section = [ + RELEASE_SECTION_START, + '### Issues closed by this release', + '', + `_Generated from the issues linked to PRs merged into \`${pr.head.ref}\` for this release, and rewritten on every push. Issues still linked from an open PR are left out. To change the list, change those PRs' linked issues._`, + '', + ...(refs.length ? refs.map((ref) => `- Closes ${ref}`) : ['None.']), + RELEASE_SECTION_END, + ].join('\n'); + + const body = pr.body ?? ''; + const start = body.indexOf(RELEASE_SECTION_START); + const end = body.indexOf(RELEASE_SECTION_END); + const updated = + start !== -1 && end > start + ? body.slice(0, start) + section + body.slice(end + RELEASE_SECTION_END.length) + : `${body.trimEnd()}\n\n${section}\n`; + if (updated === body) { + core.info('Release description is up to date.'); + return; + } + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body: updated }); + core.info(`Release description lists ${refs.length} issue(s).`); +} + +module.exports = async ({ github, context, core }) => { + const { owner, repo } = context.repo; + const project = await loadProject(github); + + // Linking an issue from the PR's Development sidebar fires no event, so a periodic sweep catches those. + if (context.eventName === 'schedule' || context.eventName === 'workflow_dispatch') { + const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100 }); + for (const pr of prs.filter((p) => !isReleaseBranch(p.head.ref))) { + await markInReview(github, core, project, owner, repo, pr); + } + return; + } + + const pr = context.payload.pull_request; + const action = context.payload.action; + + if (isReleaseBranch(pr.head.ref) && pr.base.ref === context.payload.repository.default_branch) { + if (['opened', 'reopened', 'synchronize'].includes(action)) { + await updateReleaseDescription(github, core, owner, repo, pr); + } + return; + } + + if (action === 'closed') { + if (pr.merged && isReleaseBranch(pr.base.ref)) { + await markPendingRelease(github, core, project, owner, repo, pr); + } + return; + } + + // Hotfixes merged straight into the default branch close their issues natively. + if (pr.base.ref !== context.payload.repository.default_branch) { + await markInReview(github, core, project, owner, repo, pr); + } +}; diff --git a/.github/workflows/issue-status.yml b/.github/workflows/issue-status.yml new file mode 100644 index 00000000..8d5d305b --- /dev/null +++ b/.github/workflows/issue-status.yml @@ -0,0 +1,41 @@ +name: Issue status + +# Moves linked issues across the MaveDB Backlog project; the logic and its rules are in .github/scripts/issue-status.js. +# pull_request_target runs this file from the base branch with the org secrets, so it works for fork PRs too. It never +# checks out or runs the PR's code. +on: + pull_request_target: + types: [opened, reopened, edited, synchronize, ready_for_review, converted_to_draft, closed] + schedule: + - cron: '17 * * * *' + workflow_dispatch: + +concurrency: + group: issue-status-${{ github.event.pull_request.number || github.run_id }} + +permissions: + contents: read + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + + # The default GITHUB_TOKEN can't write to org projects. The app needs org Projects read/write and repo Issues + # read and Pull requests read/write, installed on mavedb-api, mavedb-ui and dcd_mapping2. + - uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.PROJECT_AUTOMATION_APP_ID }} + private-key: ${{ secrets.PROJECT_AUTOMATION_PRIVATE_KEY }} + owner: VariantEffect + + - uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const run = require('./.github/scripts/issue-status.js'); + await run({ github, context, core }); From b0eceb2425c2b1d3c4747eff9964149f6f795cda Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 19 Oct 2026 11:27:24 -0700 Subject: [PATCH 2/2] ci: read Closes lines from PR descriptions GitHub turns a Closes line into an issue link only on PRs into the default branch. On a PR into a release branch it stays a plain mention, so the workflow missed issues named that way. The script now reads closing lines from PR descriptions as well as the sidebar links. --- .github/pull_request_template.md | 2 +- .github/scripts/issue-status.js | 123 ++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 43 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5ca95c65..604b0b68 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,7 +5,7 @@ Closes # diff --git a/.github/scripts/issue-status.js b/.github/scripts/issue-status.js index cd068290..ec24876d 100644 --- a/.github/scripts/issue-status.js +++ b/.github/scripts/issue-status.js @@ -11,6 +11,10 @@ * pattern covers every repo because issues are linked across repos, and each repo carries an identical copy of this * file. * + * A PR's issues are the ones linked from its Development sidebar plus those named on a "Closes #N" line in its + * description. GitHub turns that line into a link only on PRs into the default branch; on a PR into a release branch it + * is a plain mention, so this script reads the description itself. + * * Issues only move forward, so the release PR linking an issue that is already Pending release leaves it there. Issues * that aren't on the board are skipped, not added: adding one fires the board's "Item added to project" workflow, which * would race this script's status change. @@ -54,33 +58,73 @@ async function loadProject(github) { return { id: project.id, fieldId: project.field.id, options }; } -async function linkedIssues(github, owner, repo, number) { +const ISSUE_FIELDS = ` + id number state + repository { nameWithOwner } + projectItems(first: 20) { + nodes { + id + project { id } + status: fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } } + } + } + closedByPullRequestsReferences(first: 20, includeClosedPrs: false) { + nodes { number headRefName repository { nameWithOwner } } + } +`; + +const CLOSING_REF = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+(?:([\w.-]+)\/([\w.-]+))?#(\d+)\b/gi; + +// HTML comments are dropped first so the template's own "Closes #123" example doesn't count. +function closingRefs(body, owner, repo) { + const text = (body ?? '').replace(//g, ''); + return [...text.matchAll(CLOSING_REF)].map(([, o, r, n]) => ({ owner: o ?? owner, repo: r ?? repo, number: Number(n) })); +} + +const linkRefs = (links) => + links.nodes.map((i) => { + const [owner, repo] = i.repository.nameWithOwner.split('/'); + return { owner, repo, number: i.number }; + }); + +// Fetches the issues behind a set of refs in one query. Refs that turn out to be PRs, or that don't resolve, are dropped. +async function fetchIssues(github, refs) { + const unique = [...new Map(refs.map((r) => [`${r.owner}/${r.repo}#${r.number}`.toLowerCase(), r])).values()]; + if (!unique.length) return []; + const query = `query { + ${unique + .map( + (r, i) => `i${i}: repository(owner: ${JSON.stringify(r.owner)}, name: ${JSON.stringify(r.repo)}) { + issueOrPullRequest(number: ${r.number}) { ... on Issue { ${ISSUE_FIELDS} } } + }`, + ) + .join('\n')} + }`; + let data; + try { + data = await github.graphql(query); + } catch (error) { + if (!error.data) throw error; + data = error.data; // A ref to a missing repo fails only its own alias. + } + return Object.values(data) + .map((r) => r?.issueOrPullRequest) + .filter((issue) => issue?.id && issue.state === 'OPEN'); +} + +async function linkedIssues(github, owner, repo, pr) { const { repository } = await github.graphql( `query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { - closingIssuesReferences(first: 50) { - nodes { - id number state - repository { nameWithOwner } - projectItems(first: 20) { - nodes { - id - project { id } - status: fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } } - } - } - closedByPullRequestsReferences(first: 20, includeClosedPrs: false) { - nodes { number headRefName repository { nameWithOwner } } - } - } - } + closingIssuesReferences(first: 50) { nodes { number repository { nameWithOwner } } } } } }`, - { owner, repo, number }, + { owner, repo, number: pr.number }, ); - return repository.pullRequest.closingIssuesReferences.nodes.filter((issue) => issue.state === 'OPEN'); + const links = repository.pullRequest.closingIssuesReferences; + return fetchIssues(github, [...linkRefs(links), ...closingRefs(pr.body, owner, repo)]); } async function moveIssue(github, core, project, issue, target) { @@ -108,13 +152,13 @@ async function moveIssue(github, core, project, issue, target) { async function markInReview(github, core, project, owner, repo, pr) { const target = pr.draft ? 'In progress' : 'In review'; - for (const issue of await linkedIssues(github, owner, repo, pr.number)) { + for (const issue of await linkedIssues(github, owner, repo, pr)) { await moveIssue(github, core, project, issue, target); } } // An issue split across several PRs, possibly in different repos, waits for the last of them. Release PRs link every -// issue in the release, so they don't count. +// issue in the release, so they don't count. Only sidebar links are visible here, not "Closes" lines in other PRs. function stillOpenElsewhere(core, issue) { const open = issue.closedByPullRequestsReferences.nodes.filter((p) => !isReleaseBranch(p.headRefName)); if (open.length) { @@ -126,7 +170,7 @@ function stillOpenElsewhere(core, issue) { async function markPendingRelease(github, core, project, owner, repo, pr) { // By the time "closed" fires, the merged PR no longer counts as open. - for (const issue of await linkedIssues(github, owner, repo, pr.number)) { + for (const issue of await linkedIssues(github, owner, repo, pr)) { if (!stillOpenElsewhere(core, issue)) await moveIssue(github, core, project, issue, 'Pending release'); } } @@ -145,7 +189,7 @@ async function lastReleasedAt(github, owner, repo, pr) { async function updateReleaseDescription(github, core, owner, repo, pr) { const since = await lastReleasedAt(github, owner, repo, pr); const query = `repo:${owner}/${repo} is:pr is:merged base:${pr.head.ref}` + (since ? ` merged:>${since}` : ''); - const issues = new Map(); + const refs = []; let cursor = null; do { const { search } = await github.graphql( @@ -154,15 +198,8 @@ async function updateReleaseDescription(github, core, owner, repo, pr) { pageInfo { hasNextPage endCursor } nodes { ... on PullRequest { - closingIssuesReferences(first: 50) { - nodes { - number state - repository { nameWithOwner } - closedByPullRequestsReferences(first: 20, includeClosedPrs: false) { - nodes { number headRefName repository { nameWithOwner } } - } - } - } + body + closingIssuesReferences(first: 50) { nodes { number repository { nameWithOwner } } } } } } @@ -170,24 +207,26 @@ async function updateReleaseDescription(github, core, owner, repo, pr) { { query, cursor }, ); for (const merged of search.nodes) { - for (const issue of merged.closingIssuesReferences.nodes) { - if (issue.state !== 'OPEN' || stillOpenElsewhere(core, issue)) continue; - const sameRepo = issue.repository.nameWithOwner === `${owner}/${repo}`; - const ref = sameRepo ? `#${issue.number}` : `${issue.repository.nameWithOwner}#${issue.number}`; - issues.set(ref, issue); - } + refs.push(...linkRefs(merged.closingIssuesReferences), ...closingRefs(merged.body, owner, repo)); } cursor = search.pageInfo.hasNextPage ? search.pageInfo.endCursor : null; } while (cursor); - const refs = [...issues.keys()].sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); + const issues = new Map(); + for (const issue of await fetchIssues(github, refs)) { + if (stillOpenElsewhere(core, issue)) continue; + const sameRepo = issue.repository.nameWithOwner === `${owner}/${repo}`; + issues.set(sameRepo ? `#${issue.number}` : `${issue.repository.nameWithOwner}#${issue.number}`, issue); + } + + const listed = [...issues.keys()].sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); const section = [ RELEASE_SECTION_START, '### Issues closed by this release', '', - `_Generated from the issues linked to PRs merged into \`${pr.head.ref}\` for this release, and rewritten on every push. Issues still linked from an open PR are left out. To change the list, change those PRs' linked issues._`, + `_Generated from the issues linked to PRs merged into \`${pr.head.ref}\` for this release, and rewritten on every push. Issues still linked from an open PR are left out. To change the list, change those PRs' linked issues or Closes lines._`, '', - ...(refs.length ? refs.map((ref) => `- Closes ${ref}`) : ['None.']), + ...(listed.length ? listed.map((ref) => `- Closes ${ref}`) : ['None.']), RELEASE_SECTION_END, ].join('\n'); @@ -203,7 +242,7 @@ async function updateReleaseDescription(github, core, owner, repo, pr) { return; } await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body: updated }); - core.info(`Release description lists ${refs.length} issue(s).`); + core.info(`Release description lists ${listed.length} issue(s).`); } module.exports = async ({ github, context, core }) => {