| author | |
| committer | |
| log | 050690d1bc048c1f94096c17a1c9231c3ad61a83 |
| tree | e171e7b2c3cc369c45f72142027b34f34ba5f0b3 |
| parent | bd16b86d50c1df49afeb7c0a7568a4908ade4048 |
| signature | Signed by PGP key B5690EEEBB952194 |
30 files changed, 197 insertions(+), 48 deletions(-)
apps/server/src/git/GitManager.test.ts+15-4| ... | ... | @@ -511,7 +511,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 511 | 511 | "--limit", |
| 512 | 512 | String(input.limit ?? 1), |
| 513 | 513 | "--json", |
| 514 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 514 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 515 | 515 | ], |
| 516 | 516 | }).pipe( |
| 517 | 517 | Effect.map((result) => JSON.parse(result.stdout) as unknown[]), |
| ... | ... | @@ -555,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 555 | 555 | "view", |
| 556 | 556 | input.reference, |
| 557 | 557 | "--json", |
| 558 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 558 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 559 | 559 | ], |
| 560 | 560 | }).pipe( |
| 561 | 561 | Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), |
| ... | ... | @@ -1148,6 +1148,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1148 | 1148 | |
| 1149 | 1149 | expect(pullRequest).toEqual({ |
| 1150 | 1150 | state: "open", |
| 1151 | closedAt: null, | |
| 1152 | mergedAt: null, | |
| 1151 | 1153 | updatedAt: "2026-04-03T15:00:00.000Z", |
| 1152 | 1154 | }); |
| 1153 | 1155 | expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); |
| ... | ... | @@ -1179,6 +1181,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1179 | 1181 | baseRefName: "develop", |
| 1180 | 1182 | headRefName: "main", |
| 1181 | 1183 | state: "MERGED", |
| 1184 | mergedAt: "2026-04-07T15:00:00Z", | |
| 1182 | 1185 | updatedAt: "2026-04-08T15:00:00Z", |
| 1183 | 1186 | }, |
| 1184 | 1187 | ]), |
| ... | ... | @@ -1190,6 +1193,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1190 | 1193 | |
| 1191 | 1194 | expect(pullRequest).toEqual({ |
| 1192 | 1195 | state: "merged", |
| 1196 | closedAt: null, | |
| 1197 | mergedAt: "2026-04-07T15:00:00Z", | |
| 1193 | 1198 | updatedAt: "2026-04-08T15:00:00.000Z", |
| 1194 | 1199 | }); |
| 1195 | 1200 | }), |
| ... | ... | @@ -1241,6 +1246,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1241 | 1246 | |
| 1242 | 1247 | expect(pullRequest).toEqual({ |
| 1243 | 1248 | state: "merged", |
| 1249 | closedAt: null, | |
| 1250 | mergedAt: null, | |
| 1244 | 1251 | updatedAt: "2026-04-04T15:00:00.000Z", |
| 1245 | 1252 | }); |
| 1246 | 1253 | expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( |
| ... | ... | @@ -1305,6 +1312,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1305 | 1312 | |
| 1306 | 1313 | expect(pullRequest).toEqual({ |
| 1307 | 1314 | state: "merged", |
| 1315 | closedAt: null, | |
| 1316 | mergedAt: null, | |
| 1308 | 1317 | updatedAt: "2026-04-05T15:00:00.000Z", |
| 1309 | 1318 | }); |
| 1310 | 1319 | expect( |
| ... | ... | @@ -1691,7 +1700,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1691 | 1700 | updatedAt: "2026-03-10T07:00:00.000Z", |
| 1692 | 1701 | }); |
| 1693 | 1702 | expect(ghCalls).toContain( |
| 1694 | "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 1703 | "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 1695 | 1704 | ); |
| 1696 | 1705 | }), |
| 1697 | 1706 | 20_000, |
| ... | ... | @@ -1757,7 +1766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1757 | 1766 | updatedAt: "2026-03-10T07:00:00.000Z", |
| 1758 | 1767 | }); |
| 1759 | 1768 | expect(ghCalls).toContain( |
| 1760 | "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 1769 | "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 1761 | 1770 | ); |
| 1762 | 1771 | }), |
| 1763 | 1772 | 20_000, |
| ... | ... | @@ -2142,6 +2151,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 2142 | 2151 | |
| 2143 | 2152 | expect(pullRequest).toEqual({ |
| 2144 | 2153 | state: "merged", |
| 2154 | closedAt: null, | |
| 2155 | mergedAt: null, | |
| 2145 | 2156 | updatedAt: "2026-05-02T10:00:00.000Z", |
| 2146 | 2157 | }); |
| 2147 | 2158 | }), |
apps/server/src/git/GitManager.ts+16-2| ... | ... | @@ -100,7 +100,12 @@ export class GitManager extends Context.Service< |
| 100 | 100 | readonly cwd: string; |
| 101 | 101 | readonly branch: string; |
| 102 | 102 | }) => Effect.Effect< |
| 103 | { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, | |
| 103 | { | |
| 104 | readonly state: "open" | "closed" | "merged"; | |
| 105 | readonly updatedAt: string | null; | |
| 106 | readonly closedAt?: string | null; | |
| 107 | readonly mergedAt?: string | null; | |
| 108 | } | null, | |
| 104 | 109 | GitManagerServiceError |
| 105 | 110 | >; |
| 106 | 111 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; |
| ... | ... | @@ -171,6 +176,8 @@ interface OpenPrInfo { |
| 171 | 176 | interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { |
| 172 | 177 | state: "open" | "closed" | "merged"; |
| 173 | 178 | isDraft?: boolean; |
| 179 | closedAt?: string | null; | |
| 180 | mergedAt?: string | null; | |
| 174 | 181 | updatedAt: Option.Option<DateTime.Utc>; |
| 175 | 182 | } |
| 176 | 183 | |
| ... | ... | @@ -406,6 +413,8 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { |
| 406 | 413 | headRefName: summary.headRefName, |
| 407 | 414 | state: summary.state ?? "open", |
| 408 | 415 | ...(summary.isDraft === true ? { isDraft: true } : {}), |
| 416 | closedAt: summary.closedAt ?? null, | |
| 417 | mergedAt: summary.mergedAt ?? null, | |
| 409 | 418 | updatedAt: summary.updatedAt, |
| 410 | 419 | ...(summary.isCrossRepository !== undefined |
| 411 | 420 | ? { isCrossRepository: summary.isCrossRepository } |
| ... | ... | @@ -2157,7 +2166,12 @@ export const make = Effect.gen(function* () { |
| 2157 | 2166 | return null; |
| 2158 | 2167 | } |
| 2159 | 2168 | const statusPr = toStatusPr(latest); |
| 2160 | return { state: statusPr.state, updatedAt: statusPr.updatedAt }; | |
| 2169 | return { | |
| 2170 | state: statusPr.state, | |
| 2171 | updatedAt: statusPr.updatedAt, | |
| 2172 | closedAt: latest.closedAt ?? null, | |
| 2173 | mergedAt: latest.mergedAt ?? null, | |
| 2174 | }; | |
| 2161 | 2175 | }); |
| 2162 | 2176 | const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( |
| 2163 | 2177 | "invalidateLocalStatus", |
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+31-12| ... | ... | @@ -6,7 +6,7 @@ import { |
| 6 | 6 | TurnId, |
| 7 | 7 | type OrchestrationThreadShell, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; | |
| 9 | import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; | |
| 10 | 10 | |
| 11 | 11 | const NOW = "2026-08-28T12:00:00.000Z"; |
| 12 | 12 | const makeThread = ( |
| ... | ... | @@ -36,7 +36,7 @@ const makeThread = ( |
| 36 | 36 | |
| 37 | 37 | const decide = ( |
| 38 | 38 | thread: OrchestrationThreadShell, |
| 39 | pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, | |
| 39 | pullRequest: SettlementPullRequest | null = null, | |
| 40 | 40 | settings: { days?: number | null; merge?: boolean } = {}, |
| 41 | 41 | ) => |
| 42 | 42 | resolveAutoSettlementAt({ |
| ... | ... | @@ -77,7 +77,7 @@ describe("resolveAutoSettlementAt", () => { |
| 77 | 77 | latestTurn: null, |
| 78 | 78 | updatedAt: "2026-08-27T00:00:00.000Z", |
| 79 | 79 | }), |
| 80 | pullRequest: { state: "closed", updatedAt: NOW }, | |
| 80 | pullRequest: { state: "closed", closedAt: NOW }, | |
| 81 | 81 | now: NOW, |
| 82 | 82 | autoSettleAfterDays: null, |
| 83 | 83 | autoSettleOnMerge: true, |
| ... | ... | @@ -100,10 +100,10 @@ describe("resolveAutoSettlementAt", () => { |
| 100 | 100 | }); |
| 101 | 101 | |
| 102 | 102 | it("settles closed requests and honors the merge setting", () => { |
| 103 | expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true); | |
| 104 | expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true); | |
| 103 | expect(decide(makeThread(), { state: "closed", closedAt: NOW }, { merge: false })).toBe(true); | |
| 104 | expect(decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false })).toBe(true); | |
| 105 | 105 | expect( |
| 106 | decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), | |
| 106 | decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false, days: null }), | |
| 107 | 107 | ).toBe(false); |
| 108 | 108 | }); |
| 109 | 109 | |
| ... | ... | @@ -111,17 +111,36 @@ describe("resolveAutoSettlementAt", () => { |
| 111 | 111 | expect( |
| 112 | 112 | decide( |
| 113 | 113 | makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), |
| 114 | { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, | |
| 114 | { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" }, | |
| 115 | 115 | { days: null }, |
| 116 | 116 | ), |
| 117 | 117 | ).toBe(false); |
| 118 | 118 | }); |
| 119 | 119 | |
| 120 | it.each(["closed", "merged"] as const)( | |
| 121 | "ignores metadata edits after resumed work for %s requests", | |
| 122 | (state) => { | |
| 123 | expect( | |
| 124 | decide( | |
| 125 | makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), | |
| 126 | { | |
| 127 | state, | |
| 128 | closedAt: "2026-08-26T00:00:00.000Z", | |
| 129 | mergedAt: "2026-08-26T00:00:00.000Z", | |
| 130 | updatedAt: NOW, | |
| 131 | }, | |
| 132 | { days: null }, | |
| 133 | ), | |
| 134 | ).toBe(false); | |
| 135 | expect(decide(makeThread(), { state, updatedAt: NOW }, { days: null })).toBe(false); | |
| 136 | }, | |
| 137 | ); | |
| 138 | ||
| 120 | 139 | it("does not inherit a terminal pull request older than the thread", () => { |
| 121 | 140 | expect( |
| 122 | 141 | decide( |
| 123 | 142 | makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }), |
| 124 | { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" }, | |
| 143 | { state: "closed", closedAt: "2026-08-19T00:00:00.000Z" }, | |
| 125 | 144 | { days: null }, |
| 126 | 145 | ), |
| 127 | 146 | ).toBe(false); |
| ... | ... | @@ -129,9 +148,9 @@ describe("resolveAutoSettlementAt", () => { |
| 129 | 148 | |
| 130 | 149 | it("requires a comparable PR timestamp for immediate settlement", () => { |
| 131 | 150 | const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); |
| 132 | expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false); | |
| 133 | expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false); | |
| 134 | expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true); | |
| 151 | expect(decide(recentThread, { state: "closed", closedAt: null })).toBe(false); | |
| 152 | expect(decide(recentThread, { state: "merged", mergedAt: "unknown" })).toBe(false); | |
| 153 | expect(decide(makeThread(), { state: "closed", closedAt: null })).toBe(true); | |
| 135 | 154 | }); |
| 136 | 155 | |
| 137 | 156 | it("uses user request time instead of completion time as the PR anchor", () => { |
| ... | ... | @@ -145,7 +164,7 @@ describe("resolveAutoSettlementAt", () => { |
| 145 | 164 | assistantMessageId: null, |
| 146 | 165 | }, |
| 147 | 166 | }); |
| 148 | expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); | |
| 167 | expect(decide(thread, { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); | |
| 149 | 168 | }); |
| 150 | 169 | |
| 151 | 170 | it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { |
apps/server/src/orchestration/ThreadSettlementPolicy.ts+6-3| ... | ... | @@ -2,7 +2,9 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | 3 | export interface SettlementPullRequest { |
| 4 | 4 | readonly state: "open" | "closed" | "merged"; |
| 5 | readonly updatedAt: string | null; | |
| 5 | readonly closedAt?: string | null; | |
| 6 | readonly mergedAt?: string | null; | |
| 7 | readonly updatedAt?: string | null; | |
| 6 | 8 | } |
| 7 | 9 | |
| 8 | 10 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| ... | ... | @@ -49,14 +51,15 @@ function pullRequestSettles( |
| 49 | 51 | if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { |
| 50 | 52 | return false; |
| 51 | 53 | } |
| 52 | if (pullRequest.updatedAt === null) return false; | |
| 54 | const terminalAt = pullRequest.state === "merged" ? pullRequest.mergedAt : pullRequest.closedAt; | |
| 55 | if (terminalAt == null) return false; | |
| 53 | 56 | const userAnchor = latestTimestamp([ |
| 54 | 57 | thread.createdAt, |
| 55 | 58 | thread.latestUserMessageAt, |
| 56 | 59 | thread.latestTurn?.requestedAt, |
| 57 | 60 | ]); |
| 58 | 61 | if (userAnchor === null) return false; |
| 59 | const pullRequestAt = Date.parse(pullRequest.updatedAt); | |
| 62 | const pullRequestAt = Date.parse(terminalAt); | |
| 60 | 63 | const userAnchorAt = Date.parse(userAnchor); |
| 61 | 64 | if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; |
| 62 | 65 | return pullRequestAt >= userAnchorAt; |
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+19-4| ... | ... | @@ -127,6 +127,8 @@ function makePullRequestSummary(input: { |
| 127 | 127 | headBranch: "feature", |
| 128 | 128 | baseBranch: "main", |
| 129 | 129 | updatedAt: input.updatedAt ?? NOW, |
| 130 | closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, | |
| 131 | mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, | |
| 130 | 132 | }; |
| 131 | 133 | } |
| 132 | 134 | |
| ... | ... | @@ -372,7 +374,9 @@ describe("ThreadSettlementReactor", () => { |
| 372 | 374 | }), |
| 373 | 375 | ]), |
| 374 | 376 | branchPullRequest: () => |
| 375 | Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), | |
| 377 | Ref.get(pullRequest).pipe( | |
| 378 | Effect.map((state) => ({ state, updatedAt: NOW, closedAt: NOW, mergedAt: NOW })), | |
| 379 | ), | |
| 376 | 380 | }); |
| 377 | 381 | |
| 378 | 382 | yield* Effect.gen(function* () { |
| ... | ... | @@ -472,7 +476,12 @@ describe("ThreadSettlementReactor", () => { |
| 472 | 476 | ]), |
| 473 | 477 | branchPullRequest: () => |
| 474 | 478 | Ref.get(state).pipe( |
| 475 | Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), | |
| 479 | Effect.map((pullRequestState) => ({ | |
| 480 | state: pullRequestState, | |
| 481 | updatedAt: NOW, | |
| 482 | closedAt: NOW, | |
| 483 | mergedAt: NOW, | |
| 484 | })), | |
| 476 | 485 | ), |
| 477 | 486 | onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), |
| 478 | 487 | }); |
| ... | ... | @@ -603,7 +612,12 @@ describe("ThreadSettlementReactor", () => { |
| 603 | 612 | : Effect.void, |
| 604 | 613 | ), |
| 605 | 614 | Effect.andThen(Ref.get(state)), |
| 606 | Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), | |
| 615 | Effect.map((pullRequestState) => ({ | |
| 616 | state: pullRequestState, | |
| 617 | updatedAt: NOW, | |
| 618 | closedAt: NOW, | |
| 619 | mergedAt: NOW, | |
| 620 | })), | |
| 607 | 621 | ), |
| 608 | 622 | }); |
| 609 | 623 | |
| ... | ... | @@ -749,7 +763,8 @@ describe("ThreadSettlementReactor", () => { |
| 749 | 763 | makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), |
| 750 | 764 | ], |
| 751 | 765 | ), |
| 752 | branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), | |
| 766 | branchPullRequest: () => | |
| 767 | Effect.succeed({ state: "closed", updatedAt: NOW, closedAt: NOW }), | |
| 753 | 768 | pullRequestSummary: (input) => |
| 754 | 769 | Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), |
| 755 | 770 | }); |
apps/server/src/orchestration/ThreadSettlementReactor.ts+3-2| ... | ... | @@ -119,7 +119,7 @@ export const make = Effect.gen(function* () { |
| 119 | 119 | ) { |
| 120 | 120 | return { |
| 121 | 121 | state: "merged", |
| 122 | updatedAt: mergedPullRequest.mergedAt, | |
| 122 | mergedAt: mergedPullRequest.mergedAt, | |
| 123 | 123 | } satisfies SettlementPullRequest; |
| 124 | 124 | } |
| 125 | 125 | if (!projects.has(thread.linkedPullRequest.projectId)) { |
| ... | ... | @@ -135,7 +135,8 @@ export const make = Effect.gen(function* () { |
| 135 | 135 | ); |
| 136 | 136 | return { |
| 137 | 137 | state: summary.state, |
| 138 | updatedAt: summary.updatedAt, | |
| 138 | closedAt: summary.closedAt ?? null, | |
| 139 | mergedAt: summary.mergedAt ?? null, | |
| 139 | 140 | } satisfies SettlementPullRequest; |
| 140 | 141 | } |
| 141 | 142 | if (thread.branch === null) return null; |
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts+2| ... | ... | @@ -90,6 +90,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq |
| 90 | 90 | additions: 0, |
| 91 | 91 | deletions: 0, |
| 92 | 92 | createdAt: pullRequest.createdAt, |
| 93 | closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, | |
| 94 | mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, | |
| 93 | 95 | updatedAt: pullRequest.updatedAt, |
| 94 | 96 | reviewRequestLogins: pullRequest.reviewRequestLogins, |
| 95 | 97 | // Azure keeps labels on work items rather than on the pull request. |
apps/server/src/pullRequest/BitbucketPullRequestProvider.ts+2-2| ... | ... | @@ -175,8 +175,8 @@ export const make = Effect.gen(function* () { |
| 175 | 175 | deletions: diffStat.deletions, |
| 176 | 176 | changedFiles: diffStat.changedFiles, |
| 177 | 177 | body: pullRequest.body, |
| 178 | mergedAt: pullRequest.state === "merged" ? pullRequest.updatedAt : null, | |
| 179 | closedAt: pullRequest.state === "closed" ? pullRequest.updatedAt : null, | |
| 178 | mergedAt: null, | |
| 179 | closedAt: null, | |
| 180 | 180 | reviewers: pullRequest.reviewers, |
| 181 | 181 | checks, |
| 182 | 182 | // Bitbucket publishes no per-repository list of allowed strategies, so the ones it |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+6-2| ... | ... | @@ -192,7 +192,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 192 | 192 | url: "https://github.com/acme/web/pull/7", |
| 193 | 193 | baseRefName: "main", |
| 194 | 194 | headRefName: "feat/summary", |
| 195 | state: "open", | |
| 195 | state: "merged", | |
| 196 | closedAt: "2026-08-23T10:00:00Z", | |
| 197 | mergedAt: "2026-08-23T10:00:00Z", | |
| 196 | 198 | updatedAt: "2026-08-24T12:34:56.000Z", |
| 197 | 199 | }), |
| 198 | 200 | ); |
| ... | ... | @@ -211,7 +213,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 211 | 213 | url: "https://github.com/acme/web/pull/7", |
| 212 | 214 | headBranch: "feat/summary", |
| 213 | 215 | baseBranch: "main", |
| 214 | state: "open", | |
| 216 | state: "merged", | |
| 217 | closedAt: "2026-08-23T10:00:00Z", | |
| 218 | mergedAt: "2026-08-23T10:00:00Z", | |
| 215 | 219 | updatedAt: "2026-08-24T12:34:56.000Z", |
| 216 | 220 | }); |
| 217 | 221 | expect(mockedGetPullRequest).toHaveBeenCalledOnce(); |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+4| ... | ... | @@ -469,6 +469,8 @@ export class GitHubPullRequestCli extends Context.Service< |
| 469 | 469 | readonly baseBranch: string; |
| 470 | 470 | readonly state: "open" | "closed" | "merged"; |
| 471 | 471 | readonly isDraft?: boolean; |
| 472 | readonly closedAt?: string | null; | |
| 473 | readonly mergedAt?: string | null; | |
| 472 | 474 | readonly updatedAt: string; |
| 473 | 475 | }, |
| 474 | 476 | GitHubPullRequestCliError |
| ... | ... | @@ -1652,6 +1654,8 @@ export const make = Effect.gen(function* () { |
| 1652 | 1654 | baseBranch: summary.baseRefName, |
| 1653 | 1655 | state: summary.state ?? "open", |
| 1654 | 1656 | ...(summary.isDraft === true ? { isDraft: true } : {}), |
| 1657 | closedAt: summary.closedAt ?? null, | |
| 1658 | mergedAt: summary.mergedAt ?? null, | |
| 1655 | 1659 | updatedAt: summary.updatedAt, |
| 1656 | 1660 | }), |
| 1657 | 1661 | ), |
apps/server/src/pullRequest/PullRequestProvider.ts+4| ... | ... | @@ -78,6 +78,8 @@ export interface ProviderChangeRequest { |
| 78 | 78 | readonly additions: number; |
| 79 | 79 | readonly deletions: number; |
| 80 | 80 | readonly createdAt: string; |
| 81 | readonly closedAt?: string | null; | |
| 82 | readonly mergedAt?: string | null; | |
| 81 | 83 | readonly updatedAt: string; |
| 82 | 84 | /** Accounts with a review requested. Team-level requests are excluded by each provider. */ |
| 83 | 85 | readonly reviewRequestLogins: ReadonlyArray<string>; |
| ... | ... | @@ -98,6 +100,8 @@ export interface ProviderChangeRequestSummary { |
| 98 | 100 | readonly state: PullRequestState; |
| 99 | 101 | /** Present when the host says an open pull request is still a draft. */ |
| 100 | 102 | readonly isDraft?: boolean; |
| 103 | readonly closedAt?: string | null; | |
| 104 | readonly mergedAt?: string | null; | |
| 101 | 105 | readonly updatedAt: string; |
| 102 | 106 | } |
| 103 | 107 |
apps/server/src/pullRequest/PullRequestService.ts+4| ... | ... | @@ -1257,6 +1257,8 @@ export const make = Effect.gen(function* () { |
| 1257 | 1257 | ...(changeRequest.isDraft === true ? { isDraft: true } : {}), |
| 1258 | 1258 | headBranch: changeRequest.headBranch, |
| 1259 | 1259 | baseBranch: changeRequest.baseBranch, |
| 1260 | closedAt: changeRequest.closedAt ?? null, | |
| 1261 | mergedAt: changeRequest.mergedAt ?? null, | |
| 1260 | 1262 | updatedAt: changeRequest.updatedAt, |
| 1261 | 1263 | })), |
| 1262 | 1264 | ); |
| ... | ... | @@ -2316,6 +2318,8 @@ export const make = Effect.gen(function* () { |
| 2316 | 2318 | ...(detail.isDraft === true ? { isDraft: true } : {}), |
| 2317 | 2319 | headBranch: detail.headBranch, |
| 2318 | 2320 | baseBranch: detail.baseBranch, |
| 2321 | closedAt: detail.closedAt, | |
| 2322 | mergedAt: detail.mergedAt, | |
| 2319 | 2323 | updatedAt: detail.updatedAt, |
| 2320 | 2324 | }); |
| 2321 | 2325 | const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => { |
apps/server/src/sourceControl/AzureDevOpsCli.test.ts+2| ... | ... | @@ -163,6 +163,8 @@ describe("AzureDevOpsCli.layer", () => { |
| 163 | 163 | }); |
| 164 | 164 | |
| 165 | 165 | assert.strictEqual(result[0]?.state, "merged"); |
| 166 | assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z"); | |
| 167 | assert.strictEqual(result[0]?.closedAt, null); | |
| 166 | 168 | expect(mockRun).toHaveBeenCalledWith({ |
| 167 | 169 | operation: "AzureDevOpsCli.execute", |
| 168 | 170 | command: "az", |
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts+5-2| ... | ... | @@ -22,7 +22,8 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" |
| 22 | 22 | url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", |
| 23 | 23 | baseRefName: "main", |
| 24 | 24 | headRefName: "feature/source-control", |
| 25 | state: "open", | |
| 25 | state: "closed", | |
| 26 | closedAt: "2026-08-23T10:00:00Z", | |
| 26 | 27 | updatedAt: Option.none(), |
| 27 | 28 | }), |
| 28 | 29 | }); |
| ... | ... | @@ -39,7 +40,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" |
| 39 | 40 | url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", |
| 40 | 41 | baseRefName: "main", |
| 41 | 42 | headRefName: "feature/source-control", |
| 42 | state: "open", | |
| 43 | state: "closed", | |
| 44 | closedAt: "2026-08-23T10:00:00Z", | |
| 45 | mergedAt: null, | |
| 43 | 46 | updatedAt: Option.none(), |
| 44 | 47 | isCrossRepository: false, |
| 45 | 48 | }); |
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+4| ... | ... | @@ -62,6 +62,8 @@ function toChangeRequest(summary: { |
| 62 | 62 | readonly headRefName: string; |
| 63 | 63 | readonly state: "open" | "closed" | "merged"; |
| 64 | 64 | readonly isDraft?: boolean; |
| 65 | readonly closedAt?: string | null; | |
| 66 | readonly mergedAt?: string | null; | |
| 65 | 67 | readonly updatedAt: ChangeRequest["updatedAt"]; |
| 66 | 68 | }): ChangeRequest { |
| 67 | 69 | return { |
| ... | ... | @@ -73,6 +75,8 @@ function toChangeRequest(summary: { |
| 73 | 75 | headRefName: summary.headRefName, |
| 74 | 76 | state: summary.state, |
| 75 | 77 | ...(summary.isDraft === true ? { isDraft: true } : {}), |
| 78 | closedAt: summary.closedAt ?? null, | |
| 79 | mergedAt: summary.mergedAt ?? null, | |
| 76 | 80 | updatedAt: summary.updatedAt, |
| 77 | 81 | isCrossRepository: false, |
| 78 | 82 | }; |
apps/server/src/sourceControl/GitHubCli.test.ts+9-1| ... | ... | @@ -93,6 +93,8 @@ describe("GitHubCli.layer", () => { |
| 93 | 93 | baseRefName: "main", |
| 94 | 94 | headRefName: "feature/pr-threads", |
| 95 | 95 | state: "open", |
| 96 | closedAt: null, | |
| 97 | mergedAt: null, | |
| 96 | 98 | isDraft: true, |
| 97 | 99 | updatedAt: "2026-08-24T12:34:56.000Z", |
| 98 | 100 | isCrossRepository: true, |
| ... | ... | @@ -107,7 +109,7 @@ describe("GitHubCli.layer", () => { |
| 107 | 109 | "view", |
| 108 | 110 | "#42", |
| 109 | 111 | "--json", |
| 110 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 112 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 111 | 113 | ], |
| 112 | 114 | cwd: "/repo", |
| 113 | 115 | timeoutMs: 30_000, |
| ... | ... | @@ -154,6 +156,8 @@ describe("GitHubCli.layer", () => { |
| 154 | 156 | baseRefName: "main", |
| 155 | 157 | headRefName: "feature/pr-threads", |
| 156 | 158 | state: "open", |
| 159 | closedAt: null, | |
| 160 | mergedAt: null, | |
| 157 | 161 | isCrossRepository: true, |
| 158 | 162 | headRepositoryNameWithOwner: "octocat/codething-mvp", |
| 159 | 163 | headRepositoryOwnerLogin: "octocat", |
| ... | ... | @@ -207,6 +211,8 @@ describe("GitHubCli.layer", () => { |
| 207 | 211 | baseRefName: "main", |
| 208 | 212 | headRefName: "feature/pr-list", |
| 209 | 213 | state: "open", |
| 214 | closedAt: null, | |
| 215 | mergedAt: null, | |
| 210 | 216 | }, |
| 211 | 217 | ]); |
| 212 | 218 | }).pipe(Effect.provide(layer)), |
| ... | ... | @@ -259,6 +265,8 @@ describe("GitHubCli.layer", () => { |
| 259 | 265 | baseRefName: "main", |
| 260 | 266 | headRefName: "t3code/codex-turn-mapping", |
| 261 | 267 | state: "open", |
| 268 | closedAt: null, | |
| 269 | mergedAt: null, | |
| 262 | 270 | isCrossRepository: false, |
| 263 | 271 | headRepositoryNameWithOwner: "pingdotgg/codething-mvp", |
| 264 | 272 | headRepositoryOwnerLogin: "pingdotgg", |
apps/server/src/sourceControl/GitHubCli.ts+4-2| ... | ... | @@ -206,6 +206,8 @@ export interface GitHubPullRequestSummary { |
| 206 | 206 | readonly headRefName: string; |
| 207 | 207 | readonly state?: "open" | "closed" | "merged"; |
| 208 | 208 | readonly isDraft?: boolean; |
| 209 | readonly closedAt?: string | null; | |
| 210 | readonly mergedAt?: string | null; | |
| 209 | 211 | readonly updatedAt?: string; |
| 210 | 212 | readonly isCrossRepository?: boolean; |
| 211 | 213 | readonly headRepositoryNameWithOwner?: string | null; |
| ... | ... | @@ -367,7 +369,7 @@ export const make = Effect.gen(function* () { |
| 367 | 369 | "--limit", |
| 368 | 370 | String(input.limit ?? 1), |
| 369 | 371 | "--json", |
| 370 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 372 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 371 | 373 | ], |
| 372 | 374 | }).pipe( |
| 373 | 375 | Effect.map((result) => result.stdout.trim()), |
| ... | ... | @@ -399,7 +401,7 @@ export const make = Effect.gen(function* () { |
| 399 | 401 | "view", |
| 400 | 402 | input.reference, |
| 401 | 403 | "--json", |
| 402 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 404 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 403 | 405 | ], |
| 404 | 406 | }).pipe( |
| 405 | 407 | Effect.map((result) => result.stdout.trim()), |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+5-1| ... | ... | @@ -60,6 +60,8 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = |
| 60 | 60 | baseRefName: "main", |
| 61 | 61 | headRefName: "feature/source-control", |
| 62 | 62 | state: "open", |
| 63 | closedAt: null, | |
| 64 | mergedAt: null, | |
| 63 | 65 | updatedAt: Option.none(), |
| 64 | 66 | isCrossRepository: true, |
| 65 | 67 | headRepositoryNameWithOwner: "fork/t3code", |
| ... | ... | @@ -125,6 +127,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = |
| 125 | 127 | baseRefName: "main", |
| 126 | 128 | headRefName: "feature/merged", |
| 127 | 129 | state: "merged", |
| 130 | mergedAt: "2026-01-01T00:00:00Z", | |
| 128 | 131 | updatedAt: "2026-01-02T00:00:00.000Z", |
| 129 | 132 | }, |
| 130 | 133 | ]), |
| ... | ... | @@ -150,10 +153,11 @@ it.effect("uses gh json listing for non-open change request state queries", () = |
| 150 | 153 | "--limit", |
| 151 | 154 | "10", |
| 152 | 155 | "--json", |
| 153 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 156 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 154 | 157 | ]); |
| 155 | 158 | assert.strictEqual(changeRequests[0]?.provider, "github"); |
| 156 | 159 | assert.strictEqual(changeRequests[0]?.state, "merged"); |
| 160 | assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); | |
| 157 | 161 | assert.deepStrictEqual( |
| 158 | 162 | changeRequests[0]?.updatedAt, |
| 159 | 163 | Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+3-1| ... | ... | @@ -31,6 +31,8 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq |
| 31 | 31 | headRefName: summary.headRefName, |
| 32 | 32 | state: summary.state ?? "open", |
| 33 | 33 | ...(summary.isDraft === true ? { isDraft: true } : {}), |
| 34 | closedAt: summary.closedAt ?? null, | |
| 35 | mergedAt: summary.mergedAt ?? null, | |
| 34 | 36 | updatedAt: |
| 35 | 37 | summary.updatedAt === undefined |
| 36 | 38 | ? Option.none() |
| ... | ... | @@ -154,7 +156,7 @@ export const make = Effect.gen(function* () { |
| 154 | 156 | "--limit", |
| 155 | 157 | String(input.limit ?? 20), |
| 156 | 158 | "--json", |
| 157 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 159 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 158 | 160 | ], |
| 159 | 161 | }) |
| 160 | 162 | .pipe( |
apps/server/src/sourceControl/GitLabCli.test.ts+8-2| ... | ... | @@ -46,7 +46,8 @@ layer("GitLabCli.layer", (it) => { |
| 46 | 46 | web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", |
| 47 | 47 | target_branch: "main", |
| 48 | 48 | source_branch: "feature/mr-threads", |
| 49 | state: "opened", | |
| 49 | state: "closed", | |
| 50 | closed_at: "2026-08-23T10:00:00Z", | |
| 50 | 51 | source_project_id: 101, |
| 51 | 52 | target_project_id: 100, |
| 52 | 53 | source_project: { |
| ... | ... | @@ -71,7 +72,9 @@ layer("GitLabCli.layer", (it) => { |
| 71 | 72 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", |
| 72 | 73 | baseRefName: "main", |
| 73 | 74 | headRefName: "feature/mr-threads", |
| 74 | state: "open", | |
| 75 | state: "closed", | |
| 76 | closedAt: "2026-08-23T10:00:00Z", | |
| 77 | mergedAt: null, | |
| 75 | 78 | isCrossRepository: true, |
| 76 | 79 | headRepositoryNameWithOwner: "octocat/t3code", |
| 77 | 80 | headRepositoryOwnerLogin: "octocat", |
| ... | ... | @@ -107,6 +110,7 @@ layer("GitLabCli.layer", (it) => { |
| 107 | 110 | target_branch: " main ", |
| 108 | 111 | source_branch: " feature/mr-list ", |
| 109 | 112 | state: "merged", |
| 113 | merged_at: "2026-08-23T11:00:00Z", | |
| 110 | 114 | }, |
| 111 | 115 | ]), |
| 112 | 116 | ), |
| ... | ... | @@ -130,6 +134,8 @@ layer("GitLabCli.layer", (it) => { |
| 130 | 134 | baseRefName: "main", |
| 131 | 135 | headRefName: "feature/mr-list", |
| 132 | 136 | state: "merged", |
| 137 | closedAt: null, | |
| 138 | mergedAt: "2026-08-23T11:00:00Z", | |
| 133 | 139 | }, |
| 134 | 140 | ]); |
| 135 | 141 | expect(mockedRun).toHaveBeenCalledWith( |
apps/server/src/sourceControl/GitLabCli.ts+2| ... | ... | @@ -247,6 +247,8 @@ export interface GitLabMergeRequestSummary { |
| 247 | 247 | readonly headRefName: string; |
| 248 | 248 | readonly state?: "open" | "closed" | "merged"; |
| 249 | 249 | readonly isDraft?: boolean; |
| 250 | readonly closedAt?: string | null; | |
| 251 | readonly mergedAt?: string | null; | |
| 250 | 252 | readonly updatedAt?: Option.Option<DateTime.Utc>; |
| 251 | 253 | readonly isCrossRepository?: boolean; |
| 252 | 254 | readonly headRepositoryNameWithOwner?: string | null; |
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts+5-2| ... | ... | @@ -24,7 +24,8 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = |
| 24 | 24 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", |
| 25 | 25 | baseRefName: "main", |
| 26 | 26 | headRefName: "feature/source-control", |
| 27 | state: "open", | |
| 27 | state: "closed", | |
| 28 | closedAt: "2026-08-23T10:00:00Z", | |
| 28 | 29 | isCrossRepository: true, |
| 29 | 30 | headRepositoryNameWithOwner: "fork/t3code", |
| 30 | 31 | headRepositoryOwnerLogin: "fork", |
| ... | ... | @@ -43,7 +44,9 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = |
| 43 | 44 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", |
| 44 | 45 | baseRefName: "main", |
| 45 | 46 | headRefName: "feature/source-control", |
| 46 | state: "open", | |
| 47 | state: "closed", | |
| 48 | closedAt: "2026-08-23T10:00:00Z", | |
| 49 | mergedAt: null, | |
| 47 | 50 | updatedAt: Option.none(), |
| 48 | 51 | isCrossRepository: true, |
| 49 | 52 | headRepositoryNameWithOwner: "fork/t3code", |
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+2| ... | ... | @@ -27,6 +27,8 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe |
| 27 | 27 | headRefName: summary.headRefName, |
| 28 | 28 | state: summary.state ?? "open", |
| 29 | 29 | ...(summary.isDraft === true ? { isDraft: true } : {}), |
| 30 | closedAt: summary.closedAt ?? null, | |
| 31 | mergedAt: summary.mergedAt ?? null, | |
| 30 | 32 | updatedAt: summary.updatedAt ?? Option.none(), |
| 31 | 33 | ...(summary.isCrossRepository !== undefined |
| 32 | 34 | ? { isCrossRepository: summary.isCrossRepository } |
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+10-1| ... | ... | @@ -15,6 +15,8 @@ export interface NormalizedAzureDevOpsPullRequestRecord { |
| 15 | 15 | readonly headRefName: string; |
| 16 | 16 | readonly state: "open" | "closed" | "merged"; |
| 17 | 17 | readonly isDraft?: boolean; |
| 18 | readonly closedAt?: string | null; | |
| 19 | readonly mergedAt?: string | null; | |
| 18 | 20 | readonly updatedAt: Option.Option<DateTime.Utc>; |
| 19 | 21 | } |
| 20 | 22 | |
| ... | ... | @@ -163,14 +165,21 @@ function normalizeAzureDevOpsPullRequestUrl( |
| 163 | 165 | function normalizeAzureDevOpsPullRequestRecord( |
| 164 | 166 | raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>, |
| 165 | 167 | ): NormalizedAzureDevOpsPullRequestRecord { |
| 168 | const state = normalizeAzureDevOpsPullRequestState(raw.status); | |
| 169 | const terminalAt = Option.match(raw.closedDate ?? Option.none(), { | |
| 170 | onNone: () => null, | |
| 171 | onSome: DateTime.formatIso, | |
| 172 | }); | |
| 166 | 173 | return { |
| 167 | 174 | number: raw.pullRequestId, |
| 168 | 175 | title: raw.title, |
| 169 | 176 | url: normalizeAzureDevOpsPullRequestUrl(raw), |
| 170 | 177 | baseRefName: normalizeRefName(raw.targetRefName), |
| 171 | 178 | headRefName: normalizeRefName(raw.sourceRefName), |
| 172 | state: normalizeAzureDevOpsPullRequestState(raw.status), | |
| 179 | state, | |
| 173 | 180 | ...(raw.isDraft === true ? { isDraft: true } : {}), |
| 181 | closedAt: state === "closed" ? terminalAt : null, | |
| 182 | mergedAt: state === "merged" ? terminalAt : null, | |
| 174 | 183 | updatedAt: (raw.closedDate ?? Option.none()).pipe( |
| 175 | 184 | Option.orElse(() => raw.creationDate ?? Option.none()), |
| 176 | 185 | ), |
apps/server/src/sourceControl/gitHubPullRequests.ts+5| ... | ... | @@ -15,6 +15,8 @@ export interface NormalizedGitHubPullRequestRecord { |
| 15 | 15 | readonly headRefName: string; |
| 16 | 16 | readonly state: "open" | "closed" | "merged"; |
| 17 | 17 | readonly isDraft?: boolean; |
| 18 | readonly closedAt?: string | null; | |
| 19 | readonly mergedAt?: string | null; | |
| 18 | 20 | readonly updatedAt: Option.Option<DateTime.Utc>; |
| 19 | 21 | readonly isCrossRepository?: boolean; |
| 20 | 22 | readonly headRepositoryNameWithOwner?: string | null; |
| ... | ... | @@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({ |
| 29 | 31 | headRefName: TrimmedNonEmptyString, |
| 30 | 32 | state: Schema.optional(Schema.NullOr(Schema.String)), |
| 31 | 33 | isDraft: Schema.optional(Schema.Boolean), |
| 34 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 32 | 35 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| 33 | 36 | updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), |
| 34 | 37 | isCrossRepository: Schema.optional(Schema.Boolean), |
| ... | ... | @@ -96,6 +99,8 @@ function normalizeGitHubPullRequestRecord( |
| 96 | 99 | headRefName: raw.headRefName, |
| 97 | 100 | state: normalizeGitHubPullRequestState(raw), |
| 98 | 101 | ...(raw.isDraft === true ? { isDraft: true } : {}), |
| 102 | closedAt: raw.closedAt ?? null, | |
| 103 | mergedAt: raw.mergedAt ?? null, | |
| 99 | 104 | updatedAt: raw.updatedAt ?? Option.none(), |
| 100 | 105 | ...(typeof raw.isCrossRepository === "boolean" |
| 101 | 106 | ? { isCrossRepository: raw.isCrossRepository } |
apps/server/src/sourceControl/gitLabMergeRequests.ts+6| ... | ... | @@ -15,6 +15,8 @@ export interface NormalizedGitLabMergeRequestRecord { |
| 15 | 15 | readonly headRefName: string; |
| 16 | 16 | readonly state: "open" | "closed" | "merged"; |
| 17 | 17 | readonly isDraft?: boolean; |
| 18 | readonly closedAt?: string | null; | |
| 19 | readonly mergedAt?: string | null; | |
| 18 | 20 | readonly updatedAt: Option.Option<DateTime.Utc>; |
| 19 | 21 | readonly isCrossRepository?: boolean; |
| 20 | 22 | readonly headRepositoryNameWithOwner?: string | null; |
| ... | ... | @@ -44,6 +46,8 @@ const GitLabMergeRequestSchema = Schema.Struct({ |
| 44 | 46 | state: Schema.optional(Schema.NullOr(Schema.String)), |
| 45 | 47 | draft: Schema.optional(Schema.Boolean), |
| 46 | 48 | work_in_progress: Schema.optional(Schema.Boolean), |
| 49 | closed_at: Schema.optional(Schema.NullOr(Schema.String)), | |
| 50 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), | |
| 47 | 51 | updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), |
| 48 | 52 | source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), |
| 49 | 53 | target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), |
| ... | ... | @@ -112,6 +116,8 @@ function normalizeGitLabMergeRequestRecord( |
| 112 | 116 | headRefName: raw.source_branch, |
| 113 | 117 | state: normalizeGitLabMergeRequestState(raw.state), |
| 114 | 118 | ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), |
| 119 | closedAt: raw.closed_at ?? null, | |
| 120 | mergedAt: raw.merged_at ?? null, | |
| 115 | 121 | updatedAt: raw.updated_at ?? Option.none(), |
| 116 | 122 | ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), |
| 117 | 123 | ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+8| ... | ... | @@ -627,6 +627,14 @@ export function PullRequestDetailPanel({ |
| 627 | 627 | : { |
| 628 | 628 | ...resolvedCoreDetail, |
| 629 | 629 | ...sharedSummary, |
| 630 | closedAt: | |
| 631 | sharedSummary.closedAt === undefined | |
| 632 | ? resolvedCoreDetail.closedAt | |
| 633 | : sharedSummary.closedAt, | |
| 634 | mergedAt: | |
| 635 | sharedSummary.mergedAt === undefined | |
| 636 | ? resolvedCoreDetail.mergedAt | |
| 637 | : sharedSummary.mergedAt, | |
| 630 | 638 | // A summary may come from an older server that does not report draft state. Keep the |
| 631 | 639 | // detail's required value instead of making the complete detail shape partial. |
| 632 | 640 | isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, |
packages/contracts/src/git.ts+3-5| ... | ... | @@ -201,11 +201,9 @@ const VcsStatusChangeRequest = Schema.Struct({ |
| 201 | 201 | /** Optional for compatibility with older servers and providers. */ |
| 202 | 202 | isDraft: Schema.optional(Schema.Boolean), |
| 203 | 203 | /** |
| 204 | * Last provider-side activity (ISO). For a merged/closed change request | |
| 205 | * this bounds when it reached that state, so clients can tell a PR that | |
| 206 | * terminated during a thread's life from one that was already history | |
| 207 | * when the thread was created. Optional for old servers and providers | |
| 208 | * whose lookups do not report it. | |
| 204 | * Last provider-side activity (ISO), including comments and metadata edits. | |
| 205 | * This is not the time a change request closed or merged. Optional for old | |
| 206 | * servers and providers whose lookups do not report it. | |
| 209 | 207 | */ |
| 210 | 208 | updatedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| 211 | 209 | }); |
packages/contracts/src/pullRequest.ts+2| ... | ... | @@ -638,6 +638,8 @@ export const PullRequestSummary = Schema.Struct({ |
| 638 | 638 | isDraft: Schema.optional(Schema.Boolean), |
| 639 | 639 | headBranch: TrimmedNonEmptyString, |
| 640 | 640 | baseBranch: TrimmedNonEmptyString, |
| 641 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 642 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 641 | 643 | updatedAt: IsoDateTime, |
| 642 | 644 | }); |
| 643 | 645 | export type PullRequestSummary = typeof PullRequestSummary.Type; |
packages/contracts/src/sourceControl.ts+2| ... | ... | @@ -31,6 +31,8 @@ export const ChangeRequest = Schema.Struct({ |
| 31 | 31 | state: ChangeRequestState, |
| 32 | 32 | /** Present when the provider can tell that an open change request is still a draft. */ |
| 33 | 33 | isDraft: Schema.optional(Schema.Boolean), |
| 34 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 35 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 34 | 36 | updatedAt: Schema.Option(Schema.DateTimeUtc), |
| 35 | 37 | isCrossRepository: Schema.optional(Schema.Boolean), |
| 36 | 38 | headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |