authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-05 21:29:41-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 21:29:41-04:00
log050690d1bc048c1f94096c17a1c9231c3ad61a83
treee171e7b2c3cc369c45f72142027b34f34ba5f0b3
parentbd16b86d50c1df49afeb7c0a7568a4908ade4048
signature Signed by PGP key B5690EEEBB952194

fix(server): settle threads using actual pull request terminal timestamps (#9934)


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) => {
11481148
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) => {
11901193
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) => {
12411246
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) => {
13051312
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) => {
21422151
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 GitManagerServiceError109 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 {
171interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {176interface 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}
176183
...@@ -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 !== undefined419 ...(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";
9import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";9import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";
1010
11const NOW = "2026-08-28T12:00:00.000Z";11const NOW = "2026-08-28T12:00:00.000Z";
12const makeThread = (12const makeThread = (
...@@ -36,7 +36,7 @@ const makeThread = (...@@ -36,7 +36,7 @@ const makeThread = (
3636
37const decide = (37const 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 });
101101
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 });
109109
...@@ -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 });
119119
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", () => {
129148
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 });
136155
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 });
150169
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";
22
3export interface SettlementPullRequest {3export 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}
79
8const DAY_MS = 24 * 60 * 60 * 1_000;10const 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}
132134
...@@ -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 });
377381
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 });
609623
...@@ -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 it182 // 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 GitHubPullRequestCliError476 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}
103107
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 });
164164
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 === undefined37 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 !== undefined33 ...(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}
2022
...@@ -163,14 +165,21 @@ function normalizeAzureDevOpsPullRequestUrl(...@@ -163,14 +165,21 @@ function normalizeAzureDevOpsPullRequestUrl(
163function normalizeAzureDevOpsPullRequestRecord(165function 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 the638 // 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 request204 * Last provider-side activity (ISO), including comments and metadata edits.
205 * this bounds when it reached that state, so clients can tell a PR that205 * 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 history206 * 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});
643export type PullRequestSummary = typeof PullRequestSummary.Type;645export 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)),