Repository navigation
feat(pr-pipeline): arm native auto-merge for lane pull requests #9399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
060e179
feat(pr-pipeline): arm native auto-merge for lane pull requests
kyle-sexton f231b96
chore(pr-pipeline): mark enable-auto-merge.mjs executable
kyle-sexton 07352f7
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton 838629c
test(pr-pipeline): name the plan item, not an issue number, in test c…
kyle-sexton 93ba29f
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton ec4936f
test(pr-pipeline): pin report-outcome's PR body to the arm rule
kyle-sexton 689f2bb
fix(pr-pipeline): honor member vetoes and manual disables when arming
kyle-sexton 777fb0d
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton 2f6b030
fix(pr-pipeline): check hold paths on the armed head's files
kyle-sexton 5e92e85
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton 48340a6
feat(pr-pipeline): fail ci-status on a lanes App PR's body hold or me…
kyle-sexton 672a21e
fix(pr-pipeline): spell honors as the typos check wants
kyle-sexton 950f81e
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton c86f268
fix(pr-pipeline): run the lanes hold module only as main has it
kyle-sexton eea9401
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton 4815a44
fix(pr-pipeline): hold a lanes App pull request on a hold path in ci-…
kyle-sexton 614eb41
docs(pr-pipeline): note the ci-status hold rollout and its self-prote…
kyle-sexton c40dded
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton b4faaae
feat(pr-pipeline): let a trusted person's approval lift a ci-status h…
kyle-sexton bdb6336
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton f41d3a1
fix(pr-pipeline): hold dot-prefixed files under a denied path
kyle-sexton 27c4aaf
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton efb7a0e
fix(pr-pipeline): escape line breaks in merge-holds log lines
kyle-sexton 3e5ba98
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton a924859
fix(pr-pipeline): hold the merge gate's HIGH floor and pin Node in ci…
kyle-sexton 4783626
Merge remote-tracking branch 'origin/main' into feat/pr-pipeline-arm-…
kyle-sexton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| bump: minor | ||
| --- | ||
|
|
||
| ### Added | ||
|
|
||
| - The pr-merge lane's `enable-auto-merge` activity (effect `merge`) turns on GitHub's native auto-merge, squash at the head it read, for each open lanes App pull request into main that opens with `Closes #<issue>` and carries no hold (a `Hold:` line or "do not merge" in its body, `do-not-merge`, the human-gated label, a member's newest comment saying "do not merge", a head that now changes a denied path), and turns it off on an armed one that now carries a hold. Auto-merge a person turned off stays off. `pr-merge.yml` runs it in its own job, with its own lanes App token, when the issue lane finishes (a new `workflow_run` trigger the merge job ignores), on a listed human's edit, unlabel or ready, and on the schedule. While `merge.report-only` is true it writes nothing ([README.md, Native auto-merge](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#native-auto-merge)). `ci-status` fails a lanes App pull request on a body hold, a member's veto comment or a changed or renamed-from denied path (lifted by a trusted person's approval of the head), on each pull request event and in the merge queue, so a hold added after arming stops the merge. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,284 @@ | ||
| #!/usr/bin/env node | ||
| // enable-auto-merge.mjs: turns on GitHub's native auto-merge for the lanes | ||
| // App's open pull requests into main, and turns it off on one that now carries | ||
| // a hold. No model. pr-merge.yml's enable-auto-merge job runs it when the issue | ||
| // lane finishes, when a listed human edits, unlabels or readies a pull | ||
| // request, and on the schedule, which arms any the other events missed. | ||
| // | ||
| // A pull request is armed only when all of these hold: | ||
| // - The lanes App opened it (merge.agent-identity), into main, from a branch | ||
| // of this repository, and it is not a draft. | ||
| // - Its body opens with `Closes #<issue>`: a `Refs:` pull request is merged | ||
| // by a person (report-outcome.mjs). | ||
| // - It carries no hold: no `do-not-merge` and no human-gated label (the | ||
| // tracker's role label, `needs-human` by default), and no body hold | ||
| // (merge-holds.mjs). | ||
| // - No member vetoes it (merge-holds.mjs). ci-status fails on both holds too, | ||
| // so one added after arming stops the merge queue rather than waiting for | ||
| // this job's next run. | ||
| // - Its head changes no hold path: the merge lane's denied paths | ||
| // (report-outcome.readHoldPaths), read against the files, renamed-from paths | ||
| // included, that the head changes now, since a push after report-outcome | ||
| // wrote its Hold: line can add one. The rule is merge-holds.pathHold, which | ||
| // ci-status also runs on every lanes App pull request event and merge | ||
| // group, so a push after arming is held without waiting for this job. | ||
| // - No person turned its auto-merge off: the newest auto-merge-disabled event | ||
| // by a user account keeps it off until a person turns it on again. | ||
| // Arming passes the head just read as expectedHeadOid, so a push in between | ||
| // fails the arm rather than arming a head nobody read. An armed pull request | ||
| // that fails any rule is disarmed. While merge.report-only is true it reports | ||
| // each decision and writes nothing. | ||
| // | ||
| // Environment: | ||
| // GH_TOKEN the lane App token (contents and pull-requests write) | ||
| // GITHUB_REPOSITORY owner/repo | ||
| // KILL_SWITCH `proceed` from the kill-switch step; anything else stops | ||
| // REPORT_ONLY `false` writes; anything else reports only | ||
| // LANES_BOT_LOGIN merge.agent-identity, an App bot login | ||
| // TRACKER_CONFIG the base .work-item-tracker.json, for the human-gated | ||
| // label (default .base/.work-item-tracker.json) | ||
| // RESOLVED_CONFIG the resolve-config output, for the hold paths | ||
| // GITHUB_API_URL | ||
| import process from "node:process"; | ||
| import { pathToFileURL } from "node:url"; | ||
|
|
||
| import { createGitHub } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs"; | ||
| import { humanGatedLabelFrom } from "../pr-unblock/address-feedback.mjs"; | ||
| import { readHoldPaths } from "../work-resolve-issue/report-outcome.mjs"; | ||
| import { BODY_HOLD, pathHold, vetoers } from "./merge-holds.mjs"; | ||
|
|
||
| export { BODY_HOLD, HUMAN_VETO } from "./merge-holds.mjs"; | ||
|
|
||
| const REPO = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; | ||
| const SHA = /^[0-9a-f]{40}$/; | ||
| const BOT_LOGIN = /^([a-z0-9][a-z0-9-]*)\[bot\]$/; | ||
| // babysit_merge_policy.MERGE_HOLD_LABELS, plus the human-gated label. | ||
| const HOLD_LABELS = Object.freeze(["do-not-merge"]); | ||
| const CLOSES = /^Closes #[1-9][0-9]*\r?$/; | ||
| // 100 pull requests a page; more than this many open is a listing the run | ||
| // stops on rather than one it reads only part of. | ||
| const MAX_PAGES = 10; | ||
| // Changed files read per pull request, 100 a page; more is a head the run | ||
| // does not arm on. | ||
| const MAX_FILE_PAGES = 3; | ||
|
|
||
| export const PULLS_QUERY = `query($owner: String!, $name: String!, $after: String) { | ||
| repository(owner: $owner, name: $name) { | ||
| pullRequests(states: OPEN, baseRefName: "main", first: 100, after: $after) { | ||
| pageInfo { hasNextPage endCursor } | ||
| nodes { | ||
| id number isDraft isCrossRepository headRefOid body | ||
| author { __typename login } | ||
| labels(first: 100) { totalCount nodes { name } } | ||
| autoMergeRequest { enabledAt } | ||
| } | ||
| } | ||
| } | ||
| }`; | ||
| // One candidate's discussion. The newest 100 of each, and more than that is | ||
| // a discussion the run does not arm on rather than one it reads only part of. | ||
| export const DETAIL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | ||
| repository(owner: $owner, name: $name) { | ||
| pullRequest(number: $number) { | ||
| comments(last: 100) { totalCount nodes { author { __typename login } body createdAt authorAssociation } } | ||
| reviews(last: 100) { totalCount nodes { author { __typename login } body submittedAt authorAssociation } } | ||
| reviewThreads(last: 100) { | ||
| totalCount | ||
| nodes { comments(last: 100) { totalCount nodes { author { __typename login } body createdAt authorAssociation } } } | ||
| } | ||
| timelineItems(itemTypes: [AUTO_MERGE_DISABLED_EVENT], last: 1) { nodes { ... on AutoMergeDisabledEvent { actor { __typename login } } } } | ||
| } | ||
| } | ||
| }`; | ||
| export const ARM_MUTATION = `mutation($id: ID!, $head: GitObjectID!) { | ||
| enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: SQUASH, expectedHeadOid: $head }) { | ||
| pullRequest { autoMergeRequest { enabledAt } } | ||
| } | ||
| }`; | ||
| export const DISARM_MUTATION = `mutation($id: ID!) { | ||
| disablePullRequestAutoMerge(input: { pullRequestId: $id }) { clientMutationId } | ||
| }`; | ||
|
|
||
| const escapeCommand = (text) => String(text).replaceAll(/[\r\n%]/g, " "); | ||
|
|
||
| // Why `pull` must not be armed, or null when it may be. | ||
| export function holdReason(pull, humanGated) { | ||
| const labels = pull.labels?.nodes ?? []; | ||
| if (!(pull.labels?.totalCount <= labels.length)) { | ||
| return "its labels could not all be read"; | ||
| } | ||
| const names = new Set(labels.map((label) => String(label?.name).toLowerCase())); | ||
| const label = [...HOLD_LABELS, humanGated].find((name) => names.has(name.toLowerCase())); | ||
| if (label !== undefined) { | ||
| return `it carries ${label}`; | ||
| } | ||
| const body = String(pull.body ?? ""); | ||
| if (BODY_HOLD.test(body)) { | ||
| return "its body holds it"; | ||
|
kyle-sexton marked this conversation as resolved.
|
||
| } | ||
| if (!CLOSES.test(body.split("\n")[0])) { | ||
| return "its body does not open with Closes #<issue>"; | ||
| } | ||
| return null; | ||
|
kyle-sexton marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const whole = (connection) => Array.isArray(connection?.nodes) && connection.totalCount <= connection.nodes.length; | ||
|
|
||
| // Why the discussion `detail` (DETAIL_QUERY's pullRequest) holds it, or null. | ||
| export function discussionHold(detail) { | ||
| const threads = detail?.reviewThreads; | ||
| if (![detail?.comments, detail?.reviews, threads].every(whole) || !threads.nodes.every((thread) => whole(thread?.comments))) { | ||
| return "its comments could not all be read"; | ||
| } | ||
| const items = [...detail.comments.nodes, ...detail.reviews.nodes, ...threads.nodes.flatMap((thread) => thread.comments.nodes)].map((item) => ({ | ||
| login: item?.author?.login, | ||
| isUser: item?.author?.__typename === "User", | ||
| association: item?.authorAssociation, | ||
| at: String(item?.submittedAt ?? item?.createdAt ?? ""), | ||
| body: String(item?.body ?? ""), | ||
| })); | ||
| const vetoes = vetoers(items); | ||
| return vetoes.length ? `${vetoes.join(", ")} asked not to merge it` : null; | ||
| } | ||
|
|
||
| async function changedFiles(github, slug, number) { | ||
| const paths = []; | ||
| for (let page = 1; page <= MAX_FILE_PAGES; page += 1) { | ||
| const rows = await github("GET", `/repos/${slug}/pulls/${number}/files?per_page=100&page=${page}`); | ||
| if (!Array.isArray(rows)) { | ||
| return null; | ||
| } | ||
| for (const row of rows) { | ||
| paths.push(String(row?.filename), ...(row?.previous_filename ? [String(row.previous_filename)] : [])); | ||
| } | ||
| if (rows.length < 100) { | ||
| return paths; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| // What to do with one listed pull request: { action: arm | disarm | none, reason }. | ||
| // `detail` is its discussion; undefined means not read yet, and an arm or a | ||
| // keep then waits on it (action `read`). | ||
| export function decide(pull, { bot, humanGated, holdPaths = null }, detail) { | ||
| if (pull.author?.__typename !== "Bot" || pull.author.login !== bot) { | ||
| return { action: "none", reason: "not the lanes App's" }; | ||
| } | ||
| const armed = pull.autoMergeRequest != null; | ||
| const held = | ||
| pull.isDraft !== false | ||
| ? "it is a draft" | ||
| : pull.isCrossRepository !== false | ||
| ? "it is from another repository" | ||
| : !SHA.test(String(pull.headRefOid)) | ||
| ? "its head is not a commit SHA" | ||
| : holdReason(pull, humanGated); | ||
| if (held !== null) { | ||
| return { action: armed ? "disarm" : "none", reason: held }; | ||
| } | ||
| if (detail === undefined) { | ||
| return { action: "read", reason: "its discussion is not read yet" }; | ||
| } | ||
| if (detail === null) { | ||
| return { action: "none", reason: "its discussion did not read" }; | ||
| } | ||
| const vetoed = pathHold(detail.files, holdPaths) ?? discussionHold(detail); | ||
|
kyle-sexton marked this conversation as resolved.
|
||
| if (vetoed !== null) { | ||
| return { action: armed ? "disarm" : "none", reason: vetoed }; | ||
| } | ||
| if (armed) { | ||
| return { action: "none", reason: "already armed" }; | ||
| } | ||
| if (detail.timelineItems?.nodes?.at(-1)?.actor?.__typename === "User") { | ||
| return { action: "none", reason: "a person turned its auto-merge off" }; | ||
| } | ||
| return { action: "arm", reason: "no hold" }; | ||
| } | ||
|
|
||
| async function listPulls(github, owner, name) { | ||
| const pulls = []; | ||
| let after = null; | ||
| for (let page = 0; page < MAX_PAGES; page += 1) { | ||
| const data = (await github("POST", "/graphql", { query: PULLS_QUERY, variables: { owner, name, after } }))?.data; | ||
| const connection = data?.repository?.pullRequests; | ||
| if (!Array.isArray(connection?.nodes)) { | ||
| throw new Error("the open pull request listing did not read"); | ||
| } | ||
| pulls.push(...connection.nodes); | ||
| if (!connection.pageInfo?.hasNextPage) { | ||
| return pulls; | ||
| } | ||
| after = connection.pageInfo.endCursor; | ||
| } | ||
| throw new Error(`more than ${MAX_PAGES * 100} open pull requests into main`); | ||
| } | ||
|
|
||
| export async function main({ env = process.env, github } = {}) { | ||
| try { | ||
| const repo = REPO.exec(env.GITHUB_REPOSITORY ?? ""); | ||
| if (!repo) { | ||
| throw new Error("GITHUB_REPOSITORY is not owner/repo"); | ||
| } | ||
| if (env.KILL_SWITCH !== "proceed") { | ||
| console.log("enable-auto-merge: the lanes kill switch stops this run; nothing read or armed"); | ||
| return 0; | ||
| } | ||
| const slug = BOT_LOGIN.exec(env.LANES_BOT_LOGIN ?? "")?.[1]; | ||
| if (slug === undefined) { | ||
| throw new Error("LANES_BOT_LOGIN is not an App bot login"); | ||
| } | ||
| const write = env.REPORT_ONLY === "false"; | ||
| const humanGated = humanGatedLabelFrom(env.TRACKER_CONFIG || ".base/.work-item-tracker.json"); | ||
| const holdPaths = readHoldPaths(env.RESOLVED_CONFIG ?? ""); | ||
| github ??= createGitHub({ token: env.GH_TOKEN, apiUrl: env.GITHUB_API_URL || undefined }); | ||
| const pulls = await listPulls(github, repo[1], repo[2]); | ||
| let failed = 0; | ||
| for (const pull of pulls) { | ||
| let { action, reason } = decide(pull, { bot: slug, humanGated, holdPaths }); | ||
| if (action === "read") { | ||
| let detail = null; | ||
| try { | ||
| const variables = { owner: repo[1], name: repo[2], number: pull.number }; | ||
| detail = (await github("POST", "/graphql", { query: DETAIL_QUERY, variables }))?.data?.repository?.pullRequest ?? null; | ||
| if (detail !== null) { | ||
| detail = { ...detail, files: await changedFiles(github, `${repo[1]}/${repo[2]}`, pull.number) }; | ||
| } | ||
| } catch (error) { | ||
| console.log(`enable-auto-merge: #${pull.number} discussion did not read: ${escapeCommand(error?.message ?? error)}`); | ||
| } | ||
| ({ action, reason } = decide(pull, { bot: slug, humanGated, holdPaths }, detail)); | ||
| } | ||
| if (action === "none") { | ||
| if (reason !== "not the lanes App's") { | ||
| console.log(`enable-auto-merge: #${pull.number} left: ${reason}`); | ||
| } | ||
| continue; | ||
| } | ||
| if (!write) { | ||
| console.log(`enable-auto-merge: #${pull.number} would ${action} (report-only): ${reason}`); | ||
| continue; | ||
| } | ||
| try { | ||
| if (action === "arm") { | ||
| await github("POST", "/graphql", { query: ARM_MUTATION, variables: { id: pull.id, head: pull.headRefOid } }); | ||
| console.log(`enable-auto-merge: #${pull.number} armed at ${pull.headRefOid}`); | ||
| } else { | ||
| await github("POST", "/graphql", { query: DISARM_MUTATION, variables: { id: pull.id } }); | ||
| console.log(`enable-auto-merge: #${pull.number} disarmed: ${reason}`); | ||
| } | ||
| } catch (error) { | ||
| failed += 1; | ||
| console.log(`::error title=enable-auto-merge::#${pull.number} ${action}: ${escapeCommand(error?.message ?? error)}`); | ||
| } | ||
| } | ||
| return failed === 0 ? 0 : 1; | ||
| } catch (error) { | ||
| console.log(`::error title=enable-auto-merge::${escapeCommand(error?.message ?? error)}`); | ||
| return 1; | ||
| } | ||
| } | ||
|
|
||
| if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { | ||
| process.exitCode = await main(); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.