diff --git a/.agents/skills/_references/external-review-mechanics.md b/.agents/skills/_references/external-review-mechanics.md index 9826ab00218..37c351b7d7d 100644 --- a/.agents/skills/_references/external-review-mechanics.md +++ b/.agents/skills/_references/external-review-mechanics.md @@ -125,8 +125,14 @@ Accept Codex as clean only when its result was produced after the latest relevan push, identifies the exact `headRefOid` (for a formal review, its `commit_id` matches), and gives an explicit no-issues result or terminal clean reaction. A generic review wrapper, silence, or absence of inline findings is insufficient by -itself. After a push, request `@codex review` once unless a current request is -already active. +itself. After a push, request review once unless a current request is already +active. Bind the request to the full current head SHA using exactly these two +lines so resumable PR actions can distinguish it from an older request: + +```text +@codex review + +``` Read all review threads with GraphQL pagination. `gh api --paginate` supplies the next `$endCursor`; keep `pageInfo` in the query so it cannot silently truncate at @@ -181,9 +187,24 @@ gh api graphql \ }' ``` +When a current-head finding exists only as a top-level issue comment or +body-only formal review and the agent rejects it without pushing a fix, record +that judgement with this exact single-line issue comment before relaunching +`Wait for PR`. Use `comment:COMMENT_ID` for an issue comment or +`review:REVIEW_ID` for a formal review, plus the full current head SHA: + +```text + +``` + +The marker is unnecessary after a fix push because the new head invalidates the +old finding, and unnecessary for inline findings because thread resolution is +the durable handled state. + Before merge, take a fresh snapshot and require all of these on the same head: -- terminal-clean Codex result; +- terminal-clean Codex result, or durable handled evidence for every exact-head + finding; - zero unresolved review threads, including outdated threads; - required checks and local validation are green; - mergeability is clean and the expected base SHA has not moved. diff --git a/.agents/skills/lastcode-pr/SKILL.md b/.agents/skills/lastcode-pr/SKILL.md index a24efa6b506..f64af0a6c45 100644 --- a/.agents/skills/lastcode-pr/SKILL.md +++ b/.agents/skills/lastcode-pr/SKILL.md @@ -47,9 +47,13 @@ current GitHub thread and review-query mechanics. 1. Inspect comments and thread-level review state newer than the latest push. 2. Verify each bot finding against the source. Fix real defects; reply with a concrete reason when a finding is false. Resolve only addressed threads. -3. Request `@codex review` after each fix push. Do not merge until Codex gives an - explicit clean result for the exact current head and no review thread remains - unresolved. + When rejecting a top-level issue-comment or body-only formal finding without + pushing a fix or resolving an inline thread, post the exact handled marker + documented in the external-review reference before relaunching `Wait for PR`. +3. After each fix push, request review using the exact-head format in + `../_references/external-review-mechanics.md`. Do not merge until Codex gives + an explicit clean result or every finding for the exact current head has a + durable handled state, and no review thread remains unresolved. 4. Run `pnpm lastcode:ci` from a clean branch. Its full-CI stamp must match the exact head and fetched `origin/lastcode/main` base. 5. Use `pnpm lastcode:merge`; do not bypass the guarded merge in the GitHub UI. diff --git a/apps/server/src/entrypoint.test.ts b/apps/server/src/entrypoint.test.ts index 56f2c119764..f425c873c02 100644 --- a/apps/server/src/entrypoint.test.ts +++ b/apps/server/src/entrypoint.test.ts @@ -53,7 +53,7 @@ describe("isEntrypoint", () => { expect( isEntrypoint({ - moduleUrl: NodeURL.pathToFileURL(real).href, + moduleUrl: NodeURL.pathToFileURL(NodeFS.realpathSync(real)).href, entryPath: link, runtimeMain: undefined, }), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 2d0c996a8ec..990eab82626 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1226,6 +1226,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["remote", "set-head", "origin", initialBranch]); yield* git(cwd, ["checkout", "-b", "feature/no-upstream"]); yield* writeTextFile(cwd, "feature.txt", "feature\n"); yield* git(cwd, ["add", "feature.txt"]); @@ -1549,8 +1550,8 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { // Two threads can record the same worktree path; the second delete // must be a no-op instead of exit 128. - yield* driver.removeWorktree({ cwd, path: worktreePath }); - yield* driver.removeWorktree({ cwd, path: worktreePath }); + yield* driver.removeWorktree({ cwd, path: worktreePath, allowMissing: true }); + yield* driver.removeWorktree({ cwd, path: worktreePath, allowMissing: true }); }), ); @@ -1576,6 +1577,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* driver.removeWorktree({ cwd, path: pathService.join(worktreesRoot, "never-registered"), + allowMissing: true, }); const registered = yield* git(cwd, ["worktree", "list", "--porcelain"]); diff --git a/scripts/lastcode-wait-for-pr.test.ts b/scripts/lastcode-wait-for-pr.test.ts new file mode 100644 index 00000000000..e5a5b29ac4e --- /dev/null +++ b/scripts/lastcode-wait-for-pr.test.ts @@ -0,0 +1,730 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { describe, expect, it } from "vite-plus/test"; + +import { + classifyStatusChecks, + decideWaitForPr, + deriveReviewState, + latestCodexReviewTrigger, + pullRequestViewArgs, + requiresReadyConfirmation, + reviewThreadsArgs, + samePullRequestRevision, + type ReviewState, + type WaitObservation, +} from "./lastcode-wait-for-pr.ts"; + +const HEAD = "1234567890abcdef1234567890abcdef12345678"; +const BASE = "abcdef1234567890abcdef1234567890abcdef12"; +const reviewRequest = (head = HEAD): string => + `@codex review\n`; + +const pendingReview: ReviewState = { + terminalArtifacts: [], + requestPresent: true, + pending: true, + ready: false, + latestTriggerId: 10, +}; + +const handledReview: ReviewState = { + terminalArtifacts: [{ key: "comment:20", observedAt: "2026-08-24T10:05:00Z" }], + requestPresent: true, + pending: false, + ready: true, + latestTriggerId: 10, +}; + +function observation( + input: { + readonly ci?: WaitObservation["ci"]; + readonly review?: ReviewState; + readonly head?: string; + readonly base?: string; + readonly state?: string; + readonly isDraft?: boolean; + readonly mergeable?: string; + readonly mergeStateStatus?: string; + readonly baseRefName?: string; + readonly unresolvedReviewThreads?: number; + } = {}, +): WaitObservation { + return { + pullRequest: { + number: 87, + url: "https://github.com/lastobelus/lastCode/pull/88", + state: input.state ?? "OPEN", + isDraft: input.isDraft ?? false, + headRefOid: input.head ?? HEAD, + baseRefOid: input.base ?? BASE, + baseRefName: input.baseRefName ?? "lastcode/main", + mergeable: input.mergeable ?? "MERGEABLE", + mergeStateStatus: input.mergeStateStatus ?? "CLEAN", + statusCheckRollup: [], + }, + ci: input.ci ?? "pending", + review: input.review ?? pendingReview, + unresolvedReviewThreads: input.unresolvedReviewThreads ?? 0, + }; +} + +describe("lastcode-wait-for-pr", () => { + it("passes the checked-out branch explicitly when resolving its pull request", () => { + expect(pullRequestViewArgs("lastobelus/lastCode", "lastcode/wait-for-pr")).toEqual([ + "pr", + "view", + "lastcode/wait-for-pr", + "--repo", + "lastobelus/lastCode", + "--json", + "number,url,state,isDraft,headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,statusCheckRollup", + ]); + expect(() => pullRequestViewArgs("lastobelus/lastCode", "")).toThrow( + "requires a checked-out branch", + ); + }); + + it("discards review observations when the head or base changes during collection", () => { + const initial = observation().pullRequest; + expect(samePullRequestRevision(initial, observation().pullRequest)).toBe(true); + expect( + samePullRequestRevision(initial, observation({ head: "2".repeat(40) }).pullRequest), + ).toBe(false); + expect( + samePullRequestRevision(initial, observation({ base: "3".repeat(40) }).pullRequest), + ).toBe(false); + }); + + it("requires a matching second review snapshot before returning ready", () => { + expect( + requiresReadyConfirmation( + observation({ ci: "success", review: handledReview, unresolvedReviewThreads: 0 }), + ), + ).toBe(true); + expect(requiresReadyConfirmation(observation({ review: handledReview }))).toBe(false); + expect( + requiresReadyConfirmation( + observation({ ci: "success", review: handledReview, unresolvedReviewThreads: 1 }), + ), + ).toBe(false); + }); + + it("paginates review threads for the exact pull request", () => { + const args = reviewThreadsArgs("lastobelus/lastCode", 88); + expect(args).toContain("--paginate"); + expect(args).toContain("owner=lastobelus"); + expect(args).toContain("name=lastCode"); + expect(args).toContain("number=88"); + expect(args.at(-1)).toContain("reviewThreads(first:100,after:$endCursor)"); + expect(() => reviewThreadsArgs("invalid", 88)).toThrow("Invalid GitHub repository"); + }); + + it("keeps waiting when CI succeeds while the current-head review is pending", () => { + const baseline = observation(); + expect(decideWaitForPr(baseline, observation({ ci: "success" }))).toEqual({ + kind: "wait", + reason: "review-pending", + }); + }); + + it("wakes for a new clean or finding-bearing review even while CI is pending", () => { + const baseline = observation(); + const currentReview = { + ...handledReview, + terminalArtifacts: [ + ...handledReview.terminalArtifacts, + { key: "review:21", observedAt: "2026-08-24T10:06:00Z" }, + ], + }; + expect(decideWaitForPr(baseline, observation({ review: currentReview }))).toMatchObject({ + kind: "wake", + reason: "review-completed", + }); + }); + + it("wakes for current-head CI failure even while review is pending", () => { + const baseline = observation(); + expect(decideWaitForPr(baseline, observation({ ci: "failure" }))).toMatchObject({ + kind: "wake", + reason: "ci-failed", + }); + }); + + it("wakes for an existing unresolved review thread even while CI is pending", () => { + const handled = observation({ review: handledReview }); + expect( + decideWaitForPr(handled, observation({ review: handledReview, unresolvedReviewThreads: 2 })), + ).toMatchObject({ kind: "wake", reason: "review-unresolved" }); + }); + + it("returns ready after CI succeeds with a previously handled review", () => { + const baseline = observation({ review: handledReview }); + expect( + decideWaitForPr(baseline, observation({ ci: "success", review: handledReview })), + ).toMatchObject({ kind: "wake", reason: "ready" }); + }); + + it("waits for definitive mergeability before reporting ready", () => { + const baseline = observation({ review: handledReview }); + expect( + decideWaitForPr( + baseline, + observation({ ci: "success", review: handledReview, mergeable: "UNKNOWN" }), + ), + ).toEqual({ kind: "wait", reason: "mergeability-pending" }); + expect( + decideWaitForPr( + baseline, + observation({ ci: "success", review: handledReview, mergeStateStatus: "UNKNOWN" }), + ), + ).toEqual({ kind: "wait", reason: "mergeability-pending" }); + }); + + it("wakes when the exact head or base drifts", () => { + const baseline = observation(); + expect(decideWaitForPr(baseline, observation({ head: "2".repeat(40) }))).toMatchObject({ + kind: "wake", + reason: "head-changed", + }); + expect(decideWaitForPr(baseline, observation({ base: "3".repeat(40) }))).toMatchObject({ + kind: "wake", + reason: "base-changed", + }); + }); + + it("wakes for blocked mergeability without treating ordinary BLOCKED status as a conflict", () => { + const baseline = observation({ mergeStateStatus: "BLOCKED" }); + expect(decideWaitForPr(baseline, observation({ mergeStateStatus: "BLOCKED" }))).toEqual({ + kind: "wait", + reason: "review-pending", + }); + expect(decideWaitForPr(baseline, observation({ mergeable: "CONFLICTING" }))).toMatchObject({ + kind: "wake", + reason: "merge-blocked", + }); + expect(decideWaitForPr(baseline, observation({ mergeStateStatus: "BEHIND" }))).toMatchObject({ + kind: "wake", + reason: "merge-blocked", + }); + expect( + decideWaitForPr( + observation({ review: handledReview, mergeStateStatus: "BLOCKED" }), + observation({ + ci: "success", + review: handledReview, + mergeStateStatus: "BLOCKED", + }), + ), + ).toMatchObject({ kind: "wake", reason: "merge-blocked" }); + }); + + it("keeps eyes pending and accepts thumbs-up on an exact-head request", () => { + const issueComments = [ + { + id: 10, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: reviewRequest(), + created_at: "2026-08-24T10:00:00Z", + }, + ]; + const pending = deriveReviewState({ + headSha: HEAD, + formalReviews: [], + issueComments, + reviewComments: [], + latestTriggerReactions: [ + { + id: 11, + user: { login: "chatgpt-codex-connector[bot]" }, + content: "eyes", + created_at: "2026-08-24T10:01:00Z", + }, + ], + }); + expect(pending).toMatchObject({ requestPresent: true, pending: true }); + + const thumbsUp = deriveReviewState({ + headSha: HEAD, + formalReviews: [], + issueComments, + reviewComments: [], + latestTriggerReactions: [ + { + id: 12, + user: { login: "chatgpt-codex-connector[bot]" }, + content: "+1", + created_at: "2026-08-24T10:00:00Z", + }, + ], + }); + expect(thumbsUp).toMatchObject({ requestPresent: true, pending: false, ready: true }); + expect(thumbsUp.terminalArtifacts).toEqual([ + { key: "reaction:12", observedAt: "2026-08-24T10:00:00Z" }, + ]); + + const restarted = deriveReviewState({ + headSha: HEAD, + formalReviews: [], + issueComments, + reviewComments: [], + latestTriggerReactions: [ + { + id: 12, + user: { login: "chatgpt-codex-connector[bot]" }, + content: "+1", + created_at: "2026-08-24T10:01:00Z", + }, + { + id: 13, + user: { login: "chatgpt-codex-connector[bot]" }, + content: "eyes", + created_at: "2026-08-24T10:01:00Z", + }, + ], + }); + expect(restarted).toMatchObject({ requestPresent: true, pending: true }); + }); + + it("selects the newer exact-head request when GitHub timestamps tie", () => { + const latest = latestCodexReviewTrigger( + [ + { + id: 10, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: reviewRequest(), + created_at: "2026-08-24T10:00:00Z", + }, + { + id: 11, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: reviewRequest(), + created_at: "2026-08-24T10:00:00Z", + }, + ], + HEAD, + ); + expect(latest?.id).toBe(11); + }); + + it("ignores exact-head review triggers from untrusted commenters", () => { + const latest = latestCodexReviewTrigger( + [ + { + id: 10, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: reviewRequest(), + created_at: "2026-08-24T10:00:00Z", + }, + { + id: 11, + user: { login: "outsider" }, + author_association: "CONTRIBUTOR", + body: reviewRequest(), + created_at: "2026-08-24T10:05:00Z", + }, + ], + HEAD, + ); + expect(latest?.id).toBe(10); + }); + + it("keeps same-timestamp terminal evidence pending unless it is a matched reaction", () => { + const review = deriveReviewState({ + headSha: HEAD, + formalReviews: [ + { + id: 20, + user: { login: "chatgpt-codex-connector[bot]" }, + state: "APPROVED", + commit_id: HEAD, + submitted_at: "2026-08-24T10:00:00Z", + }, + ], + issueComments: [ + { + id: 21, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: reviewRequest(), + created_at: "2026-08-24T10:00:00Z", + }, + ], + reviewComments: [], + latestTriggerReactions: [], + }); + expect(review).toMatchObject({ requestPresent: true, pending: true }); + }); + + it("does not treat a generic formal review wrapper as clean", () => { + const review = deriveReviewState({ + headSha: HEAD, + formalReviews: [ + { + id: 23, + user: { login: "chatgpt-codex-connector[bot]" }, + state: "COMMENTED", + commit_id: HEAD, + submitted_at: "2026-08-24T10:03:00Z", + body: `### 💡 Codex Review\n\nHere are some automated review suggestions for this pull request.\n\n**Reviewed commit:** \`${HEAD.slice(0, 10)}\``, + }, + ], + issueComments: [ + { + id: 24, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: reviewRequest(), + created_at: "2026-08-24T10:00:00Z", + }, + ], + reviewComments: [], + latestTriggerReactions: [], + }); + expect(review).toMatchObject({ requestPresent: true, pending: true }); + expect(review.terminalArtifacts).toEqual([]); + }); + + it("records a body-only formal finding until a maintainer handles it", () => { + const formalReviews = [ + { + id: 25, + user: { login: "chatgpt-codex-connector[bot]" }, + state: "COMMENTED", + commit_id: HEAD, + submitted_at: "2026-08-24T10:03:00Z", + body: "The retry path can report success before the replacement run finishes.", + }, + ]; + const issueComments = [ + { + id: 26, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: reviewRequest(), + created_at: "2026-08-24T10:00:00Z", + }, + ]; + const finding = deriveReviewState({ + headSha: HEAD, + formalReviews, + issueComments, + reviewComments: [], + latestTriggerReactions: [], + }); + expect(finding).toMatchObject({ pending: false, ready: false }); + expect(finding.terminalArtifacts).toEqual([ + { key: "review:25", observedAt: "2026-08-24T10:03:00Z" }, + ]); + + const handled = deriveReviewState({ + headSha: HEAD, + formalReviews, + issueComments: [ + ...issueComments, + { + id: 27, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: ``, + created_at: "2026-08-24T10:04:00Z", + }, + ], + reviewComments: [], + latestTriggerReactions: [], + }); + expect(handled).toMatchObject({ pending: false, ready: true }); + }); + + it("records a bodyless changes-requested verdict as an unhandled artifact", () => { + const review = deriveReviewState({ + headSha: HEAD, + formalReviews: [ + { + id: 28, + user: { login: "chatgpt-codex-connector[bot]" }, + state: "CHANGES_REQUESTED", + commit_id: HEAD, + submitted_at: "2026-08-24T10:03:00Z", + body: "", + }, + ], + issueComments: [ + { + id: 29, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: reviewRequest(), + created_at: "2026-08-24T10:00:00Z", + }, + ], + reviewComments: [], + latestTriggerReactions: [], + }); + expect(review).toMatchObject({ requestPresent: true, pending: false, ready: false }); + expect(review.terminalArtifacts).toEqual([ + { key: "review:28", observedAt: "2026-08-24T10:03:00Z" }, + ]); + }); + + it("accepts exact-head formal, inline, and typographic clean-comment artifacts", () => { + const review = deriveReviewState({ + headSha: HEAD, + formalReviews: [ + { + id: 20, + user: { login: "chatgpt-codex-connector[bot]" }, + state: "APPROVED", + commit_id: HEAD, + submitted_at: "2026-08-24T10:03:00Z", + }, + ], + issueComments: [ + { + id: 21, + user: { login: "chatgpt-codex-connector[bot]" }, + body: `Codex Review: Didn’t find any major issues. Surprise wording! **Reviewed commit:** \`${HEAD.slice(0, 10)}\``, + created_at: "2026-08-24T10:04:00Z", + }, + ], + reviewComments: [ + { + id: 22, + user: { login: "chatgpt-codex-connector[bot]" }, + commit_id: HEAD, + created_at: "2026-08-24T10:03:30Z", + }, + ], + latestTriggerReactions: [], + }); + expect(review).toMatchObject({ pending: false, ready: true }); + expect(review.terminalArtifacts.map(({ key }) => key)).toEqual([ + "review:20", + "review-comment:22", + "comment:21", + ]); + }); + + it("wakes for a current-head top-level finding without treating it as prehandled", () => { + const issueComments = [ + { + id: 30, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: reviewRequest(), + created_at: "2026-08-24T10:00:00Z", + }, + { + id: 31, + user: { login: "chatgpt-codex-connector[bot]" }, + body: `Codex Review: I found something worth addressing. **Reviewed commit:** \`${HEAD.slice(0, 10)}\``, + created_at: "2026-08-24T10:05:00Z", + }, + ]; + const review = deriveReviewState({ + headSha: HEAD, + formalReviews: [], + issueComments, + reviewComments: [], + latestTriggerReactions: [], + }); + expect(review).toMatchObject({ requestPresent: true, pending: false, ready: false }); + expect(review.terminalArtifacts).toEqual([ + { key: "comment:31", observedAt: "2026-08-24T10:05:00Z" }, + ]); + + const baseline = observation(); + expect(decideWaitForPr(baseline, observation({ ci: "success", review }))).toMatchObject({ + kind: "wake", + reason: "review-completed", + }); + + expect(decideWaitForPr(observation({ review }), observation({ review }))).toMatchObject({ + kind: "wake", + reason: "review-unhandled", + }); + + const handled = deriveReviewState({ + headSha: HEAD, + formalReviews: [], + issueComments: [ + ...issueComments, + { + id: 32, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: ``, + created_at: "2026-08-24T10:06:00Z", + }, + ], + reviewComments: [], + latestTriggerReactions: [], + }); + expect(handled).toMatchObject({ pending: false, ready: true }); + expect( + decideWaitForPr( + observation({ review: handled }), + observation({ ci: "success", review: handled }), + ), + ).toMatchObject({ kind: "wake", reason: "ready" }); + + const partlyHandled = deriveReviewState({ + headSha: HEAD, + formalReviews: [], + issueComments: [ + ...issueComments, + { + id: 32, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: ``, + created_at: "2026-08-24T10:06:00Z", + }, + { + id: 33, + user: { login: "chatgpt-codex-connector[bot]" }, + body: `Codex Review: Another finding. **Reviewed commit:** \`${HEAD.slice(0, 10)}\``, + created_at: "2026-08-24T10:07:00Z", + }, + ], + reviewComments: [], + latestTriggerReactions: [], + }); + expect(partlyHandled).toMatchObject({ pending: false, ready: false }); + + const outsiderMarker = deriveReviewState({ + headSha: HEAD, + formalReviews: [], + issueComments: [ + ...issueComments, + { + id: 32, + user: { login: "untrusted-contributor" }, + author_association: "CONTRIBUTOR", + body: ``, + created_at: "2026-08-24T10:06:00Z", + }, + ], + reviewComments: [], + latestTriggerReactions: [], + }); + expect(outsiderMarker).toMatchObject({ pending: false, ready: false }); + }); + + it("does not treat a plain or older-head review request as current", () => { + const review = deriveReviewState({ + headSha: HEAD, + formalReviews: [], + issueComments: [ + { + id: 40, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: "@codex review", + created_at: "2026-08-24T10:00:00Z", + }, + { + id: 41, + user: { login: "lastobelus" }, + author_association: "OWNER", + body: reviewRequest("abcdef1234567890abcdef1234567890abcdef12"), + created_at: "2026-08-24T10:02:00Z", + }, + ], + reviewComments: [], + latestTriggerReactions: [ + { + id: 42, + user: { login: "chatgpt-codex-connector[bot]" }, + content: "+1", + created_at: "2026-08-24T10:03:00Z", + }, + ], + }); + expect(review).toMatchObject({ requestPresent: false, pending: false }); + expect(review.latestTriggerId).toBeNull(); + expect(review.terminalArtifacts).toEqual([]); + }); + + it("classifies check runs and status contexts without accepting cancellations", () => { + expect(classifyStatusChecks([])).toBe("pending"); + expect( + classifyStatusChecks([{ status: "COMPLETED", conclusion: "SUCCESS" }, { state: "SUCCESS" }]), + ).toBe("success"); + expect( + classifyStatusChecks([ + { status: "COMPLETED", conclusion: "SUCCESS" }, + { status: "IN_PROGRESS", conclusion: null }, + ]), + ).toBe("pending"); + expect( + classifyStatusChecks([ + { status: "COMPLETED", conclusion: "CANCELLED" }, + { status: "IN_PROGRESS", conclusion: null }, + ]), + ).toBe("failure"); + }); + + it("classifies only the newest run when a failed check is rerun", () => { + expect( + classifyStatusChecks([ + { + name: "build", + workflowName: "CI", + status: "COMPLETED", + conclusion: "FAILURE", + startedAt: "2026-08-24T10:00:00Z", + completedAt: "2026-08-24T10:01:00Z", + }, + { + name: "build", + workflowName: "CI", + status: "IN_PROGRESS", + conclusion: null, + startedAt: "2026-08-24T10:02:00Z", + completedAt: "0001-01-01T00:00:00Z", + }, + ]), + ).toBe("pending"); + }); + + it("keeps same-named checks from distinct kinds and providers", () => { + expect( + classifyStatusChecks([ + { + __typename: "StatusContext", + context: "build", + state: "FAILURE", + }, + { + __typename: "CheckRun", + name: "build", + status: "COMPLETED", + conclusion: "SUCCESS", + startedAt: "2026-08-24T10:00:00Z", + }, + ]), + ).toBe("failure"); + expect( + classifyStatusChecks([ + { + __typename: "CheckRun", + name: "verify", + detailsUrl: "https://checks.example-a.com/runs/1", + status: "COMPLETED", + conclusion: "FAILURE", + }, + { + __typename: "CheckRun", + name: "verify", + detailsUrl: "https://checks.example-b.com/runs/2", + status: "COMPLETED", + conclusion: "SUCCESS", + }, + ]), + ).toBe("failure"); + }); +}); diff --git a/scripts/lastcode-wait-for-pr.ts b/scripts/lastcode-wait-for-pr.ts new file mode 100644 index 00000000000..a30c22ad955 --- /dev/null +++ b/scripts/lastcode-wait-for-pr.ts @@ -0,0 +1,757 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalTimers:off -- Read-only host-side GitHub polling. +import * as NodeChildProcess from "node:child_process"; + +const LASTCODE_GITHUB_REPOSITORY = process.env.LASTCODE_GITHUB_REPOSITORY ?? "lastobelus/lastCode"; +const LASTCODE_BASE_BRANCH = "lastcode/main"; +const CODEX_BOT_LOGIN = "chatgpt-codex-connector[bot]"; +const TRUSTED_AUTHOR_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); +const POLL_INTERVAL_MS = 60_000; +const GH_TIMEOUT_MS = 30_000; + +type PullRequestState = { + readonly number: number; + readonly url: string; + readonly state: string; + readonly isDraft: boolean; + readonly headRefOid: string; + readonly baseRefOid: string; + readonly baseRefName: string; + readonly mergeable: string; + readonly mergeStateStatus: string; + readonly statusCheckRollup: ReadonlyArray | null; +}; + +type StatusCheck = { + readonly __typename?: string; + readonly name?: string; + readonly context?: string; + readonly workflowName?: string | null; + readonly detailsUrl?: string | null; + readonly startedAt?: string | null; + readonly completedAt?: string | null; + readonly status?: string; + readonly conclusion?: string | null; + readonly state?: string; +}; + +type GitHubActor = { + readonly login?: string; +}; + +type FormalReview = { + readonly id: number; + readonly user?: GitHubActor; + readonly body?: string; + readonly state?: string; + readonly commit_id?: string | null; + readonly submitted_at?: string | null; +}; + +type IssueComment = { + readonly id: number; + readonly user?: GitHubActor; + readonly author_association?: string; + readonly body?: string; + readonly created_at?: string; +}; + +type ReviewComment = { + readonly id: number; + readonly user?: GitHubActor; + readonly commit_id?: string | null; + readonly created_at?: string; +}; + +type CommentReaction = { + readonly id: number; + readonly user?: GitHubActor; + readonly content?: string; + readonly created_at?: string; +}; + +export type CiState = "pending" | "success" | "failure"; + +export interface ReviewArtifact { + readonly key: string; + readonly observedAt: string; +} + +export interface ReviewState { + readonly terminalArtifacts: ReadonlyArray; + readonly requestPresent: boolean; + readonly pending: boolean; + readonly ready: boolean; + readonly latestTriggerId: number | null; +} + +export interface WaitObservation { + readonly pullRequest: PullRequestState; + readonly ci: CiState; + readonly review: ReviewState; + readonly unresolvedReviewThreads: number; +} + +export type WaitDecision = + | { + readonly kind: "wait"; + readonly reason: "ci-pending" | "mergeability-pending" | "review-pending"; + } + | { + readonly kind: "wake"; + readonly reason: + | "base-changed" + | "ci-failed" + | "head-changed" + | "merge-blocked" + | "pr-closed" + | "pr-draft" + | "ready" + | "review-completed" + | "review-not-requested" + | "review-unhandled" + | "review-unresolved" + | "unexpected-base"; + readonly detail: string; + }; + +const successfulCheckConclusions = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]); + +const checkProvider = (detailsUrl: string | null | undefined): string | null => { + if (!detailsUrl) return null; + try { + const url = new URL(detailsUrl); + const pathIdentity = url.pathname.split("/").filter(Boolean).slice(0, 2).join("/"); + return `${url.origin}/${pathIdentity}`; + } catch { + return null; + } +}; + +const checkIdentity = (check: StatusCheck, index: number): string => { + const kind = check.__typename ?? (check.context ? "StatusContext" : "CheckRun"); + const name = check.name ?? check.context; + if (!name) return `nameless\u0000${index}`; + if (kind === "StatusContext") return `${kind}\u0000${name}`; + if (check.workflowName) return `${kind}\u0000${check.workflowName}\u0000${name}`; + const provider = checkProvider(check.detailsUrl); + return provider ? `${kind}\u0000${provider}\u0000${name}` : `${kind}\u0000${name}\u0000${index}`; +}; + +const checkTimestamp = (check: StatusCheck): number | null => { + for (const value of [check.completedAt, check.startedAt]) { + if (value) { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + } + return null; +}; + +const latestStatusChecks = (checks: ReadonlyArray): ReadonlyArray => { + const newestByIdentity = new Map< + string, + { readonly check: StatusCheck; readonly at: number | null } + >(); + for (const [index, check] of checks.entries()) { + const identity = checkIdentity(check, index); + const candidate = { check, at: checkTimestamp(check) }; + const kept = newestByIdentity.get(identity); + if ( + kept === undefined || + (candidate.at === null ? kept.at === null : kept.at === null || candidate.at >= kept.at) + ) { + newestByIdentity.set(identity, candidate); + } + } + return [...newestByIdentity.values()].map(({ check }) => check); +}; + +export function classifyStatusChecks(checks: PullRequestState["statusCheckRollup"]): CiState { + if (!checks || checks.length === 0) return "pending"; + + let pending = false; + for (const check of latestStatusChecks(checks)) { + if (check.status !== undefined) { + if (check.status !== "COMPLETED") { + pending = true; + continue; + } + if (!check.conclusion || !successfulCheckConclusions.has(check.conclusion)) { + return "failure"; + } + continue; + } + + if (check.state === "SUCCESS") continue; + if (check.state === "PENDING" || check.state === "EXPECTED" || check.state === undefined) { + pending = true; + continue; + } + return "failure"; + } + return pending ? "pending" : "success"; +} + +export function pullRequestViewArgs(repository: string, branch: string): ReadonlyArray { + if (branch.length === 0) { + throw new Error("Wait for PR requires a checked-out branch."); + } + return [ + "pr", + "view", + branch, + "--repo", + repository, + "--json", + "number,url,state,isDraft,headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,statusCheckRollup", + ]; +} + +export function samePullRequestRevision( + initial: Pick, + final: Pick, +): boolean { + return initial.headRefOid === final.headRefOid && initial.baseRefOid === final.baseRefOid; +} + +const reviewThreadsQuery = `query($owner:String!,$name:String!,$number:Int!,$endCursor:String){ + repository(owner:$owner,name:$name){ + pullRequest(number:$number){ + reviewThreads(first:100,after:$endCursor){ + nodes{id isResolved} + pageInfo{hasNextPage endCursor} + } + } + } +}`; + +export function reviewThreadsArgs( + repository: string, + pullRequestNumber: number, +): ReadonlyArray { + const [owner, name, ...rest] = repository.split("/"); + if (!owner || !name || rest.length > 0) + throw new Error(`Invalid GitHub repository: ${repository}`); + return [ + "api", + "graphql", + "--paginate", + "--slurp", + "-F", + `owner=${owner}`, + "-F", + `name=${name}`, + "-F", + `number=${pullRequestNumber}`, + "-f", + `query=${reviewThreadsQuery}`, + ]; +} + +const timestamp = (value: string | null | undefined): number => { + const parsed = value === null || value === undefined ? Number.NaN : Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const currentHeadMatches = (candidate: string | null | undefined, headSha: string): boolean => + typeof candidate === "string" && candidate.length >= 7 && headSha.startsWith(candidate); + +const reviewedCommitFromBody = (body: string | undefined): string | null => { + if (!body?.startsWith("Codex Review:")) return null; + return /\*\*Reviewed commit:\*\*\s*`([0-9a-f]{7,40})`/iu.exec(body)?.[1] ?? null; +}; + +const cleanReviewedCommitFromBody = (body: string | undefined): string | null => { + if (!/^Codex Review: Didn['’]t find any major issues\./u.test(body ?? "")) return null; + return reviewedCommitFromBody(body); +}; + +const isGenericFormalReviewWrapper = (body: string | undefined): boolean => + (body ?? "").includes("### 💡 Codex Review") && + (body ?? "").includes("Here are some automated review suggestions for this pull request."); + +const requestedHeadFromBody = (body: string | undefined): string | null => + /^@codex review\s*\n\s*$/iu.exec(body ?? "")?.[1] ?? + null; + +const handledArtifactFromBody = (body: string | undefined, headSha: string): string | null => { + const match = + /^$/iu.exec( + body ?? "", + ); + return match?.[2] === headSha ? (match[1] ?? null) : null; +}; + +export function latestCodexReviewTrigger( + comments: ReadonlyArray, + headSha: string, +): IssueComment | null { + return ( + comments + .filter( + (comment) => + comment.user?.login !== CODEX_BOT_LOGIN && + TRUSTED_AUTHOR_ASSOCIATIONS.has(comment.author_association ?? "") && + currentHeadMatches(requestedHeadFromBody(comment.body), headSha), + ) + .sort( + (left, right) => + timestamp(right.created_at) - timestamp(left.created_at) || right.id - left.id, + )[0] ?? null + ); +} + +export function deriveReviewState(input: { + readonly headSha: string; + readonly formalReviews: ReadonlyArray; + readonly issueComments: ReadonlyArray; + readonly reviewComments: ReadonlyArray; + readonly latestTriggerReactions: ReadonlyArray; +}): ReviewState { + const artifacts: ReviewArtifact[] = []; + const readyArtifacts = new Set(); + + for (const review of input.formalReviews) { + const machineReadableCleanCommit = cleanReviewedCommitFromBody(review.body); + const isClean = + review.state === "APPROVED" || currentHeadMatches(machineReadableCleanCommit, input.headSha); + const isFindingVerdict = review.state === "CHANGES_REQUESTED"; + if ( + review.user?.login === CODEX_BOT_LOGIN && + review.state !== "PENDING" && + currentHeadMatches(review.commit_id, input.headSha) && + (isClean || + isFindingVerdict || + (Boolean(review.body?.trim()) && !isGenericFormalReviewWrapper(review.body))) + ) { + const key = `review:${review.id}`; + artifacts.push({ + key, + observedAt: review.submitted_at ?? "", + }); + if (isClean) readyArtifacts.add(key); + } + } + + for (const comment of input.reviewComments) { + if ( + comment.user?.login === CODEX_BOT_LOGIN && + currentHeadMatches(comment.commit_id, input.headSha) + ) { + const key = `review-comment:${comment.id}`; + artifacts.push({ + key, + observedAt: comment.created_at ?? "", + }); + readyArtifacts.add(key); + } + } + + for (const comment of input.issueComments) { + const reviewedCommit = reviewedCommitFromBody(comment.body); + if ( + comment.user?.login === CODEX_BOT_LOGIN && + currentHeadMatches(reviewedCommit, input.headSha) + ) { + const key = `comment:${comment.id}`; + artifacts.push({ + key, + observedAt: comment.created_at ?? "", + }); + if (currentHeadMatches(cleanReviewedCommitFromBody(comment.body), input.headSha)) { + readyArtifacts.add(key); + } + } + } + + const latestTrigger = latestCodexReviewTrigger(input.issueComments, input.headSha); + const relevantReactions = latestTrigger + ? input.latestTriggerReactions.filter((reaction) => reaction.user?.login === CODEX_BOT_LOGIN) + : []; + + for (const reaction of relevantReactions) { + if (reaction.content === "+1") { + const key = `reaction:${reaction.id}`; + artifacts.push({ + key, + observedAt: reaction.created_at ?? "", + }); + readyArtifacts.add(key); + } + } + + const artifactKeys = new Set(artifacts.map(({ key }) => key)); + for (const comment of input.issueComments) { + if (!TRUSTED_AUTHOR_ASSOCIATIONS.has(comment.author_association ?? "")) continue; + const handledArtifact = handledArtifactFromBody(comment.body, input.headSha); + if (handledArtifact && artifactKeys.has(handledArtifact)) readyArtifacts.add(handledArtifact); + } + + const newestReaction = (content: string): CommentReaction | null => + relevantReactions + .filter((reaction) => reaction.content === content) + .sort( + (left, right) => + timestamp(right.created_at) - timestamp(left.created_at) || right.id - left.id, + )[0] ?? null; + const latestCleanReaction = newestReaction("+1"); + const latestEyesReaction = newestReaction("eyes"); + const matchedCleanReaction = + latestCleanReaction !== null && + (latestEyesReaction === null || + timestamp(latestCleanReaction.created_at) > timestamp(latestEyesReaction.created_at) || + (timestamp(latestCleanReaction.created_at) === timestamp(latestEyesReaction.created_at) && + latestCleanReaction.id >= latestEyesReaction.id)); + const latestTerminalAt = Math.max(0, ...artifacts.map(({ observedAt }) => timestamp(observedAt))); + const latestPendingAt = Math.max( + timestamp(latestTrigger?.created_at), + ...relevantReactions + .filter(({ content }) => content === "eyes") + .map(({ created_at }) => timestamp(created_at)), + ); + const requestPresent = latestTrigger !== null || artifacts.length > 0; + + return { + terminalArtifacts: artifacts, + requestPresent, + pending: requestPresent && !matchedCleanReaction && latestTerminalAt <= latestPendingAt, + ready: artifacts.length > 0 && artifacts.every(({ key }) => readyArtifacts.has(key)), + latestTriggerId: latestTrigger?.id ?? null, + }; +} + +export function decideWaitForPr(baseline: WaitObservation, current: WaitObservation): WaitDecision { + const pullRequest = current.pullRequest; + if (pullRequest.state !== "OPEN") { + return { + kind: "wake", + reason: "pr-closed", + detail: `Pull request #${pullRequest.number} is ${pullRequest.state.toLowerCase()}.`, + }; + } + if (pullRequest.isDraft) { + return { + kind: "wake", + reason: "pr-draft", + detail: `Pull request #${pullRequest.number} is still a draft.`, + }; + } + if (pullRequest.baseRefName !== LASTCODE_BASE_BRANCH) { + return { + kind: "wake", + reason: "unexpected-base", + detail: `Pull request #${pullRequest.number} targets ${pullRequest.baseRefName}, not ${LASTCODE_BASE_BRANCH}.`, + }; + } + if (pullRequest.headRefOid !== baseline.pullRequest.headRefOid) { + return { + kind: "wake", + reason: "head-changed", + detail: `PR head changed from ${baseline.pullRequest.headRefOid} to ${pullRequest.headRefOid}.`, + }; + } + if (pullRequest.baseRefOid !== baseline.pullRequest.baseRefOid) { + return { + kind: "wake", + reason: "base-changed", + detail: `PR base changed from ${baseline.pullRequest.baseRefOid} to ${pullRequest.baseRefOid}.`, + }; + } + if ( + pullRequest.mergeable === "CONFLICTING" || + pullRequest.mergeStateStatus === "BEHIND" || + pullRequest.mergeStateStatus === "DIRTY" + ) { + return { + kind: "wake", + reason: "merge-blocked", + detail: `Pull request #${pullRequest.number} needs attention (${pullRequest.mergeStateStatus}).`, + }; + } + + const baselineArtifacts = new Set(baseline.review.terminalArtifacts.map(({ key }) => key)); + const newArtifacts = current.review.terminalArtifacts.filter( + ({ key }) => !baselineArtifacts.has(key), + ); + if (newArtifacts.length > 0) { + return { + kind: "wake", + reason: "review-completed", + detail: `Codex delivered ${newArtifacts.length} new current-head review artifact${newArtifacts.length === 1 ? "" : "s"}.`, + }; + } + if (current.ci === "failure") { + return { + kind: "wake", + reason: "ci-failed", + detail: `Current-head CI for pull request #${pullRequest.number} needs attention.`, + }; + } + if (!current.review.requestPresent) { + return { + kind: "wake", + reason: "review-not-requested", + detail: `No current-head Codex review request or terminal result was found for pull request #${pullRequest.number}.`, + }; + } + if (current.unresolvedReviewThreads > 0) { + return { + kind: "wake", + reason: "review-unresolved", + detail: `Pull request #${pullRequest.number} has ${current.unresolvedReviewThreads} unresolved review thread${current.unresolvedReviewThreads === 1 ? "" : "s"}.`, + }; + } + if (!current.review.pending && !current.review.ready) { + return { + kind: "wake", + reason: "review-unhandled", + detail: `Pull request #${pullRequest.number} has an unhandled top-level Codex finding.`, + }; + } + if (pullRequest.mergeable === "UNKNOWN" || pullRequest.mergeStateStatus === "UNKNOWN") { + return { kind: "wait", reason: "mergeability-pending" }; + } + if ( + current.ci === "success" && + !current.review.pending && + current.review.ready && + pullRequest.mergeStateStatus === "BLOCKED" + ) { + return { + kind: "wake", + reason: "merge-blocked", + detail: `Pull request #${pullRequest.number} is blocked by a repository merge requirement.`, + }; + } + if (current.ci === "success" && !current.review.pending && current.review.ready) { + return { + kind: "wake", + reason: "ready", + detail: `GitHub CI and the handled Codex review are complete for pull request #${pullRequest.number}.`, + }; + } + return current.review.pending + ? { kind: "wait", reason: "review-pending" } + : { kind: "wait", reason: "ci-pending" }; +} + +function runGhJson(args: ReadonlyArray): T { + const result = NodeChildProcess.spawnSync("gh", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: GH_TIMEOUT_MS, + maxBuffer: 16 * 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `gh ${args.join(" ")} failed.`); + } + return JSON.parse(result.stdout) as T; +} + +function paginatedGhApi(endpoint: string): ReadonlyArray { + const pages = runGhJson>>([ + "api", + "--paginate", + "--slurp", + endpoint, + ]); + return pages.flat(); +} + +type ReviewThreadsPage = { + readonly data?: { + readonly repository?: { + readonly pullRequest?: { + readonly reviewThreads?: { + readonly nodes?: ReadonlyArray<{ readonly id?: string; readonly isResolved?: boolean }>; + }; + }; + }; + }; +}; + +function reviewThreadsSnapshot( + repository: string, + pullRequestNumber: number, +): { readonly unresolvedCount: number; readonly fingerprint: string } { + const pages = runGhJson>( + reviewThreadsArgs(repository, pullRequestNumber), + ); + return { + unresolvedCount: pages.reduce( + (count, page) => + count + + (page.data?.repository?.pullRequest?.reviewThreads?.nodes ?? []).filter( + ({ isResolved }) => isResolved === false, + ).length, + 0, + ), + fingerprint: JSON.stringify(pages), + }; +} + +function currentBranch(): string { + const result = NodeChildProcess.spawnSync("git", ["branch", "--show-current"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: GH_TIMEOUT_MS, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(result.stderr.trim() || "Failed to resolve the current Git branch."); + } + const branch = result.stdout.trim(); + if (branch.length === 0) throw new Error("Wait for PR requires a checked-out branch."); + return branch; +} + +type ReviewDataSnapshot = { + readonly review: ReviewState; + readonly unresolvedReviewThreads: number; + readonly fingerprint: string; +}; + +function readReviewData(repository: string, pullRequest: PullRequestState): ReviewDataSnapshot { + const issueComments = paginatedGhApi( + `repos/${repository}/issues/${pullRequest.number}/comments?per_page=100`, + ); + const latestTrigger = latestCodexReviewTrigger(issueComments, pullRequest.headRefOid); + const latestTriggerReactions = latestTrigger + ? paginatedGhApi( + `repos/${repository}/issues/comments/${latestTrigger.id}/reactions?per_page=100`, + ) + : []; + const formalReviews = paginatedGhApi( + `repos/${repository}/pulls/${pullRequest.number}/reviews?per_page=100`, + ); + const reviewComments = paginatedGhApi( + `repos/${repository}/pulls/${pullRequest.number}/comments?per_page=100`, + ); + const reviewThreads = reviewThreadsSnapshot(repository, pullRequest.number); + return { + review: deriveReviewState({ + headSha: pullRequest.headRefOid, + formalReviews, + issueComments, + reviewComments, + latestTriggerReactions, + }), + unresolvedReviewThreads: reviewThreads.unresolvedCount, + fingerprint: JSON.stringify({ + issueComments, + latestTriggerReactions, + formalReviews, + reviewComments, + reviewThreads: reviewThreads.fingerprint, + }), + }; +} + +export function requiresReadyConfirmation(observation: WaitObservation): boolean { + return ( + observation.ci === "success" && + !observation.review.pending && + observation.review.ready && + observation.unresolvedReviewThreads === 0 + ); +} + +const observationFrom = ( + pullRequest: PullRequestState, + reviewData: ReviewDataSnapshot, +): WaitObservation => ({ + pullRequest, + ci: classifyStatusChecks(pullRequest.statusCheckRollup), + review: reviewData.review, + unresolvedReviewThreads: reviewData.unresolvedReviewThreads, +}); + +function readObservation(repository: string, branch: string): WaitObservation { + while (true) { + const initialPullRequest = runGhJson(pullRequestViewArgs(repository, branch)); + const initialReviewData = readReviewData(repository, initialPullRequest); + const pullRequest = runGhJson(pullRequestViewArgs(repository, branch)); + if (!samePullRequestRevision(initialPullRequest, pullRequest)) continue; + + const observation = observationFrom(pullRequest, initialReviewData); + if (!requiresReadyConfirmation(observation)) return observation; + + const confirmedReviewData = readReviewData(repository, pullRequest); + const confirmedPullRequest = runGhJson( + pullRequestViewArgs(repository, branch), + ); + if ( + !samePullRequestRevision(pullRequest, confirmedPullRequest) || + initialReviewData.fingerprint !== confirmedReviewData.fingerprint + ) { + continue; + } + return observationFrom(confirmedPullRequest, confirmedReviewData); + } +} + +const summary = (observation: WaitObservation): string => + JSON.stringify({ + pr: observation.pullRequest.number, + head: observation.pullRequest.headRefOid, + base: observation.pullRequest.baseRefOid, + ci: observation.ci, + review: observation.review.pending + ? "pending" + : observation.review.ready + ? "completed" + : observation.review.terminalArtifacts.length > 0 + ? "unhandled" + : "missing", + unresolvedReviewThreads: observation.unresolvedReviewThreads, + }); + +const sleep = (durationMs: number): Promise => + new Promise((resolve) => setTimeout(resolve, durationMs)); + +async function main(): Promise { + const branch = currentBranch(); + const baseline = readObservation(LASTCODE_GITHUB_REPOSITORY, branch); + console.log(`[wait-for-pr] Baseline ${summary(baseline)}`); + + let previousSummary = ""; + let current = baseline; + while (true) { + const decision = decideWaitForPr(baseline, current); + if (decision.kind === "wake") { + console.log( + `[wait-for-pr] Result ${JSON.stringify({ + reason: decision.reason, + detail: decision.detail, + pr: current.pullRequest.number, + url: current.pullRequest.url, + head: current.pullRequest.headRefOid, + base: current.pullRequest.baseRefOid, + ci: current.ci, + reviewPending: current.review.pending, + reviewReady: current.review.ready, + reviewArtifacts: current.review.terminalArtifacts.map(({ key }) => key), + })}`, + ); + return; + } + + const currentSummary = summary(current); + if (currentSummary !== previousSummary) { + console.log(`[wait-for-pr] Waiting (${decision.reason}) ${currentSummary}`); + previousSummary = currentSummary; + } + await sleep(POLL_INTERVAL_MS); + current = readObservation(LASTCODE_GITHUB_REPOSITORY, branch); + } +} + +if (import.meta.main) { + main().catch((error: unknown) => { + console.error(`[wait-for-pr] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/t3.json b/t3.json index bd289654d15..7c647bc8cda 100644 --- a/t3.json +++ b/t3.json @@ -7,6 +7,11 @@ "command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env && node apps/web/scripts/warm-dep-cache.ts", "icon": "configure", "runOnWorktreeCreate": true + }, + { + "name": "Wait for PR", + "command": "mise exec node@24.13.1 -- node scripts/lastcode-wait-for-pr.ts", + "icon": "test" } ] }