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