feat(ci): close open pull requests superseded by a merged fix on their linked issue (#42609)

* feat(ci): close open pull requests superseded by a merged fix on their linked issue

* fix(ci): replace the mixed-anchor release-line regex with a plain predicate

* fix(ci): recover a half-done close, page linked pull requests, trust only the workflow's marker

* fix(ci): close superseded pull requests whatever closed the issue and fail the job when the script throws

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 18:24:35 -07:00 • committed by GitHub
parent 327515a3ba
commit 1953603043
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 825 additions and 90 deletions

View file

@ -6,8 +6,12 @@ on:
workflow_dispatch:
inputs:
issue_number:
description: "Closed issue number to comment on manually."
required: true
description: "Closed issue number to comment on and close the superseded pull requests of. Ignored by a sweep."
required: false
sweep:
description: "Close every open pull request whose linked issues were all fixed on the default branch. Reads every open pull request, so run it at most once an hour."
type: boolean
default: false
pull_request:
paths:
- .github/workflows/issue_fixed_comment.yml
@ -39,16 +43,17 @@ jobs:
with:
bun-version: "1.4.0"
- name: Test the closer lookup, the release placement and the comment
- name: Test the closer lookup, the release placement, the comment and the superseded pull request close
run: bun test scripts/comment-fixed-issue.test.ts
comment-fixed-issue:
if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm'
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 15
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout scripts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
@ -59,13 +64,16 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# Exact version, never latest: the next step holds an issues: write token
# Exact version, never latest: the next step holds issues: write and pull-requests: write tokens
bun-version: "1.4.0"
- name: Name the release that carries the fix
- name: Name the release that carries the fix and close the pull requests it supersedes
shell: bash
run: bun run scripts/comment-fixed-issue.ts | tee -a "${GITHUB_STEP_SUMMARY}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }}
SWEEP: ${{ github.event.inputs.sweep }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
DRY_RUN: ${{ vars.ISSUE_FIXED_COMMENT_ENABLED != 'true' }}
CLOSE_PRS_DRY_RUN: ${{ vars.ISSUE_FIXED_CLOSE_PRS_ENABLED != 'true' }}

View file

@ -3,62 +3,140 @@ import { describe, expect, test } from "bun:test";
import type { Comment, GitHubApi } from "./auto-close-duplicates";
import {
FIXED_MARKER,
OPEN_PULL_REQUESTS_QUERY,
SUPERSEDED_MARKER,
closeVerdict,
closerOf,
commentFixedIssue,
describeIssue,
describeSweep,
fixOf,
fixedBody,
handleFixedIssue,
nextMinor,
parseVersion,
placement,
readConfig,
releaseCandidate,
supersededBody,
sweep,
type ClosedIssue,
type FixedConfig,
type IssueClosure,
type LinkedIssue,
type LinkedPullRequest,
type PullRequestsPage,
} from "./comment-fixed-issue";
const MERGE_COMMIT = "68c4c82ac977b48b2b81ee8d633d5771307c6162";
const ISSUE = 41750;
const mergedPr = {
__typename: "PullRequest" as const,
number: 41767,
merged: true,
baseRefName: "main",
repository: { nameWithOwner: "BerriAI/litellm" },
mergeCommit: { oid: MERGE_COMMIT },
};
type Closer = ClosedIssue["timelineItems"]["nodes"][number]["closer"];
const commitCloser = { __typename: "Commit" as const, oid: MERGE_COMMIT, repository: { nameWithOwner: "BerriAI/litellm" } };
const closedBy = (closer: Closer, state: ClosedIssue["state"] = "CLOSED"): ClosedIssue => ({
type Closer = IssueClosure["timelineItems"]["nodes"][number]["closer"];
const closure = (closer: Closer, state: IssueClosure["state"] = "CLOSED"): IssueClosure => ({
state,
timelineItems: { nodes: [{ closer }] },
});
const page = (pages: readonly (readonly LinkedPullRequest[])[], index: number): PullRequestsPage => ({
pageInfo: { hasNextPage: index + 1 < pages.length, endCursor: String(index + 1) },
nodes: pages[index] ?? [],
});
const cursorIndex = (after: string | null): number => (after === null ? 0 : Number(after));
const closedBy = (closer: Closer, state?: IssueClosure["state"], linked: readonly LinkedPullRequest[] = []): ClosedIssue => ({
...closure(closer, state),
closedByPullRequestsReferences: page([linked], 0),
});
const reopenedAt = (createdAt: string): LinkedPullRequest["reopens"] => ({ nodes: [{ createdAt }] });
const linkedIssue = (number: number, closer: Closer = mergedPr, state?: IssueClosure["state"]): LinkedIssue => ({
number,
repository: { nameWithOwner: "BerriAI/litellm" },
...closure(closer, state),
});
const links = (...issues: readonly LinkedIssue[]): LinkedPullRequest["closingIssuesReferences"] => ({ totalCount: issues.length, nodes: issues });
const openPr = (number: number, overrides: Partial<LinkedPullRequest> = {}): LinkedPullRequest => ({
number,
state: "OPEN",
baseRefName: "main",
repository: { nameWithOwner: "BerriAI/litellm" },
closingIssuesReferences: links(linkedIssue(ISSUE)),
reopens: { nodes: [] },
...overrides,
});
const pyproject = (version: string): string =>
`[project]\nname = "litellm"\nversion = "${version}"\n\n[tool.commitizen]\nversion = "${version}"\n`;
const config: FixedConfig = { repo: "BerriAI/litellm", issueNumber: 41750, defaultBranch: "main", dryRun: false };
const config: FixedConfig = { repo: "BerriAI/litellm", defaultBranch: "main", commentDryRun: false, closeDryRun: false };
const prFix = (issue = ISSUE, number = 41767) => ({ issue, source: { kind: "pull_request" as const, number, oid: MERGE_COMMIT } });
const commitFix = (issue = ISSUE) => ({ issue, source: { kind: "commit" as const, oid: MERGE_COMMIT } });
const oneFixBody = supersededBody([prFix()], "main");
const noPause = async (): Promise<void> => {};
const supersededComment: Comment = {
id: 2,
body: `${SUPERSEDED_MARKER}\n#41750 was fixed by #41767 on main, so this pull request is closed. Reopen it if something was missed.`,
created_at: "2026-09-18T00:00:00Z",
user: { type: "Bot", login: "github-actions[bot]" },
};
interface World {
readonly issue?: ClosedIssue | null;
readonly comments?: readonly Comment[];
readonly pullRequestComments?: Readonly<Record<number, readonly Comment[]>>;
readonly openPullRequests?: readonly (readonly LinkedPullRequest[])[];
readonly linkedPullRequests?: readonly (readonly LinkedPullRequest[])[];
readonly version?: string;
// Which existing rc.1 tags contain the merge commit; a tag absent from the map does not exist
readonly tags?: Readonly<Record<string, boolean>>;
readonly reachable?: Readonly<Record<string, readonly string[]>>;
}
function fakeApi(world: World = {}): { readonly api: GitHubApi; readonly writes: string[] } {
const writes: string[] = [];
const tags = world.tags ?? {};
const reachable = world.reachable ?? { main: [MERGE_COMMIT] };
const pages = world.openPullRequests ?? [];
const api: GitHubApi = {
request: async <T>(method: string, path: string, body?: object): Promise<T> => {
if (method === "POST" && path === "/graphql") {
return { data: { repository: { issue: world.issue === undefined ? closedBy(mergedPr) : world.issue } } } as T;
const { query, variables } = body as { query: string; variables: { after: string | null } };
if (query === OPEN_PULL_REQUESTS_QUERY) {
return { data: { repository: { pullRequests: page(pages, cursorIndex(variables.after)) } } } as T;
}
const issue = world.issue === undefined ? closedBy(mergedPr) : world.issue;
if (issue === null || world.linkedPullRequests === undefined) {
return { data: { repository: { issue } } } as T;
}
const closedByPullRequestsReferences = page(world.linkedPullRequests, cursorIndex(variables.after));
return { data: { repository: { issue: { ...issue, closedByPullRequestsReferences } } } } as T;
}
if (method !== "GET") {
writes.push(`${method} ${path} ${JSON.stringify(body)}`);
return {} as T;
}
if (path.startsWith("/repos/BerriAI/litellm/issues/41750/comments")) {
return (world.comments ?? []) as T;
const comments = /^\/repos\/BerriAI\/litellm\/issues\/(\d+)\/comments/.exec(path);
if (comments !== null) {
const number = Number(comments[1]);
return ((number === ISSUE ? world.comments : world.pullRequestComments?.[number]) ?? []) as T;
}
if (path === `/repos/BerriAI/litellm/contents/pyproject.toml?ref=${MERGE_COMMIT}`) {
return { content: btoa(pyproject(world.version ?? "1.103.0")).replace(/(.{60})/g, "$1\n") } as T;
@ -68,6 +146,10 @@ function fakeApi(world: World = {}): { readonly api: GitHubApi; readonly writes:
return (matching[1] in tags ? [{ ref: `refs/tags/${matching[1]}` }] : []) as T;
}
const compare = /^\/repos\/BerriAI\/litellm\/compare\/(.+)\.\.\.(.+)$/.exec(path);
const branch = compare === null ? undefined : reachable[compare[1] ?? ""];
if (compare !== null && branch !== undefined) {
return { status: branch.includes(compare[2] ?? "") ? "behind" : "diverged" } as T;
}
if (compare !== null && compare[2] === MERGE_COMMIT) {
return { status: tags[compare[1]] ? "behind" : "ahead" } as T;
}
@ -79,23 +161,28 @@ function fakeApi(world: World = {}): { readonly api: GitHubApi; readonly writes:
describe("closerOf", () => {
test("a pull request merged into the default branch is the fix", () => {
expect(closerOf(closedBy(mergedPr), "main")).toEqual({ kind: "pull_request", number: 41767, mergeCommit: MERGE_COMMIT });
expect(closerOf(closedBy(mergedPr), "BerriAI/litellm", "main")).toEqual({ kind: "pull_request", number: 41767, mergeCommit: MERGE_COMMIT });
});
test("an issue closed by hand, by a commit, or by an unmerged pull request gets no comment", () => {
expect(closerOf(closedBy(null), "main")).toEqual({ kind: "skip", reason: "closed by hand, not by a pull request" });
expect(closerOf(closedBy({ __typename: "Commit", oid: MERGE_COMMIT }), "main").kind).toBe("skip");
expect(closerOf(closedBy({ ...mergedPr, merged: false }), "main").kind).toBe("skip");
expect(closerOf(closedBy({ ...mergedPr, mergeCommit: null }), "main").kind).toBe("skip");
expect(closerOf(closedBy(null), "BerriAI/litellm", "main")).toEqual({ kind: "skip", reason: "closed by hand, not by a pull request" });
expect(closerOf(closedBy(commitCloser), "BerriAI/litellm", "main").kind).toBe("skip");
expect(closerOf(closedBy({ ...mergedPr, merged: false }), "BerriAI/litellm", "main").kind).toBe("skip");
expect(closerOf(closedBy({ ...mergedPr, mergeCommit: null }), "BerriAI/litellm", "main").kind).toBe("skip");
});
test("a pull request merged into a release branch is not a fix on main", () => {
const verdict = closerOf(closedBy({ ...mergedPr, baseRefName: "release/1.102.0rc2" }), "main");
const verdict = closerOf(closedBy({ ...mergedPr, baseRefName: "release/1.102.0rc2" }), "BerriAI/litellm", "main");
expect(verdict).toEqual({ kind: "skip", reason: "#41767 merged into release/1.102.0rc2, not main" });
});
test("an issue reopened after the close event is left alone", () => {
expect(closerOf(closedBy(mergedPr, "OPEN"), "main")).toEqual({ kind: "skip", reason: "the issue is open again" });
expect(closerOf(closedBy(mergedPr, "OPEN"), "BerriAI/litellm", "main")).toEqual({ kind: "skip", reason: "the issue is open again" });
});
test("a pull request merged in a fork closes the issue on GitHub but is no fix here", () => {
const forkPr = { ...mergedPr, number: 9, repository: { nameWithOwner: "someone/litellm" } };
expect(closerOf(closedBy(forkPr), "BerriAI/litellm", "main")).toEqual({ kind: "skip", reason: "closed by someone/litellm#9, a pull request in another repository" });
});
});
@ -173,44 +260,327 @@ describe("fixedBody", () => {
});
});
describe("commentFixedIssue", () => {
describe("fixOf", () => {
test("a merged pull request or a commit is a fix, whatever branch it was merged into", () => {
expect(fixOf(closure(mergedPr), "BerriAI/litellm")).toEqual({ kind: "pull_request", number: 41767, oid: MERGE_COMMIT });
expect(fixOf(closure({ ...mergedPr, baseRefName: "litellm_internal_staging" }), "BerriAI/litellm")).toEqual({ kind: "pull_request", number: 41767, oid: MERGE_COMMIT });
expect(fixOf(closure(commitCloser), "BerriAI/litellm")).toEqual({ kind: "commit", oid: MERGE_COMMIT });
});
test("a hand close, a fork's pull request, an unmerged pull request, or a reopened issue is no fix", () => {
expect(fixOf(closure(null), "BerriAI/litellm")).toEqual({ kind: "skip", reason: "was closed by hand" });
expect(fixOf(closure({ ...mergedPr, repository: { nameWithOwner: "someone/litellm" } }), "BerriAI/litellm")).toEqual({ kind: "skip", reason: "was closed from someone/litellm" });
expect(fixOf(closure({ ...commitCloser, repository: { nameWithOwner: "someone/litellm" } }), "BerriAI/litellm")).toEqual({ kind: "skip", reason: "was closed from someone/litellm" });
expect(fixOf(closure({ ...mergedPr, merged: false }), "BerriAI/litellm")).toEqual({ kind: "skip", reason: "was closed by #41767, which is not merged" });
expect(fixOf(closure({ ...mergedPr, mergeCommit: null }), "BerriAI/litellm").kind).toBe("skip");
expect(fixOf(closure(mergedPr, "OPEN"), "BerriAI/litellm")).toEqual({ kind: "skip", reason: "is open again" });
});
});
describe("closeVerdict", () => {
test("an open pull request whose only linked issue was fixed by a merged pull request is a candidate", () => {
expect(closeVerdict(openPr(41760), config)).toEqual({ kind: "candidate", fixes: [prFix()] });
});
test("every linked issue has to be fixed, and each fix is named", () => {
const both = openPr(41760, { closingIssuesReferences: links(linkedIssue(ISSUE), linkedIssue(41751, commitCloser)) });
expect(closeVerdict(both, config)).toEqual({ kind: "candidate", fixes: [prFix(), commitFix(41751)] });
});
test("a pull request that still links an open issue keeps its work", () => {
const stillOpen = openPr(41760, { closingIssuesReferences: links(linkedIssue(ISSUE), linkedIssue(41751, null, "OPEN")) });
expect(closeVerdict(stillOpen, config)).toEqual({ kind: "skip", reason: "still linked to open #41751" });
});
test("a linked issue closed by hand or by an unmerged pull request is not a fix that supersedes the pull request", () => {
expect(closeVerdict(openPr(41760, { closingIssuesReferences: links(linkedIssue(ISSUE, null)) }), config)).toEqual({
kind: "skip",
reason: `#${ISSUE} was closed by hand`,
});
const unmerged = openPr(41760, { closingIssuesReferences: links(linkedIssue(ISSUE, { ...mergedPr, merged: false })) });
expect(closeVerdict(unmerged, config)).toEqual({ kind: "skip", reason: `#${ISSUE} was closed by #41767, which is not merged` });
});
test("a pull request linking an issue in another repository, or more issues than the query reads, is left alone", () => {
const foreign = { ...linkedIssue(41751), repository: { nameWithOwner: "mlflow/mlflow" } };
expect(closeVerdict(openPr(41760, { closingIssuesReferences: links(linkedIssue(ISSUE), foreign) }), config)).toEqual({
kind: "skip",
reason: "links mlflow/mlflow#41751",
});
const truncated = openPr(41760, { closingIssuesReferences: { totalCount: 11, nodes: [linkedIssue(ISSUE)] } });
expect(closeVerdict(truncated, config)).toEqual({ kind: "skip", reason: "links 11 issues, more than the 10 this workflow reads" });
});
test("a pull request against a release line is a backport and stays open, one against a retired development branch does not", () => {
for (const base of ["release/v1.102.0-rc.2", "stable/v1.83.14", "v_1_83_3_stable_patch"]) {
expect(closeVerdict(openPr(41760, { baseRefName: base }), config)).toEqual({ kind: "skip", reason: `targets the release line ${base}` });
}
expect(closeVerdict(openPr(41760, { baseRefName: "litellm_internal_staging" }), config).kind).toBe("candidate");
});
test("a pull request in a fork, one that is not open, or one linking no issue is left alone", () => {
expect(closeVerdict(openPr(1, { repository: { nameWithOwner: "someone/litellm" } }), config)).toEqual({ kind: "skip", reason: "lives in someone/litellm" });
expect(closeVerdict(openPr(41767, { state: "MERGED" }), config)).toEqual({ kind: "skip", reason: "is merged" });
expect(closeVerdict(openPr(41760, { closingIssuesReferences: links() }), config)).toEqual({ kind: "skip", reason: "links no issue" });
});
});
describe("supersededBody", () => {
test("names each issue with the pull request or commit that fixed it, invites a reopen, and carries the marker the rerun looks for", () => {
expect(oneFixBody.startsWith(SUPERSEDED_MARKER)).toBe(true);
expect(oneFixBody).toContain("#41750 was fixed by #41767 on main");
expect(oneFixBody).toContain("Reopen it");
expect(supersededBody([prFix(), commitFix(41751)], "main")).toContain("#41750 was fixed by #41767 and #41751 by commit 68c4c82ac9 on main");
});
test("stays within the 25-word comment rule for one and two fixes", () => {
for (const fixes of [[prFix()], [commitFix()], [prFix(), commitFix(41751)]]) {
const words = supersededBody(fixes, "main").replace(SUPERSEDED_MARKER, "").trim().split(/\s+/);
expect(words.length).toBeGreaterThanOrEqual(15);
expect(words.length).toBeLessThanOrEqual(25);
}
});
});
describe("handleFixedIssue", () => {
test("a real run posts one comment naming the pull request and the release", async () => {
const { api, writes } = fakeApi();
const verdict = await commentFixedIssue(api, config);
expect(verdict).toMatchObject({ kind: "commented", pullRequest: 41767, tag: "v1.103.0-rc.1" });
const { comment, pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(comment).toMatchObject({ kind: "commented", pullRequest: 41767, tag: "v1.103.0-rc.1" });
expect(pullRequests).toEqual([]);
expect(writes).toHaveLength(1);
expect(writes[0]).toContain("POST /repos/BerriAI/litellm/issues/41750/comments");
expect(writes[0]).toContain("Fixed by #41767. This ships in v1.103.0-rc.1 and up");
});
test("a dry run renders the comment and writes nothing", async () => {
const { api, writes } = fakeApi();
const verdict = await commentFixedIssue(api, { ...config, dryRun: true });
expect(verdict.kind).toBe("commented");
expect(writes).toEqual([]);
test("every other open pull request linked to the fixed issue is commented on and then closed, a pause before every write", async () => {
const { api, writes } = fakeApi({ issue: closedBy(mergedPr, "CLOSED", [openPr(41760), openPr(41761)]) });
let pauses = 0;
const { pullRequests } = await handleFixedIssue(api, config, ISSUE, async () => {
pauses += 1;
});
expect(pullRequests.map((pullRequest) => pullRequest.kind)).toEqual(["closed", "closed"]);
expect(writes).toEqual([
expect.stringContaining("POST /repos/BerriAI/litellm/issues/41750/comments"),
`POST /repos/BerriAI/litellm/issues/41760/comments ${JSON.stringify({ body: oneFixBody })}`,
'PATCH /repos/BerriAI/litellm/pulls/41760 {"state":"closed"}',
expect.stringContaining("POST /repos/BerriAI/litellm/issues/41761/comments"),
'PATCH /repos/BerriAI/litellm/pulls/41761 {"state":"closed"}',
]);
expect(pauses).toBe(4);
});
test("an issue that already carries the comment is not commented twice", async () => {
test("the fixed-in comment and the closing are gated separately: a close dry run still posts the comment and closes nothing", async () => {
const { api, writes } = fakeApi({ issue: closedBy(mergedPr, "CLOSED", [openPr(41760)]) });
let pauses = 0;
const outcome = await handleFixedIssue(api, { ...config, closeDryRun: true }, ISSUE, async () => {
pauses += 1;
});
expect(outcome.comment.kind).toBe("commented");
expect(outcome.pullRequests).toEqual([{ kind: "closed", number: 41760, body: oneFixBody }]);
expect(writes).toHaveLength(1);
expect(writes[0]).toContain("/issues/41750/comments");
expect(pauses).toBe(0);
});
test("a comment dry run still closes the pull requests for real", async () => {
const { api, writes } = fakeApi({ issue: closedBy(mergedPr, "CLOSED", [openPr(41760)]) });
const outcome = await handleFixedIssue(api, { ...config, commentDryRun: true }, ISSUE, noPause);
expect(outcome.comment.kind).toBe("commented");
expect(writes).toEqual([expect.stringContaining("/issues/41760/comments"), 'PATCH /repos/BerriAI/litellm/pulls/41760 {"state":"closed"}']);
});
test("an issue that already carries the fixed-in comment is not commented twice, and its linked pull requests still get closed", async () => {
const existing: Comment = {
id: 1,
body: `${FIXED_MARKER}\nFixed by #41767. This ships in v1.103.0-rc.1 and up.`,
created_at: "2026-09-18T00:00:00Z",
user: { type: "Bot", login: "github-actions[bot]" },
};
const { api, writes } = fakeApi({ comments: [existing] });
expect(await commentFixedIssue(api, config)).toEqual({ kind: "skip", reason: "already carries a fixed-in comment" });
const { api, writes } = fakeApi({ comments: [existing], issue: closedBy(mergedPr, "CLOSED", [openPr(41760)]) });
const outcome = await handleFixedIssue(api, config, ISSUE, noPause);
expect(outcome.comment).toEqual({ kind: "skip", reason: "already carries a fixed-in comment" });
expect(writes).toEqual([expect.stringContaining("/issues/41760/comments"), 'PATCH /repos/BerriAI/litellm/pulls/41760 {"state":"closed"}']);
});
test("a fix merged into the retired development branch or pushed as a commit counts once it is on the default branch", async () => {
const stagingPr = { ...mergedPr, baseRefName: "litellm_internal_staging" };
const staging = openPr(41760, { baseRefName: "litellm_internal_staging", closingIssuesReferences: links(linkedIssue(ISSUE, stagingPr)) });
const byCommit = openPr(41761, { closingIssuesReferences: links(linkedIssue(41751, commitCloser)) });
const { api, writes } = fakeApi({ issue: closedBy(mergedPr, "CLOSED", [staging, byCommit]) });
const { pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(pullRequests).toEqual([
{ kind: "closed", number: 41760, body: oneFixBody },
{ kind: "closed", number: 41761, body: supersededBody([commitFix(41751)], "main") },
]);
expect(writes).toHaveLength(5);
expect(writes[3]).toContain("#41751 was fixed by commit 68c4c82ac9 on main");
});
test("a fix whose commit never reached the default branch supersedes nothing", async () => {
const { api, writes } = fakeApi({ issue: closedBy(mergedPr, "CLOSED", [openPr(41760)]), reachable: { main: [] } });
const { pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(pullRequests).toEqual([{ kind: "skip", number: 41760, reason: "#41750 was fixed by #41767, which is not on main" }]);
expect(writes).toHaveLength(1);
expect(writes[0]).toContain("/issues/41750/comments");
});
test("the default branch comes from the config for the containment check and the comment alike", async () => {
const stagingConfig = { ...config, defaultBranch: "litellm_internal_staging" };
const closer = { ...mergedPr, baseRefName: "litellm_internal_staging" };
const { api, writes } = fakeApi({
issue: closedBy(closer, "CLOSED", [openPr(41760, { closingIssuesReferences: links(linkedIssue(ISSUE, closer)) })]),
reachable: { litellm_internal_staging: [MERGE_COMMIT] },
});
const { pullRequests } = await handleFixedIssue(api, stagingConfig, ISSUE, noPause);
expect(pullRequests).toEqual([{ kind: "closed", number: 41760, body: supersededBody([prFix()], "litellm_internal_staging") }]);
expect(writes[1]).toContain("on litellm_internal_staging, so this pull request is closed");
});
test("the closer sits in the linked list as merged and gets neither a line nor a write", async () => {
const { api, writes } = fakeApi({ issue: closedBy(mergedPr, "CLOSED", [openPr(41767, { state: "MERGED" }), openPr(41760)]) });
const { pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(pullRequests).toEqual([{ kind: "closed", number: 41760, body: oneFixBody }]);
expect(writes.map((write) => write.split(" ")[1])).toEqual([
"/repos/BerriAI/litellm/issues/41750/comments",
"/repos/BerriAI/litellm/issues/41760/comments",
"/repos/BerriAI/litellm/pulls/41760",
]);
});
test("a pull request this workflow closed once and its author reopened stays open", async () => {
const reopened = openPr(41760, { reopens: reopenedAt("2026-09-19T00:00:00Z") });
const { api, writes } = fakeApi({
issue: closedBy(mergedPr, "CLOSED", [reopened, openPr(41761)]),
pullRequestComments: { 41760: [supersededComment] },
});
const { pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(pullRequests[0]).toEqual({ kind: "skip", number: 41760, reason: "was closed by this workflow once and reopened" });
expect(pullRequests[1]?.kind).toBe("closed");
expect(writes.filter((write) => write.includes("41760"))).toEqual([]);
});
test("a pull request whose comment landed but whose close failed is closed on the next run without a second comment", async () => {
const reopenedBeforeTheComment = openPr(41761, { reopens: reopenedAt("2026-09-17T00:00:00Z") });
const { api, writes } = fakeApi({
issue: closedBy(mergedPr, "CLOSED", [openPr(41760), reopenedBeforeTheComment]),
pullRequestComments: { 41760: [supersededComment], 41761: [supersededComment] },
});
const { pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(pullRequests).toEqual([
{ kind: "closed", number: 41760, body: supersededComment.body },
{ kind: "closed", number: 41761, body: supersededComment.body },
]);
expect(writes.filter((write) => write.includes("/4176"))).toEqual([
'PATCH /repos/BerriAI/litellm/pulls/41760 {"state":"closed"}',
'PATCH /repos/BerriAI/litellm/pulls/41761 {"state":"closed"}',
]);
});
test("a superseded marker pasted by anyone but the workflow neither keeps a pull request open nor replaces its comment", async () => {
const forged: Comment = { ...supersededComment, id: 3, user: { type: "User", login: "someone" } };
const { api, writes } = fakeApi({
issue: closedBy(mergedPr, "CLOSED", [openPr(41760, { reopens: reopenedAt("2026-09-19T00:00:00Z") })]),
pullRequestComments: { 41760: [forged] },
});
const { pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(pullRequests).toEqual([{ kind: "closed", number: 41760, body: oneFixBody }]);
expect(writes.filter((write) => write.includes("/41760"))).toEqual([
`POST /repos/BerriAI/litellm/issues/41760/comments ${JSON.stringify({ body: oneFixBody })}`,
'PATCH /repos/BerriAI/litellm/pulls/41760 {"state":"closed"}',
]);
});
test("every page of linked pull requests is read, not just the first", async () => {
const { api } = fakeApi({ linkedPullRequests: [[openPr(41760)], [openPr(41761)], [openPr(41762)]] });
const { pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(pullRequests).toEqual([
{ kind: "closed", number: 41760, body: oneFixBody },
{ kind: "closed", number: 41761, body: oneFixBody },
{ kind: "closed", number: 41762, body: oneFixBody },
]);
});
test("a linked pull request from a fork or one still tied to another open issue is reported, not closed", async () => {
const fork = openPr(1, { repository: { nameWithOwner: "someone/litellm" } });
const busy = openPr(41762, { closingIssuesReferences: links(linkedIssue(ISSUE), linkedIssue(41751, null, "OPEN")) });
const { api, writes } = fakeApi({ issue: closedBy(mergedPr, "CLOSED", [fork, busy]) });
const { pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(pullRequests).toEqual([
{ kind: "skip", number: 1, reason: "lives in someone/litellm" },
{ kind: "skip", number: 41762, reason: "still linked to open #41751" },
]);
expect(writes).toHaveLength(1);
});
test("a hand-closed issue gets no comment and leaves its linked pull requests open with the reason on each", async () => {
const byHand = openPr(41760, { closingIssuesReferences: links(linkedIssue(ISSUE, null)) });
const { api, writes } = fakeApi({ issue: closedBy(null, "CLOSED", [byHand]) });
const outcome = await handleFixedIssue(api, config, ISSUE, noPause);
expect(outcome.comment).toEqual({ kind: "skip", reason: "closed by hand, not by a pull request" });
expect(outcome.pullRequests).toEqual([{ kind: "skip", number: 41760, reason: `#${ISSUE} was closed by hand` }]);
expect(writes).toEqual([]);
});
test("a hand-closed issue never reaches the release lookup or the API writes", async () => {
const { api, writes } = fakeApi({ issue: closedBy(null) });
expect((await commentFixedIssue(api, config)).kind).toBe("skip");
test("an issue closed by a commit on the default branch gets no comment but still closes its linked pull requests", async () => {
const byCommit = openPr(41760, { closingIssuesReferences: links(linkedIssue(ISSUE, commitCloser)) });
const { api, writes } = fakeApi({ issue: closedBy(commitCloser, "CLOSED", [byCommit]) });
const { comment, pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(comment).toEqual({ kind: "skip", reason: "closed by commit 68c4c82ac9, not by a pull request" });
expect(pullRequests).toEqual([{ kind: "closed", number: 41760, body: supersededBody([commitFix()], "main") }]);
expect(writes).toEqual([expect.stringContaining("/issues/41760/comments"), 'PATCH /repos/BerriAI/litellm/pulls/41760 {"state":"closed"}']);
expect(writes[0]).toContain("#41750 was fixed by commit 68c4c82ac9 on main");
});
test("an issue closed from the retired development branch gets no comment but still closes its linked pull requests once the fix is on the default branch", async () => {
const stagingPr = { ...mergedPr, baseRefName: "litellm_internal_staging" };
const staging = openPr(41760, { closingIssuesReferences: links(linkedIssue(ISSUE, stagingPr)) });
const { api, writes } = fakeApi({ issue: closedBy(stagingPr, "CLOSED", [staging]) });
const { comment, pullRequests } = await handleFixedIssue(api, config, ISSUE, noPause);
expect(comment).toEqual({ kind: "skip", reason: "#41767 merged into litellm_internal_staging, not main" });
expect(pullRequests).toEqual([{ kind: "closed", number: 41760, body: oneFixBody }]);
expect(writes.map((write) => write.split(" ")[1])).toEqual(["/repos/BerriAI/litellm/issues/41760/comments", "/repos/BerriAI/litellm/pulls/41760"]);
});
test("an issue that is open again gets no comment and its linked pull requests are neither read nor touched", async () => {
const { api, writes } = fakeApi({ issue: closedBy(mergedPr, "OPEN", [openPr(41760)]) });
const outcome = await handleFixedIssue(api, config, ISSUE, noPause);
expect(outcome).toEqual({ comment: { kind: "skip", reason: "the issue is open again" }, pullRequests: [] });
expect(writes).toEqual([]);
});
test("a number that is not an issue in the repository is a skip", async () => {
const { api, writes } = fakeApi({ issue: null });
expect(await commentFixedIssue(api, config)).toEqual({ kind: "skip", reason: "not an issue in this repository" });
const outcome = await handleFixedIssue(api, config, ISSUE, noPause);
expect(outcome.comment).toEqual({ kind: "skip", reason: "not an issue in this repository" });
expect(writes).toEqual([]);
});
});
describe("sweep", () => {
test("walks every page of open pull requests and closes the ones whose linked issues were all fixed", async () => {
const unlinked = openPr(41700, { closingIssuesReferences: links() });
const busy = openPr(41701, { closingIssuesReferences: links(linkedIssue(41751, null, "OPEN")) });
const { api, writes } = fakeApi({ openPullRequests: [[unlinked, openPr(41760)], [busy, openPr(41761)]] });
const outcome = await sweep(api, { ...config, commentDryRun: true }, noPause);
expect(outcome.considered).toBe(4);
expect(outcome.pullRequests).toEqual([
{ kind: "closed", number: 41760, body: oneFixBody },
{ kind: "skip", number: 41701, reason: "still linked to open #41751" },
{ kind: "closed", number: 41761, body: oneFixBody },
]);
expect(writes).toEqual([
expect.stringContaining("POST /repos/BerriAI/litellm/issues/41760/comments"),
'PATCH /repos/BerriAI/litellm/pulls/41760 {"state":"closed"}',
expect.stringContaining("POST /repos/BerriAI/litellm/issues/41761/comments"),
'PATCH /repos/BerriAI/litellm/pulls/41761 {"state":"closed"}',
]);
});
test("a sweep dry run lists what it would close and writes nothing", async () => {
const { api, writes } = fakeApi({ openPullRequests: [[openPr(41760)]] });
const outcome = await sweep(api, { ...config, closeDryRun: true }, noPause);
expect(outcome.pullRequests.map((pullRequest) => pullRequest.kind)).toEqual(["closed"]);
expect(writes).toEqual([]);
});
});
@ -218,10 +588,24 @@ describe("commentFixedIssue", () => {
describe("readConfig", () => {
const env = { GITHUB_TOKEN: "t", GITHUB_REPOSITORY: "BerriAI/litellm", ISSUE_NUMBER: "41750", DEFAULT_BRANCH: "main" };
test("reads the four inputs and treats anything but the literal true as a real run", () => {
expect(readConfig(env)).toEqual({ token: "t", repo: "BerriAI/litellm", issueNumber: 41750, defaultBranch: "main", dryRun: false });
expect(readConfig({ ...env, DRY_RUN: "true" }).dryRun).toBe(true);
expect(readConfig({ ...env, DRY_RUN: "false" }).dryRun).toBe(false);
test("reads the inputs and treats anything but the literal true as a real run for each gate", () => {
expect(readConfig(env)).toEqual({
token: "t",
repo: "BerriAI/litellm",
defaultBranch: "main",
commentDryRun: false,
closeDryRun: false,
run: { kind: "issue", number: 41750 },
});
expect(readConfig({ ...env, DRY_RUN: "true" })).toMatchObject({ commentDryRun: true, closeDryRun: false });
expect(readConfig({ ...env, CLOSE_PRS_DRY_RUN: "true" })).toMatchObject({ commentDryRun: false, closeDryRun: true });
expect(readConfig({ ...env, DRY_RUN: "false", CLOSE_PRS_DRY_RUN: "false" })).toMatchObject({ commentDryRun: false, closeDryRun: false });
});
test("a sweep needs no issue number and anything but the literal true is an issue run", () => {
expect(readConfig({ ...env, ISSUE_NUMBER: undefined, SWEEP: "true" }).run).toEqual({ kind: "sweep" });
expect(readConfig({ ...env, SWEEP: "false" }).run).toEqual({ kind: "issue", number: 41750 });
expect(() => readConfig({ ...env, ISSUE_NUMBER: "", SWEEP: "false" })).toThrow("dispatch with an issue_number or with sweep ticked");
});
test("refuses a missing token, repo, branch or a bad issue number", () => {
@ -232,3 +616,31 @@ describe("readConfig", () => {
expect(() => readConfig({ ...env, ISSUE_NUMBER: "abc" })).toThrow("ISSUE_NUMBER");
});
});
describe("step summary", () => {
const closed = { kind: "closed" as const, number: 41760, body: oneFixBody };
const left = { kind: "skip" as const, number: 41762, reason: "still linked to open #41751" };
const commented = { kind: "commented" as const, pullRequest: 41767, tag: "v1.103.0-rc.1", body: fixedBody(41767, { tag: "v1.103.0-rc.1", shipped: false }) };
test("an issue run names the comment, each close, and each pull request left open", () => {
const summary = describeIssue(config, ISSUE, { comment: commented, pullRequests: [closed, left] });
expect(summary).toContain("#41750: commented, fixed by #41767 in v1.103.0-rc.1");
expect(summary).toContain("#41760: closed with: #41750 was fixed by #41767 on main");
expect(summary).toContain("#41762: left open, still linked to open #41751");
expect(summary).not.toContain("DRY RUN");
});
test("a close dry run names the repo variable that turns closing on", () => {
const summary = describeIssue({ ...config, closeDryRun: true }, ISSUE, { comment: commented, pullRequests: [closed] });
expect(summary).toContain("ISSUE_FIXED_CLOSE_PRS_ENABLED");
expect(summary).toContain("#41760: DRY RUN, would close with: #41750 was fixed by #41767 on main");
});
test("a sweep summary counts what it saw and lists only the closes", () => {
const summary = describeSweep(config, { considered: 3522, pullRequests: [closed, left] });
expect(summary).toContain("Swept 3522 open pull requests, 2 linked to an issue, 1 closed");
expect(summary).toContain("#41760: closed with:");
expect(summary).not.toContain("#41762");
expect(describeSweep({ ...config, closeDryRun: true }, { considered: 3522, pullRequests: [closed] })).toContain("1 would be closed, DRY RUN, set the ISSUE_FIXED_CLOSE_PRS_ENABLED");
});
});

View file

@ -6,35 +6,66 @@ declare const process: { readonly env: Readonly<Record<string, string | undefine
export interface FixedConfig {
readonly repo: string;
readonly issueNumber: number;
readonly defaultBranch: string;
readonly dryRun: boolean;
readonly commentDryRun: boolean;
readonly closeDryRun: boolean;
}
export type Run = { readonly kind: "issue"; readonly number: number } | { readonly kind: "sweep" };
interface PullRequestCloser {
readonly __typename: "PullRequest";
readonly number: number;
readonly merged: boolean;
readonly baseRefName: string;
readonly repository: { readonly nameWithOwner: string };
readonly mergeCommit: { readonly oid: string } | null;
}
interface CommitCloser {
readonly __typename: "Commit";
readonly oid: string;
readonly repository: { readonly nameWithOwner: string };
}
export interface ClosedIssue {
export interface IssueClosure {
readonly state: "OPEN" | "CLOSED";
readonly timelineItems: {
readonly nodes: readonly { readonly closer: PullRequestCloser | CommitCloser | null }[];
};
}
export interface LinkedIssue extends IssueClosure {
readonly number: number;
readonly repository: { readonly nameWithOwner: string };
}
export interface LinkedPullRequest {
readonly number: number;
readonly state: "OPEN" | "CLOSED" | "MERGED";
readonly baseRefName: string;
readonly repository: { readonly nameWithOwner: string };
readonly closingIssuesReferences: { readonly totalCount: number; readonly nodes: readonly LinkedIssue[] };
readonly reopens: { readonly nodes: readonly { readonly createdAt: string }[] };
}
export interface PullRequestsPage {
readonly pageInfo: { readonly hasNextPage: boolean; readonly endCursor: string | null };
readonly nodes: readonly LinkedPullRequest[];
}
export interface ClosedIssue extends IssueClosure {
readonly closedByPullRequestsReferences: PullRequestsPage;
}
interface TimelineResponse {
readonly data?: { readonly repository?: { readonly issue: ClosedIssue | null } };
}
interface OpenPullRequestsResponse {
readonly data?: { readonly repository?: { readonly pullRequests: PullRequestsPage } };
}
interface MatchingRef {
readonly ref: string;
}
@ -59,33 +90,100 @@ export type FixedVerdict =
| { readonly kind: "commented"; readonly pullRequest: number; readonly tag: string; readonly body: string }
| { readonly kind: "skip"; readonly reason: string };
export const FIXED_MARKER = "<!-- litellm:fixed-in -->";
const MAX_MINOR_BUMPS = 3;
export type FixSource =
| { readonly kind: "pull_request"; readonly number: number; readonly oid: string }
| { readonly kind: "commit"; readonly oid: string };
export const CLOSER_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
state
timelineItems(last: 1, itemTypes: [CLOSED_EVENT]) {
nodes {
... on ClosedEvent {
closer {
__typename
... on PullRequest { number merged baseRefName mergeCommit { oid } }
... on Commit { oid }
}
}
export type FixVerdict = FixSource | { readonly kind: "skip"; readonly reason: string };
export interface Fix {
readonly issue: number;
readonly source: FixSource;
}
export type CloseVerdict =
| { readonly kind: "candidate"; readonly fixes: readonly Fix[] }
| { readonly kind: "skip"; readonly reason: string };
export type CloseOutcome =
| { readonly kind: "closed"; readonly number: number; readonly body: string }
| { readonly kind: "skip"; readonly number: number; readonly reason: string };
export interface IssueOutcome {
readonly comment: FixedVerdict;
readonly pullRequests: readonly CloseOutcome[];
}
export interface SweepOutcome {
readonly considered: number;
readonly pullRequests: readonly CloseOutcome[];
}
export const FIXED_MARKER = "<!-- litellm:fixed-in -->";
export const SUPERSEDED_MARKER = "<!-- litellm:superseded -->";
const MAX_MINOR_BUMPS = 3;
const MAX_LINKED_PULL_REQUESTS = 50;
const MAX_LINKED_ISSUES = 10;
const SWEEP_PAGE_SIZE = 100;
const CLOSE_PAUSE_MS = 1000;
const WORKFLOW_LOGIN = "github-actions[bot]";
const OPEN_AGAIN = "the issue is open again";
const isReleaseLine = (base: string): boolean => base.startsWith("release/") || base.includes("stable");
const CLOSURE_FRAGMENT = `fragment Closure on Issue {
state
timelineItems(last: 1, itemTypes: [CLOSED_EVENT]) {
nodes {
... on ClosedEvent {
closer {
__typename
... on PullRequest { number merged baseRefName repository { nameWithOwner } mergeCommit { oid } }
... on Commit { oid repository { nameWithOwner } }
}
}
}
}
}`;
const LINKED_PULL_REQUEST_FRAGMENT = `fragment Linked on PullRequest {
number
state
baseRefName
repository { nameWithOwner }
closingIssuesReferences(first: ${MAX_LINKED_ISSUES}) { totalCount nodes { number repository { nameWithOwner } ...Closure } }
reopens: timelineItems(last: 1, itemTypes: [REOPENED_EVENT]) { nodes { ... on ReopenedEvent { createdAt } } }
}`;
export const CLOSER_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
...Closure
closedByPullRequestsReferences(first: ${MAX_LINKED_PULL_REQUESTS}, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { ...Linked }
}
}
}
}
${CLOSURE_FRAGMENT}
${LINKED_PULL_REQUEST_FRAGMENT}`;
export const OPEN_PULL_REQUESTS_QUERY = `query($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
pullRequests(states: OPEN, first: ${SWEEP_PAGE_SIZE}, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { ...Linked }
}
}
}
${CLOSURE_FRAGMENT}
${LINKED_PULL_REQUEST_FRAGMENT}`;
const skip = (reason: string): { readonly kind: "skip"; readonly reason: string } => ({ kind: "skip", reason });
export function closerOf(issue: ClosedIssue, defaultBranch: string): Closer {
export function closerOf(issue: IssueClosure, repo: string, defaultBranch: string): Closer {
if (issue.state !== "CLOSED") {
return skip("the issue is open again");
return skip(OPEN_AGAIN);
}
const closer = issue.timelineItems.nodes[0]?.closer ?? null;
if (closer === null) {
@ -94,6 +192,9 @@ export function closerOf(issue: ClosedIssue, defaultBranch: string): Closer {
if (closer.__typename === "Commit") {
return skip(`closed by commit ${closer.oid.slice(0, 10)}, not by a pull request`);
}
if (closer.repository.nameWithOwner !== repo) {
return skip(`closed by ${closer.repository.nameWithOwner}#${closer.number}, a pull request in another repository`);
}
if (!closer.merged || closer.mergeCommit === null) {
return skip(`closed by #${closer.number}, which is not merged`);
}
@ -121,8 +222,8 @@ async function tagExists(api: GitHubApi, repo: string, tag: string): Promise<boo
return refs.some((ref) => ref.ref === `refs/tags/${tag}`);
}
async function tagContains(api: GitHubApi, repo: string, tag: string, sha: string): Promise<boolean> {
const comparison = await api.request<Comparison>("GET", `/repos/${repo}/compare/${tag}...${sha}`);
async function refContains(api: GitHubApi, repo: string, ref: string, sha: string): Promise<boolean> {
const comparison = await api.request<Comparison>("GET", `/repos/${repo}/compare/${ref}...${sha}`);
return comparison.status === "behind" || comparison.status === "identical";
}
@ -139,7 +240,7 @@ async function firstReleaseWith(
if (!(await tagExists(api, repo, tag))) {
return { kind: "release", tag, shipped: false };
}
if (await tagContains(api, repo, tag, sha)) {
if (await refContains(api, repo, tag, sha)) {
return { kind: "release", tag, shipped: true };
}
if (bumpsLeft === 0) {
@ -164,21 +265,13 @@ export function fixedBody(pullRequest: number, release: { readonly tag: string;
return `${FIXED_MARKER}\nFixed by #${pullRequest}. ${availability}`;
}
export async function commentFixedIssue(api: GitHubApi, config: FixedConfig): Promise<FixedVerdict> {
const [owner, name] = config.repo.split("/");
const response = await api.request<TimelineResponse>("POST", "/graphql", {
query: CLOSER_QUERY,
variables: { owner, name, number: config.issueNumber },
});
const issue = response.data?.repository?.issue ?? null;
if (issue === null) {
return skip("not an issue in this repository");
}
const closer = closerOf(issue, config.defaultBranch);
if (closer.kind === "skip") {
return closer;
}
const issuePath = `/repos/${config.repo}/issues/${config.issueNumber}`;
export async function commentFixedIssue(
api: GitHubApi,
config: FixedConfig,
issueNumber: number,
closer: { readonly number: number; readonly mergeCommit: string },
): Promise<FixedVerdict> {
const issuePath = `/repos/${config.repo}/issues/${issueNumber}`;
const comments = await listAll<Comment>(api, `${issuePath}/comments`);
if (comments.some((comment) => comment.body.includes(FIXED_MARKER))) {
return skip("already carries a fixed-in comment");
@ -188,37 +281,259 @@ export async function commentFixedIssue(api: GitHubApi, config: FixedConfig): Pr
return release;
}
const body = fixedBody(closer.number, release);
if (!config.dryRun) {
if (!config.commentDryRun) {
await api.request("POST", `${issuePath}/comments`, { body });
}
return { kind: "commented", pullRequest: closer.number, tag: release.tag, body };
}
export function readConfig(env: Readonly<Record<string, string | undefined>>): FixedConfig & { readonly token: string } {
export function fixOf(issue: IssueClosure, repo: string): FixVerdict {
if (issue.state !== "CLOSED") {
return skip("is open again");
}
const closer = issue.timelineItems.nodes[0]?.closer ?? null;
if (closer === null) {
return skip("was closed by hand");
}
if (closer.repository.nameWithOwner !== repo) {
return skip(`was closed from ${closer.repository.nameWithOwner}`);
}
if (closer.__typename === "Commit") {
return { kind: "commit", oid: closer.oid };
}
if (!closer.merged || closer.mergeCommit === null) {
return skip(`was closed by #${closer.number}, which is not merged`);
}
return { kind: "pull_request", number: closer.number, oid: closer.mergeCommit.oid };
}
export function closeVerdict(pullRequest: LinkedPullRequest, config: FixedConfig): CloseVerdict {
if (pullRequest.repository.nameWithOwner !== config.repo) {
return skip(`lives in ${pullRequest.repository.nameWithOwner}`);
}
if (pullRequest.state !== "OPEN") {
return skip(`is ${pullRequest.state.toLowerCase()}`);
}
if (isReleaseLine(pullRequest.baseRefName)) {
return skip(`targets the release line ${pullRequest.baseRefName}`);
}
const { totalCount, nodes: linked } = pullRequest.closingIssuesReferences;
if (linked.length === 0) {
return skip("links no issue");
}
if (totalCount > linked.length) {
return skip(`links ${totalCount} issues, more than the ${MAX_LINKED_ISSUES} this workflow reads`);
}
const foreign = linked.find((issue) => issue.repository.nameWithOwner !== config.repo);
if (foreign !== undefined) {
return skip(`links ${foreign.repository.nameWithOwner}#${foreign.number}`);
}
const stillOpen = linked.find((issue) => issue.state === "OPEN");
if (stillOpen !== undefined) {
return skip(`still linked to open #${stillOpen.number}`);
}
const verdicts = linked.map((issue) => ({ issue: issue.number, fix: fixOf(issue, config.repo) }));
for (const { issue, fix } of verdicts) {
if (fix.kind === "skip") {
return skip(`#${issue} ${fix.reason}`);
}
}
return {
kind: "candidate",
fixes: verdicts.flatMap(({ issue, fix }) => (fix.kind === "skip" ? [] : [{ issue, source: fix }])),
};
}
function describeSource(source: FixSource): string {
return source.kind === "pull_request" ? `#${source.number}` : `commit ${source.oid.slice(0, 10)}`;
}
async function fixOffDefaultBranch(api: GitHubApi, config: FixedConfig, fixes: readonly Fix[]): Promise<Fix | undefined> {
const onBranch = await Promise.all(fixes.map((fix) => refContains(api, config.repo, config.defaultBranch, fix.source.oid)));
return fixes.find((_, index) => !onBranch[index]);
}
export function supersededBody(fixes: readonly Fix[], defaultBranch: string): string {
const pairs = fixes
.map((fix, index) => `#${fix.issue} ${index === 0 ? "was fixed by" : "by"} ${describeSource(fix.source)}`)
.join(" and ");
return `${SUPERSEDED_MARKER}\n${pairs} on ${defaultBranch}, so this pull request is closed. Reopen it if something was missed.`;
}
function reopenedAfter(pullRequest: LinkedPullRequest, comment: Comment): boolean {
const reopen = pullRequest.reopens.nodes[0];
return reopen !== undefined && Date.parse(reopen.createdAt) > Date.parse(comment.created_at);
}
async function closePullRequest(
api: GitHubApi,
config: FixedConfig,
pullRequest: LinkedPullRequest,
pause: () => Promise<void>,
): Promise<CloseOutcome> {
const verdict = closeVerdict(pullRequest, config);
if (verdict.kind === "skip") {
return { kind: "skip", number: pullRequest.number, reason: verdict.reason };
}
const offBranch = await fixOffDefaultBranch(api, config, verdict.fixes);
if (offBranch !== undefined) {
const fix = describeSource(offBranch.source);
return { kind: "skip", number: pullRequest.number, reason: `#${offBranch.issue} was fixed by ${fix}, which is not on ${config.defaultBranch}` };
}
const issuePath = `/repos/${config.repo}/issues/${pullRequest.number}`;
const comments = await listAll<Comment>(api, `${issuePath}/comments`);
const marker = comments.find((comment) => comment.user.login === WORKFLOW_LOGIN && comment.body.includes(SUPERSEDED_MARKER));
if (marker !== undefined && reopenedAfter(pullRequest, marker)) {
return { kind: "skip", number: pullRequest.number, reason: "was closed by this workflow once and reopened" };
}
const body = marker?.body ?? supersededBody(verdict.fixes, config.defaultBranch);
if (config.closeDryRun) {
return { kind: "closed", number: pullRequest.number, body };
}
if (marker === undefined) {
await pause();
await api.request("POST", `${issuePath}/comments`, { body });
}
await pause();
await api.request("PATCH", `/repos/${config.repo}/pulls/${pullRequest.number}`, { state: "closed" });
return { kind: "closed", number: pullRequest.number, body };
}
export function closePullRequests(
api: GitHubApi,
config: FixedConfig,
candidates: readonly LinkedPullRequest[],
pause: () => Promise<void>,
): Promise<readonly CloseOutcome[]> {
return candidates.reduce<Promise<readonly CloseOutcome[]>>(
async (previous, candidate) => [...(await previous), await closePullRequest(api, config, candidate, pause)],
Promise.resolve([]),
);
}
type NextPage = (after: string | null) => Promise<PullRequestsPage>;
async function collectPages(page: PullRequestsPage, nextPage: NextPage): Promise<readonly LinkedPullRequest[]> {
if (!page.pageInfo.hasNextPage) {
return page.nodes;
}
return [...page.nodes, ...(await collectPages(await nextPage(page.pageInfo.endCursor), nextPage))];
}
async function closedIssue(api: GitHubApi, config: FixedConfig, issueNumber: number, after: string | null): Promise<ClosedIssue | null> {
const [owner, name] = config.repo.split("/");
const response = await api.request<TimelineResponse>("POST", "/graphql", {
query: CLOSER_QUERY,
variables: { owner, name, number: issueNumber, after },
});
return response.data?.repository?.issue ?? null;
}
export async function handleFixedIssue(
api: GitHubApi,
config: FixedConfig,
issueNumber: number,
pause: () => Promise<void>,
): Promise<IssueOutcome> {
const issue = await closedIssue(api, config, issueNumber, null);
if (issue === null) {
return { comment: skip("not an issue in this repository"), pullRequests: [] };
}
if (issue.state !== "CLOSED") {
return { comment: skip(OPEN_AGAIN), pullRequests: [] };
}
const closer = closerOf(issue, config.repo, config.defaultBranch);
const comment = closer.kind === "skip" ? closer : await commentFixedIssue(api, config, issueNumber, closer);
const nextPage: NextPage = async (after) => {
const more = await closedIssue(api, config, issueNumber, after);
if (more === null) {
throw new Error(`#${issueNumber} came back without data while reading its linked pull requests after cursor ${after}`);
}
return more.closedByPullRequestsReferences;
};
const linked = await collectPages(issue.closedByPullRequestsReferences, nextPage);
const open = linked.filter((pullRequest) => pullRequest.state === "OPEN");
const pullRequests = await closePullRequests(api, config, open, pause);
return { comment, pullRequests };
}
async function openPullRequests(api: GitHubApi, config: FixedConfig): Promise<readonly LinkedPullRequest[]> {
const [owner, name] = config.repo.split("/");
const nextPage: NextPage = async (after) => {
const response = await api.request<OpenPullRequestsResponse>("POST", "/graphql", {
query: OPEN_PULL_REQUESTS_QUERY,
variables: { owner, name, after },
});
const page = response.data?.repository?.pullRequests;
if (page === undefined) {
throw new Error(`open pull requests after cursor ${after} came back without data: ${JSON.stringify(response)}`);
}
return page;
};
return collectPages(await nextPage(null), nextPage);
}
export async function sweep(api: GitHubApi, config: FixedConfig, pause: () => Promise<void>): Promise<SweepOutcome> {
const open = await openPullRequests(api, config);
const linked = open.filter((pullRequest) => pullRequest.closingIssuesReferences.nodes.length > 0);
return { considered: open.length, pullRequests: await closePullRequests(api, config, linked, pause) };
}
export function readConfig(
env: Readonly<Record<string, string | undefined>>,
): FixedConfig & { readonly token: string; readonly run: Run } {
const token = env.GITHUB_TOKEN;
const repo = env.GITHUB_REPOSITORY;
const defaultBranch = env.DEFAULT_BRANCH;
if (!token || !repo || !/^[\w.-]+\/[\w.-]+$/.test(repo) || !defaultBranch) {
throw new Error("GITHUB_TOKEN, GITHUB_REPOSITORY (owner/repo) and DEFAULT_BRANCH are required");
}
const config = { token, repo, defaultBranch, commentDryRun: env.DRY_RUN === "true", closeDryRun: env.CLOSE_PRS_DRY_RUN === "true" };
if (env.SWEEP === "true") {
return { ...config, run: { kind: "sweep" } };
}
const issueNumber = Number(env.ISSUE_NUMBER);
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
throw new Error(`ISSUE_NUMBER must be a positive integer, got "${env.ISSUE_NUMBER}"`);
throw new Error(`ISSUE_NUMBER must be a positive integer, got "${env.ISSUE_NUMBER}": dispatch with an issue_number or with sweep ticked`);
}
return { token, repo, issueNumber, defaultBranch, dryRun: env.DRY_RUN === "true" };
return { ...config, run: { kind: "issue", number: issueNumber } };
}
function describe(config: FixedConfig, verdict: FixedVerdict): string {
if (verdict.kind === "skip") {
return `#${config.issueNumber}: skipped, ${verdict.reason}`;
const CLOSE_DRY_RUN_HINT = "set the ISSUE_FIXED_CLOSE_PRS_ENABLED repo variable to true to close pull requests";
function describeClose(config: FixedConfig, outcome: CloseOutcome): string {
if (outcome.kind === "skip") {
return `#${outcome.number}: left open, ${outcome.reason}`;
}
if (config.dryRun) {
return `#${config.issueNumber}: DRY RUN, set the ISSUE_FIXED_COMMENT_ENABLED repo variable to true to post this:\n\n${verdict.body}`;
}
return `#${config.issueNumber}: commented, fixed by #${verdict.pullRequest} in ${verdict.tag}`;
const text = outcome.body.replace(`${SUPERSEDED_MARKER}\n`, "");
return config.closeDryRun ? `#${outcome.number}: DRY RUN, would close with: ${text}` : `#${outcome.number}: closed with: ${text}`;
}
export function describeIssue(config: FixedConfig, issueNumber: number, outcome: IssueOutcome): string {
const comment =
outcome.comment.kind === "skip"
? `#${issueNumber}: skipped, ${outcome.comment.reason}`
: config.commentDryRun
? `#${issueNumber}: DRY RUN, set the ISSUE_FIXED_COMMENT_ENABLED repo variable to true to post this:\n\n${outcome.comment.body}`
: `#${issueNumber}: commented, fixed by #${outcome.comment.pullRequest} in ${outcome.comment.tag}`;
const hint = config.closeDryRun && outcome.pullRequests.some((pullRequest) => pullRequest.kind === "closed") ? [`Closing is a DRY RUN, ${CLOSE_DRY_RUN_HINT}`] : [];
return [comment, ...hint, ...outcome.pullRequests.map((pullRequest) => describeClose(config, pullRequest))].join("\n");
}
export function describeSweep(config: FixedConfig, outcome: SweepOutcome): string {
const closed = outcome.pullRequests.filter((pullRequest) => pullRequest.kind === "closed");
const verb = config.closeDryRun ? `would be closed, DRY RUN, ${CLOSE_DRY_RUN_HINT}` : "closed";
const header = `Swept ${outcome.considered} open pull requests, ${outcome.pullRequests.length} linked to an issue, ${closed.length} ${verb}`;
return [header, ...closed.map((pullRequest) => describeClose(config, pullRequest))].join("\n");
}
if (import.meta.main) {
const { token, ...config } = readConfig(process.env);
console.log(describe(config, await commentFixedIssue(githubApi(token), config)));
const { token, run, ...config } = readConfig(process.env);
const api = githubApi(token);
const pause = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, CLOSE_PAUSE_MS));
console.log(
run.kind === "sweep"
? describeSweep(config, await sweep(api, config, pause))
: describeIssue(config, run.number, await handleFixedIssue(api, config, run.number, pause)),
);
}