Repository navigation
fix(ci): credit takeover authors and exempt stacked PR bases #742
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -393,6 +393,10 @@ jobs: | |
| pr_notes="$(gh api "${generate_notes_api[@]}" --jq '.body')" | ||
| # Drop generate-notes' trailing compare link; we re-append it after the commit list. | ||
| printf '%s\n' "$pr_notes" | sed '/^\*\*Full Changelog\*\*:/d' > "$delta_file" | ||
| bun scripts/release-notes.ts credit-takeovers \ | ||
| --repo "$GITHUB_REPOSITORY" \ | ||
| --in "$delta_file" \ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a stable release carries a matching preview created before this helper was introduced, the takeover PR is excluded from the generated delta because Useful? React with 👍 / 👎. |
||
| --out "$delta_file" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This new command calls Useful? React with 👍 / 👎. |
||
| else | ||
| # First release on this channel: never call generate-notes without previous_tag_name. | ||
| # GitHub would baseline the newest repo tag, which may belong to the other channel. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ | |
| * bun scripts/release-notes.ts matching-preview-tags <version> | ||
| * bun scripts/release-notes.ts previous-release-tag <version> | ||
| * bun scripts/release-notes.ts has-meaningful [body-file] | ||
| * bun scripts/release-notes.ts credit-takeovers --repo <owner/name> --in <file> --out <file> | ||
| * bun scripts/release-notes.ts assemble --npm-metadata ... --out ... | ||
| */ | ||
|
|
||
|
|
@@ -207,6 +208,69 @@ export function selectNewestCarriedPreviewTag( | |
| return newest; | ||
| } | ||
|
|
||
| /** | ||
| * Parse a maintainer-takeover source PR number from title/body text. | ||
| * Matches forms already used in-repo: `takeover of #N`, `takeover #N`, | ||
| * `maintainer takeover of #N` (case-insensitive). | ||
| */ | ||
| export function parseTakeoverSourcePr(title: string, body = ""): number | null { | ||
| const text = `${title}\n${body}`; | ||
| const match = /\b(?:maintainer\s+)?takeover(?:\s+of)?\s+#(\d+)\b/i.exec(text); | ||
| if (!match) return null; | ||
| const n = Number(match[1]); | ||
| return Number.isInteger(n) && n > 0 ? n : null; | ||
| } | ||
|
|
||
| const GENERATE_NOTES_PR_LINE = | ||
| /^(?<prefix>\* .+? by @)(?<author>[A-Za-z0-9-]+)(?<mid> in https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/)(?<pr>\d+)(?<suffix>\s*)$/; | ||
|
|
||
| export type TakeoverCreditLookup = { | ||
| title: string; | ||
| body: string; | ||
| authorLogin: string; | ||
| }; | ||
|
|
||
| /** | ||
| * Rewrite generate-notes lines so maintainer-takeover PRs also credit the | ||
| * original PR creator: `by @Original (takeover by @Landing) in …/pull/P`. | ||
| */ | ||
| export async function rewriteTakeoverCredits( | ||
| notesBody: string, | ||
| resolveLanding: (prNumber: number) => Promise<TakeoverCreditLookup | null>, | ||
| resolveOriginalAuthor: (sourcePrNumber: number) => Promise<string | null>, | ||
| ): Promise<string> { | ||
| const lines = notesBody.replace(/\r\n/g, "\n").split("\n"); | ||
| const out: string[] = []; | ||
| for (const line of lines) { | ||
| const match = GENERATE_NOTES_PR_LINE.exec(line); | ||
| if (!match?.groups) { | ||
| out.push(line); | ||
| continue; | ||
| } | ||
| const landingPr = Number(match.groups.pr); | ||
| const landingAuthor = match.groups.author!; | ||
| const landing = await resolveLanding(landingPr); | ||
| if (!landing) { | ||
| out.push(line); | ||
| continue; | ||
| } | ||
| const sourcePr = parseTakeoverSourcePr(landing.title, landing.body); | ||
| if (sourcePr == null) { | ||
| out.push(line); | ||
| continue; | ||
| } | ||
| const original = await resolveOriginalAuthor(sourcePr); | ||
| if (!original || original.toLowerCase() === landingAuthor.toLowerCase()) { | ||
| out.push(line); | ||
| continue; | ||
| } | ||
| out.push( | ||
| `${match.groups.prefix}${original} (takeover by @${landingAuthor})${match.groups.mid}${match.groups.pr}${match.groups.suffix ?? ""}`, | ||
| ); | ||
| } | ||
| return out.join("\n"); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| export function assembleReleaseNotes(input: { | ||
| npmMetadata: string; | ||
| carriedPreviewNotes?: string; | ||
|
|
@@ -324,6 +388,79 @@ async function main(argv: string[]): Promise<void> { | |
| return; | ||
| } | ||
|
|
||
| if (cmd === "credit-takeovers") { | ||
| const args = new Map<string, string>(); | ||
| for (let i = 0; i < rest.length; i += 1) { | ||
| const key = rest[i]; | ||
| if (!key?.startsWith("--")) continue; | ||
| const value = rest[i + 1]; | ||
| if (!value || value.startsWith("--")) { | ||
| console.error(`Missing value for ${key}`); | ||
| process.exit(1); | ||
| } | ||
| args.set(key.slice(2), value); | ||
| i += 1; | ||
| } | ||
| const repo = args.get("repo"); | ||
| const inputPath = args.get("in"); | ||
| const outPath = args.get("out"); | ||
| if (!repo || !inputPath || !outPath) { | ||
| console.error("Usage: bun scripts/release-notes.ts credit-takeovers --repo <owner/name> --in <file> --out <file>"); | ||
| process.exit(1); | ||
| } | ||
| const [owner, name] = repo.split("/"); | ||
| if (!owner || !name || repo.split("/").length !== 2) { | ||
| console.error(`Invalid --repo value: ${repo}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| async function ghJson(path: string): Promise<unknown | null> { | ||
| const proc = Bun.spawn(["gh", "api", path], { | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
| const [stdout, stderr, exitCode] = await Promise.all([ | ||
| new Response(proc.stdout).text(), | ||
| new Response(proc.stderr).text(), | ||
| proc.exited, | ||
| ]); | ||
| if (exitCode !== 0) { | ||
| console.error(`gh api ${path} failed: ${stderr.trim() || `exit ${exitCode}`}`); | ||
| return null; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When GitHub returns a transient error, rate limit, permission error, or malformed response for either the landing or source PR, this path logs the error and returns AGENTS.md reference: scripts/AGENTS.md:L14-L18 Useful? React with 👍 / 👎. |
||
| } | ||
| try { | ||
| return JSON.parse(stdout) as unknown; | ||
| } catch { | ||
| console.error(`gh api ${path} returned non-JSON`); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| const body = await Bun.file(inputPath).text(); | ||
| const rewritten = await rewriteTakeoverCredits( | ||
| body, | ||
| async (prNumber) => { | ||
| const data = await ghJson(`repos/${owner}/${name}/pulls/${prNumber}`); | ||
| if (!data || typeof data !== "object") return null; | ||
| const pr = data as { title?: unknown; body?: unknown; user?: { login?: unknown } }; | ||
| if (typeof pr.title !== "string" || typeof pr.user?.login !== "string") return null; | ||
| return { | ||
| title: pr.title, | ||
| body: typeof pr.body === "string" ? pr.body : "", | ||
| authorLogin: pr.user.login, | ||
| }; | ||
| }, | ||
| async (sourcePrNumber) => { | ||
| const data = await ghJson(`repos/${owner}/${name}/pulls/${sourcePrNumber}`); | ||
| if (!data || typeof data !== "object") return null; | ||
| const pr = data as { user?: { login?: unknown } }; | ||
| return typeof pr.user?.login === "string" ? pr.user.login : null; | ||
| }, | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| await Bun.write(outPath, rewritten.endsWith("\n") ? rewritten : rewritten + "\n"); | ||
| return; | ||
| } | ||
|
|
||
| if (cmd === "assemble") { | ||
| const args = new Map<string, string>(); | ||
| for (let i = 0; i < rest.length; i += 1) { | ||
|
|
@@ -372,6 +509,7 @@ Usage: | |
| bun scripts/release-notes.ts matching-preview-tag <version> # tags on stdin | ||
| bun scripts/release-notes.ts matching-preview-tags <version> # tags on stdin, oldest→newest | ||
| bun scripts/release-notes.ts previous-release-tag <version> # tags on stdin | ||
| bun scripts/release-notes.ts credit-takeovers --repo <owner/name> --in <file> --out <file> | ||
| bun scripts/release-notes.ts assemble --npm-metadata ... --out ...`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
stackedBaseis true, this condition allows a PR targeting any non-allow-listed branch to pass the required target check, and the subsequent branch also skips ancestry enforcement. An open PR with a matching head therefore permits changes to be merged into a non-devbranch, contrary to the repository policy whose only stated exceptions are releases and maintainer promotions; retainwrong_baseenforcement or obtain and document an explicit stacked-PR policy exception.AGENTS.md reference: AGENTS.md:L132-L133
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kept the stacked exemption (intentional review workflow, not a second integration line). Documented it in
AGENTS.md: open-parent-head children skip wrong-base; after the parent lands/closes, retarget todev.Also addressed the P2s in this push:
pull-requests: readon the release workflow, fail-closed takeover lookups (404 on source PR still leaves the line unchanged), andcredit-takeoverson both carried + delta notes.