-
Notifications
You must be signed in to change notification settings - Fork 0
fix(pr-fix): keep the item URL as the PR URL when CI evidence re-enqueues it #1117
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 all commits
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 |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| -- Repair PR-fix item URLs that the pre-#1098 ingestion overwrote with a CI job | ||
| -- URL (#1118). The item URL is identity and is now write-once, so these rows | ||
| -- would otherwise keep the job URL. repo + pr already identify the PR. | ||
| UPDATE "PrFixQueueItem" | ||
| SET "url" = 'https://api.github.com/repos/' || "repo" || '/pulls/' || "pr" | ||
|
Contributor
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. Minor: Both the migration's WHERE regex and isActionsRunUrl only match the /actions/runs/ shape, so a row poisoned with the legacy check-run html_url shape (https://github.com///runs/ — the exact shape the old webhook path stored, per this PR's own updated fixture) is neither repaired nor treated as empty by the heal guard. Automated finding from AI PR review. |
||
| WHERE "url" ~ '^https://github\.com/[^/]+/[^/]+/actions/runs/'; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -287,7 +287,8 @@ export async function POST(request: NextRequest) { | |
| repoFullName, | ||
| prNumber: pr.number, | ||
| branch: pr.head?.ref ?? null, | ||
| url: checkRun.html_url, | ||
| url: pr.url, | ||
|
Contributor
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. Minor: The sync route's new event shape (url: pr.url, checkRunUrl: checkRun.html_url) has no co-located route test asserting the constructed event, unlike the webhook and ingestion paths. Automated finding from AI PR review. |
||
| checkRunUrl: checkRun.html_url, | ||
| title: checkRun.name, | ||
| author: pr.user.login, | ||
| body: excerpt || checkRun.output?.summary || "", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -274,6 +274,127 @@ describe("PR review-fix queue", () => { | |
| }); | ||
| }); | ||
|
|
||
| describe("item URL is identity, write-once (#1098)", () => { | ||
| let client: ReturnType<typeof makeClient>; | ||
|
|
||
| beforeEach(() => { | ||
| client = makeClient(); | ||
| surfacingMocks.surfacePrFixBlocked.mockReset(); | ||
| surfacingMocks.surfacePrFixBlocked.mockResolvedValue({ labelApplied: true, commentPosted: true, errors: [] }); | ||
| }); | ||
|
|
||
| it("keeps the PR URL when a CI-failure re-enqueue carries a job URL", async () => { | ||
| await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 7, lane: "NORMAL", type: "REVIEW_FEEDBACK", | ||
| reason: "PR review: CHANGES_REQUESTED", feedback: "changes requested", | ||
| evidenceKey: "rev-1", | ||
| url: "https://api.github.com/repos/o/repo/pulls/7", | ||
| }); | ||
|
|
||
| const after = await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 7, lane: "NORMAL", type: "CI_FAILURE", | ||
| reason: "checks failed", feedback: "build failed", | ||
| evidenceKey: "cr-1", | ||
| url: "https://github.com/o/repo/actions/runs/9/job/1", | ||
| }); | ||
|
|
||
| // The item URL is identity: it must stay the PR URL, never flip to the | ||
| // CI job URL, so next-task keeps handing out pullRequest.url. | ||
| expect(after.url).toBe("https://api.github.com/repos/o/repo/pulls/7"); | ||
| expect(client.items[0].url).toBe("https://api.github.com/repos/o/repo/pulls/7"); | ||
| // Evidence is still appended even when the URL is not. | ||
| expect(client.items[0].feedback).toEqual(["changes requested", "build failed"]); | ||
| expect(client.items[0].evidenceKeys).toEqual(["rev-1", "cr-1"]); | ||
| }); | ||
|
|
||
| it("repairs a stored CI job URL on the next enqueue that carries the PR URL (#1118)", async () => { | ||
| await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 9, lane: "NORMAL", type: "CI_FAILURE", | ||
| reason: "r", feedback: "f1", evidenceKey: "cr-1", | ||
| }); | ||
| // A row poisoned by the pre-#1098 ingestion. | ||
| client.items[0].url = "https://github.com/o/repo/actions/runs/9/job/1"; | ||
|
|
||
| const after = await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 9, lane: "NORMAL", type: "REVIEW_FEEDBACK", | ||
| reason: "r", feedback: "f2", evidenceKey: "rev-1", | ||
| url: "https://api.github.com/repos/o/repo/pulls/9", | ||
| }); | ||
|
|
||
| expect(after.url).toBe("https://api.github.com/repos/o/repo/pulls/9"); | ||
| }); | ||
|
|
||
| it("leaves a legitimate stored URL alone even when a different one arrives", async () => { | ||
| await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 10, lane: "NORMAL", type: "REVIEW_FEEDBACK", | ||
| reason: "r", feedback: "f1", evidenceKey: "rev-1", | ||
| url: "https://github.com/o/repo/pull/10", | ||
| }); | ||
|
|
||
| const after = await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 10, lane: "NORMAL", type: "REVIEW_FEEDBACK", | ||
| reason: "r", feedback: "f2", evidenceKey: "rev-2", | ||
| url: "https://api.github.com/repos/o/repo/pulls/10", | ||
| }); | ||
|
|
||
| expect(after.url).toBe("https://github.com/o/repo/pull/10"); | ||
| }); | ||
|
|
||
| it("never stores a CI job URL as the item URL, on create or on backfill (#1118)", async () => { | ||
| const created = await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 11, lane: "NORMAL", type: "CI_FAILURE", | ||
| reason: "r", feedback: "f1", evidenceKey: "cr-1", | ||
| url: "https://github.com/o/repo/actions/runs/9/job/1", | ||
| }); | ||
| expect(created.url).toBeUndefined(); | ||
|
|
||
| const reenqueued = await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 11, lane: "NORMAL", type: "CI_FAILURE", | ||
| reason: "r", feedback: "f2", evidenceKey: "cr-2", | ||
| url: "https://github.com/o/repo/actions/runs/10/job/2", | ||
| }); | ||
| expect(reenqueued.url).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("backfills the URL only when the item has none", async () => { | ||
| await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 8, lane: "NORMAL", type: "REVIEW_FEEDBACK", | ||
| reason: "r", feedback: "f1", evidenceKey: "rev-1", | ||
| }); | ||
| expect(client.items[0].url).toBeUndefined(); | ||
|
|
||
| const after = await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 8, lane: "NORMAL", type: "CI_FAILURE", | ||
| reason: "r", feedback: "f2", evidenceKey: "cr-1", | ||
| url: "https://api.github.com/repos/o/repo/pulls/8", | ||
| }); | ||
|
|
||
| expect(after.url).toBe("https://api.github.com/repos/o/repo/pulls/8"); | ||
| expect(client.items[0].url).toBe("https://api.github.com/repos/o/repo/pulls/8"); | ||
| }); | ||
|
|
||
| it("backfills the URL when the stored value is the empty string (String? column shape)", async () => { | ||
| await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 8, lane: "NORMAL", type: "CI_FAILURE", | ||
| reason: "r", feedback: "f1", evidenceKey: "cr-1", | ||
| url: "", | ||
| }); | ||
| // The webhook enqueues with url: "" when the PR object has no url (#1098); | ||
|
Contributor
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. Info: The create-branch truthiness guard for an empty-string input.url is not exercised directly; the empty-string case is only tested by manually mutating a stored item before a second enqueue. Automated finding from AI PR review. |
||
| // a String? column can carry the empty string, which the write-once guard | ||
| // must treat as empty. | ||
| client.items[0].url = ""; | ||
|
|
||
| const after = await enqueuePrFixItem(client, { | ||
| repo: "o/repo", pr: 8, lane: "NORMAL", type: "CI_FAILURE", | ||
| reason: "r", feedback: "f2", evidenceKey: "cr-2", | ||
| url: "https://api.github.com/repos/o/repo/pulls/8", | ||
| }); | ||
|
|
||
| expect(after.url).toBe("https://api.github.com/repos/o/repo/pulls/8"); | ||
| expect(client.items[0].url).toBe("https://api.github.com/repos/o/repo/pulls/8"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("work generation identity (#1044)", () => { | ||
| let client: ReturnType<typeof makeClient>; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ export interface EnqueuePrFixInput { | |
| evidenceKey: string; | ||
| issue?: number | null; | ||
| branch?: string | null; | ||
| /** Item identity — should be the PR URL. Set at first enqueue, only backfilled while empty (#1098). */ | ||
| url?: string | null; | ||
| title?: string | null; | ||
| headSha?: string | null; | ||
|
|
@@ -206,12 +207,30 @@ function extractUrlsFromTextSafe(text: string): string[] { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * A GitHub Actions run/job URL: what the pre-#1098 ingestion stored as an | ||
| * item URL for CI failures. Never valid item identity (#1118). | ||
| */ | ||
| const ACTIONS_RUN_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\//; | ||
|
|
||
| function isActionsRunUrl(url: string | null | undefined): boolean { | ||
| return typeof url === "string" && ACTIONS_RUN_URL.test(url); | ||
| } | ||
|
|
||
| /** The item URL an enqueue may write: never a CI job URL (#1098, #1118). */ | ||
|
Contributor
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. Info: itemUrlFromInput blocklists only the Actions-run URL shape rather than positively validating the URL matches the item's repo+pr, so any other non-PR URL from an ingestion caller could still become the write-once identity; acceptable given callers are internal, but worth noting. Automated finding from AI PR review. |
||
| function itemUrlFromInput(input: EnqueuePrFixInput): string | undefined { | ||
| return input.url && !isActionsRunUrl(input.url) ? input.url : undefined; | ||
| } | ||
|
|
||
| // `url` is not part of this patch (#1098): the item URL is identity (the PR | ||
| // URL) — set at first enqueue, backfilled only while empty. A CI-failure | ||
| // re-enqueue's job URL must not flip it; job links belong in feedback/ | ||
| // evidence (pr-followup-ingestion), not the item URL. | ||
| function metadataPatch(input: EnqueuePrFixInput): Record<string, string | number> { | ||
| const patch: Record<string, string | number> = {}; | ||
| for (const [key, value] of Object.entries({ | ||
| issue: input.issue ?? undefined, | ||
| branch: input.branch ?? undefined, | ||
| url: input.url ?? undefined, | ||
| title: input.title ?? undefined, | ||
| headSha: input.headSha ?? undefined, | ||
| author: input.author ?? undefined, | ||
|
|
@@ -324,6 +343,14 @@ export async function enqueuePrFixItem(client: PrFixQueueClient, input: EnqueueP | |
| reason: input.reason, | ||
| feedback: uniqueAppend(existing.feedback ?? [], input.feedback, 12), | ||
| evidenceKeys: nextEvidenceKeys, | ||
| // #1098: the item URL is identity — write-once. Backfill it only | ||
| // while empty; never overwrite an existing URL with a | ||
| // re-enqueue's value (e.g. a CI job URL). A stored CI job URL left | ||
| // by the pre-#1098 ingestion counts as empty, so it heals on the | ||
| // next enqueue instead of being frozen by the guard (#1118). | ||
| ...((!existing.url || isActionsRunUrl(existing.url)) && itemUrlFromInput(input) | ||
| ? { url: itemUrlFromInput(input) } | ||
| : {}), | ||
| // A fresh attempt gets a fresh per-attempt head baseline (#1074): | ||
| // the head the sync observed in THIS enqueue, else the last one | ||
| // observed (#1104). metadataPatch below keeps refreshing the mutable | ||
|
|
@@ -364,6 +391,9 @@ export async function enqueuePrFixItem(client: PrFixQueueClient, input: EnqueueP | |
| reason: input.reason, | ||
| feedback: [input.feedback], | ||
| evidenceKeys: [input.evidenceKey], | ||
| // #1098: the item URL is identity (the PR URL) — set it at first | ||
| // enqueue, explicitly, so it is never left to the update patch. | ||
| ...(itemUrlFromInput(input) ? { url: itemUrlFromInput(input) } : {}), | ||
| // A brand-new item is a fresh attempt: capture the head the sync | ||
| // observed now as its immutable per-attempt baseline (#1074). | ||
| attemptHeadSha: input.headSha ?? null, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1011,6 +1011,63 @@ describe("processPrFollowupEvents", () => { | |
| error.mockRestore(); | ||
| }); | ||
|
|
||
| it("enqueues a check_run with the PR URL as the item URL and the job URL in feedback (#1098)", async () => { | ||
|
Contributor
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. Minor: The new check_run ingestion test asserts the job URL appears in feedback but does not assert evidenceKeys population for the check-run path. Automated finding from AI PR review. |
||
| process.env.PR_FOLLOWUP_BOT_IDENTITIES = "itsmiso-ai"; | ||
| const client = makeClient(); | ||
|
|
||
| const result = await processPrFollowupEvents(client, [ | ||
| { | ||
| eventType: "check_run", | ||
| repoFullName: "org/repo", | ||
| prNumber: 7, | ||
| branch: "fix/c", | ||
| url: "https://api.github.com/repos/org/repo/pulls/7", | ||
| checkRunUrl: "https://github.com/org/repo/actions/runs/9/job/1", | ||
| title: "Fix C", | ||
| author: "itsmiso-ai", | ||
| body: "Error: Cannot read property 'x' of undefined", | ||
| id: "cr1098", | ||
| conclusion: "failure", | ||
| checkName: "lint", | ||
| }, | ||
| ]); | ||
|
|
||
| expect(result.enqueued).toBe(1); | ||
| expect(client.items).toHaveLength(1); | ||
| // The item URL is the PR URL, not the CI job URL. | ||
| expect(client.items[0].url).toBe("https://api.github.com/repos/org/repo/pulls/7"); | ||
| // The job URL is evidence in the feedback text ("Full log:" line). | ||
| const feedback = String(client.items[0].feedback); | ||
| expect(feedback).toContain("Full log: https://github.com/org/repo/actions/runs/9/job/1"); | ||
| expect(feedback).not.toContain("pulls/7"); | ||
| }); | ||
|
|
||
| it("keeps the job URL in feedback when checkRunUrl is absent (legacy shape)", async () => { | ||
| process.env.PR_FOLLOWUP_BOT_IDENTITIES = "itsmiso-ai"; | ||
| const client = makeClient(); | ||
|
|
||
| const result = await processPrFollowupEvents(client, [ | ||
| { | ||
| eventType: "check_run", | ||
| repoFullName: "org/repo", | ||
| prNumber: 7, | ||
| branch: "fix/c", | ||
| url: "https://github.com/org/repo/actions/runs/9/job/1", | ||
| title: "Fix C", | ||
| author: "itsmiso-ai", | ||
| body: "Error: Cannot read property 'x' of undefined", | ||
| id: "cr1098-legacy", | ||
| conclusion: "failure", | ||
| checkName: "lint", | ||
| }, | ||
| ]); | ||
|
|
||
| expect(result.enqueued).toBe(1); | ||
| expect(client.items).toHaveLength(1); | ||
| const feedback = String(client.items[0].feedback); | ||
| expect(feedback).toContain("Full log: https://github.com/org/repo/actions/runs/9/job/1"); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| delete process.env.PR_FOLLOWUP_BOT_IDENTITIES; | ||
| }); | ||
|
|
||
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.
Info: The job-URL pattern is duplicated between the migration SQL and ACTIONS_RUN_URL in pr-fix-queue.ts with no test asserting the two predicates stay in sync; consider a comment cross-reference on both sides.
Automated finding from AI PR review.