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 = {}): {
511511 "--limit",
512512 String(input.limit ?? 1),
513513 "--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",
515515 ],
516516 }).pipe(
517517 Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
......@@ -555,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
555555 "view",
556556 input.reference,
557557 "--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",
559559 ],
560560 }).pipe(
561561 Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary),
......@@ -1148,6 +1148,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11481148
11491149 expect(pullRequest).toEqual({
11501150 state: "open",
1151 closedAt: null,
1152 mergedAt: null,
11511153 updatedAt: "2026-04-03T15:00:00.000Z",
11521154 });
11531155 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
......@@ -1179,6 +1181,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11791181 baseRefName: "develop",
11801182 headRefName: "main",
11811183 state: "MERGED",
1184 mergedAt: "2026-04-07T15:00:00Z",
11821185 updatedAt: "2026-04-08T15:00:00Z",
11831186 },
11841187 ]),
......@@ -1190,6 +1193,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11901193
11911194 expect(pullRequest).toEqual({
11921195 state: "merged",
1196 closedAt: null,
1197 mergedAt: "2026-04-07T15:00:00Z",
11931198 updatedAt: "2026-04-08T15:00:00.000Z",
11941199 });
11951200 }),
......@@ -1241,6 +1246,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
12411246
12421247 expect(pullRequest).toEqual({
12431248 state: "merged",
1249 closedAt: null,
1250 mergedAt: null,
12441251 updatedAt: "2026-04-04T15:00:00.000Z",
12451252 });
12461253 expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe(
......@@ -1305,6 +1312,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13051312
13061313 expect(pullRequest).toEqual({
13071314 state: "merged",
1315 closedAt: null,
1316 mergedAt: null,
13081317 updatedAt: "2026-04-05T15:00:00.000Z",
13091318 });
13101319 expect(
......@@ -1691,7 +1700,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
16911700 updatedAt: "2026-03-10T07:00:00.000Z",
16921701 });
16931702 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",
16951704 );
16961705 }),
16971706 20_000,
......@@ -1757,7 +1766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
17571766 updatedAt: "2026-03-10T07:00:00.000Z",
17581767 });
17591768 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",
17611770 );
17621771 }),
17631772 20_000,
......@@ -2142,6 +2151,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
21422151
21432152 expect(pullRequest).toEqual({
21442153 state: "merged",
2154 closedAt: null,
2155 mergedAt: null,
21452156 updatedAt: "2026-05-02T10:00:00.000Z",
21462157 });
21472158 }),
apps/server/src/git/GitManager.ts+16-2
......@@ -100,7 +100,12 @@ export class GitManager extends Context.Service<
100100 readonly cwd: string;
101101 readonly branch: string;
102102 }) => 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,
104109 GitManagerServiceError
105110 >;
106111 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
......@@ -171,6 +176,8 @@ interface OpenPrInfo {
171176interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
172177 state: "open" | "closed" | "merged";
173178 isDraft?: boolean;
179 closedAt?: string | null;
180 mergedAt?: string | null;
174181 updatedAt: Option.Option<DateTime.Utc>;
175182}
176183
......@@ -406,6 +413,8 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
406413 headRefName: summary.headRefName,
407414 state: summary.state ?? "open",
408415 ...(summary.isDraft === true ? { isDraft: true } : {}),
416 closedAt: summary.closedAt ?? null,
417 mergedAt: summary.mergedAt ?? null,
409418 updatedAt: summary.updatedAt,
410419 ...(summary.isCrossRepository !== undefined
411420 ? { isCrossRepository: summary.isCrossRepository }
......@@ -2157,7 +2166,12 @@ export const make = Effect.gen(function* () {
21572166 return null;
21582167 }
21592168 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 };
21612175 });
21622176 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
21632177 "invalidateLocalStatus",
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+31-12
......@@ -6,7 +6,7 @@ import {
66 TurnId,
77 type OrchestrationThreadShell,
88} from "@t3tools/contracts";
9import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";
9import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";
1010
1111const NOW = "2026-08-28T12:00:00.000Z";
1212const makeThread = (
......@@ -36,7 +36,7 @@ const makeThread = (
3636
3737const decide = (
3838 thread: OrchestrationThreadShell,
39 pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null,
39 pullRequest: SettlementPullRequest | null = null,
4040 settings: { days?: number | null; merge?: boolean } = {},
4141) =>
4242 resolveAutoSettlementAt({
......@@ -77,7 +77,7 @@ describe("resolveAutoSettlementAt", () => {
7777 latestTurn: null,
7878 updatedAt: "2026-08-27T00:00:00.000Z",
7979 }),
80 pullRequest: { state: "closed", updatedAt: NOW },
80 pullRequest: { state: "closed", closedAt: NOW },
8181 now: NOW,
8282 autoSettleAfterDays: null,
8383 autoSettleOnMerge: true,
......@@ -100,10 +100,10 @@ describe("resolveAutoSettlementAt", () => {
100100 });
101101
102102 it("settles closed requests and honors the merge setting", () => {
103 expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true);
104 expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true);
103 expect(decide(makeThread(), { state: "closed", closedAt: NOW }, { merge: false })).toBe(true);
104 expect(decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false })).toBe(true);
105105 expect(
106 decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }),
106 decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false, days: null }),
107107 ).toBe(false);
108108 });
109109
......@@ -111,17 +111,36 @@ describe("resolveAutoSettlementAt", () => {
111111 expect(
112112 decide(
113113 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" },
115115 { days: null },
116116 ),
117117 ).toBe(false);
118118 });
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
120139 it("does not inherit a terminal pull request older than the thread", () => {
121140 expect(
122141 decide(
123142 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" },
125144 { days: null },
126145 ),
127146 ).toBe(false);
......@@ -129,9 +148,9 @@ describe("resolveAutoSettlementAt", () => {
129148
130149 it("requires a comparable PR timestamp for immediate settlement", () => {
131150 const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" });
132 expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false);
133 expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false);
134 expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true);
151 expect(decide(recentThread, { state: "closed", closedAt: null })).toBe(false);
152 expect(decide(recentThread, { state: "merged", mergedAt: "unknown" })).toBe(false);
153 expect(decide(makeThread(), { state: "closed", closedAt: null })).toBe(true);
135154 });
136155
137156 it("uses user request time instead of completion time as the PR anchor", () => {
......@@ -145,7 +164,7 @@ describe("resolveAutoSettlementAt", () => {
145164 assistantMessageId: null,
146165 },
147166 });
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);
149168 });
150169
151170 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";
22
33export interface SettlementPullRequest {
44 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;
68}
79
810const DAY_MS = 24 * 60 * 60 * 1_000;
......@@ -49,14 +51,15 @@ function pullRequestSettles(
4951 if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) {
5052 return false;
5153 }
52 if (pullRequest.updatedAt === null) return false;
54 const terminalAt = pullRequest.state === "merged" ? pullRequest.mergedAt : pullRequest.closedAt;
55 if (terminalAt == null) return false;
5356 const userAnchor = latestTimestamp([
5457 thread.createdAt,
5558 thread.latestUserMessageAt,
5659 thread.latestTurn?.requestedAt,
5760 ]);
5861 if (userAnchor === null) return false;
59 const pullRequestAt = Date.parse(pullRequest.updatedAt);
62 const pullRequestAt = Date.parse(terminalAt);
6063 const userAnchorAt = Date.parse(userAnchor);
6164 if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false;
6265 return pullRequestAt >= userAnchorAt;
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+19-4
......@@ -127,6 +127,8 @@ function makePullRequestSummary(input: {
127127 headBranch: "feature",
128128 baseBranch: "main",
129129 updatedAt: input.updatedAt ?? NOW,
130 closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null,
131 mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null,
130132 };
131133}
132134
......@@ -372,7 +374,9 @@ describe("ThreadSettlementReactor", () => {
372374 }),
373375 ]),
374376 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 ),
376380 });
377381
378382 yield* Effect.gen(function* () {
......@@ -472,7 +476,12 @@ describe("ThreadSettlementReactor", () => {
472476 ]),
473477 branchPullRequest: () =>
474478 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 })),
476485 ),
477486 onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
478487 });
......@@ -603,7 +612,12 @@ describe("ThreadSettlementReactor", () => {
603612 : Effect.void,
604613 ),
605614 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 })),
607621 ),
608622 });
609623
......@@ -749,7 +763,8 @@ describe("ThreadSettlementReactor", () => {
749763 makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"),
750764 ],
751765 ),
752 branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }),
766 branchPullRequest: () =>
767 Effect.succeed({ state: "closed", updatedAt: NOW, closedAt: NOW }),
753768 pullRequestSummary: (input) =>
754769 Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })),
755770 });
apps/server/src/orchestration/ThreadSettlementReactor.ts+3-2
......@@ -119,7 +119,7 @@ export const make = Effect.gen(function* () {
119119 ) {
120120 return {
121121 state: "merged",
122 updatedAt: mergedPullRequest.mergedAt,
122 mergedAt: mergedPullRequest.mergedAt,
123123 } satisfies SettlementPullRequest;
124124 }
125125 if (!projects.has(thread.linkedPullRequest.projectId)) {
......@@ -135,7 +135,8 @@ export const make = Effect.gen(function* () {
135135 );
136136 return {
137137 state: summary.state,
138 updatedAt: summary.updatedAt,
138 closedAt: summary.closedAt ?? null,
139 mergedAt: summary.mergedAt ?? null,
139140 } satisfies SettlementPullRequest;
140141 }
141142 if (thread.branch === null) return null;
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts+2
......@@ -90,6 +90,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq
9090 additions: 0,
9191 deletions: 0,
9292 createdAt: pullRequest.createdAt,
93 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
94 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
9395 updatedAt: pullRequest.updatedAt,
9496 reviewRequestLogins: pullRequest.reviewRequestLogins,
9597 // 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* () {
175175 deletions: diffStat.deletions,
176176 changedFiles: diffStat.changedFiles,
177177 body: pullRequest.body,
178 mergedAt: pullRequest.state === "merged" ? pullRequest.updatedAt : null,
179 closedAt: pullRequest.state === "closed" ? pullRequest.updatedAt : null,
178 mergedAt: null,
179 closedAt: null,
180180 reviewers: pullRequest.reviewers,
181181 checks,
182182 // 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) => {
192192 url: "https://github.com/acme/web/pull/7",
193193 baseRefName: "main",
194194 headRefName: "feat/summary",
195 state: "open",
195 state: "merged",
196 closedAt: "2026-08-23T10:00:00Z",
197 mergedAt: "2026-08-23T10:00:00Z",
196198 updatedAt: "2026-08-24T12:34:56.000Z",
197199 }),
198200 );
......@@ -211,7 +213,9 @@ layer("GitHubPullRequestCli.layer", (it) => {
211213 url: "https://github.com/acme/web/pull/7",
212214 headBranch: "feat/summary",
213215 baseBranch: "main",
214 state: "open",
216 state: "merged",
217 closedAt: "2026-08-23T10:00:00Z",
218 mergedAt: "2026-08-23T10:00:00Z",
215219 updatedAt: "2026-08-24T12:34:56.000Z",
216220 });
217221 expect(mockedGetPullRequest).toHaveBeenCalledOnce();
apps/server/src/pullRequest/GitHubPullRequestCli.ts+4
......@@ -469,6 +469,8 @@ export class GitHubPullRequestCli extends Context.Service<
469469 readonly baseBranch: string;
470470 readonly state: "open" | "closed" | "merged";
471471 readonly isDraft?: boolean;
472 readonly closedAt?: string | null;
473 readonly mergedAt?: string | null;
472474 readonly updatedAt: string;
473475 },
474476 GitHubPullRequestCliError
......@@ -1652,6 +1654,8 @@ export const make = Effect.gen(function* () {
16521654 baseBranch: summary.baseRefName,
16531655 state: summary.state ?? "open",
16541656 ...(summary.isDraft === true ? { isDraft: true } : {}),
1657 closedAt: summary.closedAt ?? null,
1658 mergedAt: summary.mergedAt ?? null,
16551659 updatedAt: summary.updatedAt,
16561660 }),
16571661 ),
apps/server/src/pullRequest/PullRequestProvider.ts+4
......@@ -78,6 +78,8 @@ export interface ProviderChangeRequest {
7878 readonly additions: number;
7979 readonly deletions: number;
8080 readonly createdAt: string;
81 readonly closedAt?: string | null;
82 readonly mergedAt?: string | null;
8183 readonly updatedAt: string;
8284 /** Accounts with a review requested. Team-level requests are excluded by each provider. */
8385 readonly reviewRequestLogins: ReadonlyArray<string>;
......@@ -98,6 +100,8 @@ export interface ProviderChangeRequestSummary {
98100 readonly state: PullRequestState;
99101 /** Present when the host says an open pull request is still a draft. */
100102 readonly isDraft?: boolean;
103 readonly closedAt?: string | null;
104 readonly mergedAt?: string | null;
101105 readonly updatedAt: string;
102106}
103107
apps/server/src/pullRequest/PullRequestService.ts+4
......@@ -1257,6 +1257,8 @@ export const make = Effect.gen(function* () {
12571257 ...(changeRequest.isDraft === true ? { isDraft: true } : {}),
12581258 headBranch: changeRequest.headBranch,
12591259 baseBranch: changeRequest.baseBranch,
1260 closedAt: changeRequest.closedAt ?? null,
1261 mergedAt: changeRequest.mergedAt ?? null,
12601262 updatedAt: changeRequest.updatedAt,
12611263 })),
12621264 );
......@@ -2316,6 +2318,8 @@ export const make = Effect.gen(function* () {
23162318 ...(detail.isDraft === true ? { isDraft: true } : {}),
23172319 headBranch: detail.headBranch,
23182320 baseBranch: detail.baseBranch,
2321 closedAt: detail.closedAt,
2322 mergedAt: detail.mergedAt,
23192323 updatedAt: detail.updatedAt,
23202324 });
23212325 const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => {
apps/server/src/sourceControl/AzureDevOpsCli.test.ts+2
......@@ -163,6 +163,8 @@ describe("AzureDevOpsCli.layer", () => {
163163 });
164164
165165 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);
166168 expect(mockRun).toHaveBeenCalledWith({
167169 operation: "AzureDevOpsCli.execute",
168170 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"
2222 url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
2323 baseRefName: "main",
2424 headRefName: "feature/source-control",
25 state: "open",
25 state: "closed",
26 closedAt: "2026-08-23T10:00:00Z",
2627 updatedAt: Option.none(),
2728 }),
2829 });
......@@ -39,7 +40,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"
3940 url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
4041 baseRefName: "main",
4142 headRefName: "feature/source-control",
42 state: "open",
43 state: "closed",
44 closedAt: "2026-08-23T10:00:00Z",
45 mergedAt: null,
4346 updatedAt: Option.none(),
4447 isCrossRepository: false,
4548 });
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+4
......@@ -62,6 +62,8 @@ function toChangeRequest(summary: {
6262 readonly headRefName: string;
6363 readonly state: "open" | "closed" | "merged";
6464 readonly isDraft?: boolean;
65 readonly closedAt?: string | null;
66 readonly mergedAt?: string | null;
6567 readonly updatedAt: ChangeRequest["updatedAt"];
6668}): ChangeRequest {
6769 return {
......@@ -73,6 +75,8 @@ function toChangeRequest(summary: {
7375 headRefName: summary.headRefName,
7476 state: summary.state,
7577 ...(summary.isDraft === true ? { isDraft: true } : {}),
78 closedAt: summary.closedAt ?? null,
79 mergedAt: summary.mergedAt ?? null,
7680 updatedAt: summary.updatedAt,
7781 isCrossRepository: false,
7882 };
apps/server/src/sourceControl/GitHubCli.test.ts+9-1
......@@ -93,6 +93,8 @@ describe("GitHubCli.layer", () => {
9393 baseRefName: "main",
9494 headRefName: "feature/pr-threads",
9595 state: "open",
96 closedAt: null,
97 mergedAt: null,
9698 isDraft: true,
9799 updatedAt: "2026-08-24T12:34:56.000Z",
98100 isCrossRepository: true,
......@@ -107,7 +109,7 @@ describe("GitHubCli.layer", () => {
107109 "view",
108110 "#42",
109111 "--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",
111113 ],
112114 cwd: "/repo",
113115 timeoutMs: 30_000,
......@@ -154,6 +156,8 @@ describe("GitHubCli.layer", () => {
154156 baseRefName: "main",
155157 headRefName: "feature/pr-threads",
156158 state: "open",
159 closedAt: null,
160 mergedAt: null,
157161 isCrossRepository: true,
158162 headRepositoryNameWithOwner: "octocat/codething-mvp",
159163 headRepositoryOwnerLogin: "octocat",
......@@ -207,6 +211,8 @@ describe("GitHubCli.layer", () => {
207211 baseRefName: "main",
208212 headRefName: "feature/pr-list",
209213 state: "open",
214 closedAt: null,
215 mergedAt: null,
210216 },
211217 ]);
212218 }).pipe(Effect.provide(layer)),
......@@ -259,6 +265,8 @@ describe("GitHubCli.layer", () => {
259265 baseRefName: "main",
260266 headRefName: "t3code/codex-turn-mapping",
261267 state: "open",
268 closedAt: null,
269 mergedAt: null,
262270 isCrossRepository: false,
263271 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
264272 headRepositoryOwnerLogin: "pingdotgg",
apps/server/src/sourceControl/GitHubCli.ts+4-2
......@@ -206,6 +206,8 @@ export interface GitHubPullRequestSummary {
206206 readonly headRefName: string;
207207 readonly state?: "open" | "closed" | "merged";
208208 readonly isDraft?: boolean;
209 readonly closedAt?: string | null;
210 readonly mergedAt?: string | null;
209211 readonly updatedAt?: string;
210212 readonly isCrossRepository?: boolean;
211213 readonly headRepositoryNameWithOwner?: string | null;
......@@ -367,7 +369,7 @@ export const make = Effect.gen(function* () {
367369 "--limit",
368370 String(input.limit ?? 1),
369371 "--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",
371373 ],
372374 }).pipe(
373375 Effect.map((result) => result.stdout.trim()),
......@@ -399,7 +401,7 @@ export const make = Effect.gen(function* () {
399401 "view",
400402 input.reference,
401403 "--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",
403405 ],
404406 }).pipe(
405407 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", () =
6060 baseRefName: "main",
6161 headRefName: "feature/source-control",
6262 state: "open",
63 closedAt: null,
64 mergedAt: null,
6365 updatedAt: Option.none(),
6466 isCrossRepository: true,
6567 headRepositoryNameWithOwner: "fork/t3code",
......@@ -125,6 +127,7 @@ it.effect("uses gh json listing for non-open change request state queries", () =
125127 baseRefName: "main",
126128 headRefName: "feature/merged",
127129 state: "merged",
130 mergedAt: "2026-01-01T00:00:00Z",
128131 updatedAt: "2026-01-02T00:00:00.000Z",
129132 },
130133 ]),
......@@ -150,10 +153,11 @@ it.effect("uses gh json listing for non-open change request state queries", () =
150153 "--limit",
151154 "10",
152155 "--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",
154157 ]);
155158 assert.strictEqual(changeRequests[0]?.provider, "github");
156159 assert.strictEqual(changeRequests[0]?.state, "merged");
160 assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z");
157161 assert.deepStrictEqual(
158162 changeRequests[0]?.updatedAt,
159163 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
3131 headRefName: summary.headRefName,
3232 state: summary.state ?? "open",
3333 ...(summary.isDraft === true ? { isDraft: true } : {}),
34 closedAt: summary.closedAt ?? null,
35 mergedAt: summary.mergedAt ?? null,
3436 updatedAt:
3537 summary.updatedAt === undefined
3638 ? Option.none()
......@@ -154,7 +156,7 @@ export const make = Effect.gen(function* () {
154156 "--limit",
155157 String(input.limit ?? 20),
156158 "--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",
158160 ],
159161 })
160162 .pipe(
apps/server/src/sourceControl/GitLabCli.test.ts+8-2
......@@ -46,7 +46,8 @@ layer("GitLabCli.layer", (it) => {
4646 web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
4747 target_branch: "main",
4848 source_branch: "feature/mr-threads",
49 state: "opened",
49 state: "closed",
50 closed_at: "2026-08-23T10:00:00Z",
5051 source_project_id: 101,
5152 target_project_id: 100,
5253 source_project: {
......@@ -71,7 +72,9 @@ layer("GitLabCli.layer", (it) => {
7172 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
7273 baseRefName: "main",
7374 headRefName: "feature/mr-threads",
74 state: "open",
75 state: "closed",
76 closedAt: "2026-08-23T10:00:00Z",
77 mergedAt: null,
7578 isCrossRepository: true,
7679 headRepositoryNameWithOwner: "octocat/t3code",
7780 headRepositoryOwnerLogin: "octocat",
......@@ -107,6 +110,7 @@ layer("GitLabCli.layer", (it) => {
107110 target_branch: " main ",
108111 source_branch: " feature/mr-list ",
109112 state: "merged",
113 merged_at: "2026-08-23T11:00:00Z",
110114 },
111115 ]),
112116 ),
......@@ -130,6 +134,8 @@ layer("GitLabCli.layer", (it) => {
130134 baseRefName: "main",
131135 headRefName: "feature/mr-list",
132136 state: "merged",
137 closedAt: null,
138 mergedAt: "2026-08-23T11:00:00Z",
133139 },
134140 ]);
135141 expect(mockedRun).toHaveBeenCalledWith(
apps/server/src/sourceControl/GitLabCli.ts+2
......@@ -247,6 +247,8 @@ export interface GitLabMergeRequestSummary {
247247 readonly headRefName: string;
248248 readonly state?: "open" | "closed" | "merged";
249249 readonly isDraft?: boolean;
250 readonly closedAt?: string | null;
251 readonly mergedAt?: string | null;
250252 readonly updatedAt?: Option.Option<DateTime.Utc>;
251253 readonly isCrossRepository?: boolean;
252254 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", () =
2424 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
2525 baseRefName: "main",
2626 headRefName: "feature/source-control",
27 state: "open",
27 state: "closed",
28 closedAt: "2026-08-23T10:00:00Z",
2829 isCrossRepository: true,
2930 headRepositoryNameWithOwner: "fork/t3code",
3031 headRepositoryOwnerLogin: "fork",
......@@ -43,7 +44,9 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () =
4344 url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42",
4445 baseRefName: "main",
4546 headRefName: "feature/source-control",
46 state: "open",
47 state: "closed",
48 closedAt: "2026-08-23T10:00:00Z",
49 mergedAt: null,
4750 updatedAt: Option.none(),
4851 isCrossRepository: true,
4952 headRepositoryNameWithOwner: "fork/t3code",
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+2
......@@ -27,6 +27,8 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe
2727 headRefName: summary.headRefName,
2828 state: summary.state ?? "open",
2929 ...(summary.isDraft === true ? { isDraft: true } : {}),
30 closedAt: summary.closedAt ?? null,
31 mergedAt: summary.mergedAt ?? null,
3032 updatedAt: summary.updatedAt ?? Option.none(),
3133 ...(summary.isCrossRepository !== undefined
3234 ? { isCrossRepository: summary.isCrossRepository }
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+10-1
......@@ -15,6 +15,8 @@ export interface NormalizedAzureDevOpsPullRequestRecord {
1515 readonly headRefName: string;
1616 readonly state: "open" | "closed" | "merged";
1717 readonly isDraft?: boolean;
18 readonly closedAt?: string | null;
19 readonly mergedAt?: string | null;
1820 readonly updatedAt: Option.Option<DateTime.Utc>;
1921}
2022
......@@ -163,14 +165,21 @@ function normalizeAzureDevOpsPullRequestUrl(
163165function normalizeAzureDevOpsPullRequestRecord(
164166 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
165167): NormalizedAzureDevOpsPullRequestRecord {
168 const state = normalizeAzureDevOpsPullRequestState(raw.status);
169 const terminalAt = Option.match(raw.closedDate ?? Option.none(), {
170 onNone: () => null,
171 onSome: DateTime.formatIso,
172 });
166173 return {
167174 number: raw.pullRequestId,
168175 title: raw.title,
169176 url: normalizeAzureDevOpsPullRequestUrl(raw),
170177 baseRefName: normalizeRefName(raw.targetRefName),
171178 headRefName: normalizeRefName(raw.sourceRefName),
172 state: normalizeAzureDevOpsPullRequestState(raw.status),
179 state,
173180 ...(raw.isDraft === true ? { isDraft: true } : {}),
181 closedAt: state === "closed" ? terminalAt : null,
182 mergedAt: state === "merged" ? terminalAt : null,
174183 updatedAt: (raw.closedDate ?? Option.none()).pipe(
175184 Option.orElse(() => raw.creationDate ?? Option.none()),
176185 ),
apps/server/src/sourceControl/gitHubPullRequests.ts+5
......@@ -15,6 +15,8 @@ export interface NormalizedGitHubPullRequestRecord {
1515 readonly headRefName: string;
1616 readonly state: "open" | "closed" | "merged";
1717 readonly isDraft?: boolean;
18 readonly closedAt?: string | null;
19 readonly mergedAt?: string | null;
1820 readonly updatedAt: Option.Option<DateTime.Utc>;
1921 readonly isCrossRepository?: boolean;
2022 readonly headRepositoryNameWithOwner?: string | null;
......@@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({
2931 headRefName: TrimmedNonEmptyString,
3032 state: Schema.optional(Schema.NullOr(Schema.String)),
3133 isDraft: Schema.optional(Schema.Boolean),
34 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
3235 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
3336 updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
3437 isCrossRepository: Schema.optional(Schema.Boolean),
......@@ -96,6 +99,8 @@ function normalizeGitHubPullRequestRecord(
9699 headRefName: raw.headRefName,
97100 state: normalizeGitHubPullRequestState(raw),
98101 ...(raw.isDraft === true ? { isDraft: true } : {}),
102 closedAt: raw.closedAt ?? null,
103 mergedAt: raw.mergedAt ?? null,
99104 updatedAt: raw.updatedAt ?? Option.none(),
100105 ...(typeof raw.isCrossRepository === "boolean"
101106 ? { isCrossRepository: raw.isCrossRepository }
apps/server/src/sourceControl/gitLabMergeRequests.ts+6
......@@ -15,6 +15,8 @@ export interface NormalizedGitLabMergeRequestRecord {
1515 readonly headRefName: string;
1616 readonly state: "open" | "closed" | "merged";
1717 readonly isDraft?: boolean;
18 readonly closedAt?: string | null;
19 readonly mergedAt?: string | null;
1820 readonly updatedAt: Option.Option<DateTime.Utc>;
1921 readonly isCrossRepository?: boolean;
2022 readonly headRepositoryNameWithOwner?: string | null;
......@@ -44,6 +46,8 @@ const GitLabMergeRequestSchema = Schema.Struct({
4446 state: Schema.optional(Schema.NullOr(Schema.String)),
4547 draft: Schema.optional(Schema.Boolean),
4648 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)),
4751 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
4852 source_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
4953 target_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
......@@ -112,6 +116,8 @@ function normalizeGitLabMergeRequestRecord(
112116 headRefName: raw.source_branch,
113117 state: normalizeGitLabMergeRequestState(raw.state),
114118 ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}),
119 closedAt: raw.closed_at ?? null,
120 mergedAt: raw.merged_at ?? null,
115121 updatedAt: raw.updated_at ?? Option.none(),
116122 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
117123 ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}),
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+8
......@@ -627,6 +627,14 @@ export function PullRequestDetailPanel({
627627 : {
628628 ...resolvedCoreDetail,
629629 ...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,
630638 // A summary may come from an older server that does not report draft state. Keep the
631639 // detail's required value instead of making the complete detail shape partial.
632640 isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft,
packages/contracts/src/git.ts+3-5
......@@ -201,11 +201,9 @@ const VcsStatusChangeRequest = Schema.Struct({
201201 /** Optional for compatibility with older servers and providers. */
202202 isDraft: Schema.optional(Schema.Boolean),
203203 /**
204 * Last provider-side activity (ISO). For a merged/closed change request
205 * this bounds when it reached that state, so clients can tell a PR that
206 * terminated during a thread's life from one that was already history
207 * when the thread was created. Optional for old servers and providers
208 * whose lookups do not report it.
204 * Last provider-side activity (ISO), including comments and metadata edits.
205 * This is not the time a change request closed or merged. Optional for old
206 * servers and providers whose lookups do not report it.
209207 */
210208 updatedAt: Schema.optional(Schema.NullOr(Schema.String)),
211209});
packages/contracts/src/pullRequest.ts+2
......@@ -638,6 +638,8 @@ export const PullRequestSummary = Schema.Struct({
638638 isDraft: Schema.optional(Schema.Boolean),
639639 headBranch: TrimmedNonEmptyString,
640640 baseBranch: TrimmedNonEmptyString,
641 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
642 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
641643 updatedAt: IsoDateTime,
642644});
643645export type PullRequestSummary = typeof PullRequestSummary.Type;
packages/contracts/src/sourceControl.ts+2
......@@ -31,6 +31,8 @@ export const ChangeRequest = Schema.Struct({
3131 state: ChangeRequestState,
3232 /** Present when the provider can tell that an open change request is still a draft. */
3333 isDraft: Schema.optional(Schema.Boolean),
34 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
35 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
3436 updatedAt: Schema.Option(Schema.DateTimeUtc),
3537 isCrossRepository: Schema.optional(Schema.Boolean),
3638 headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),