From 050690d1bc048c1f94096c17a1c9231c3ad61a83 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 21:29:41 -0400 Subject: [PATCH] fix(server): settle threads using actual pull request terminal timestamps (#9934) --- apps/server/src/git/GitManager.test.ts | 19 ++++++-- apps/server/src/git/GitManager.ts | 18 +++++++- .../ThreadSettlementPolicy.test.ts | 43 +++++++++++++------ .../orchestration/ThreadSettlementPolicy.ts | 9 ++-- .../ThreadSettlementReactor.test.ts | 23 ++++++++-- .../orchestration/ThreadSettlementReactor.ts | 5 ++- .../AzureDevOpsPullRequestProvider.ts | 2 + .../BitbucketPullRequestProvider.ts | 4 +- .../pullRequest/GitHubPullRequestCli.test.ts | 8 +++- .../src/pullRequest/GitHubPullRequestCli.ts | 4 ++ .../src/pullRequest/PullRequestProvider.ts | 4 ++ .../src/pullRequest/PullRequestService.ts | 4 ++ .../src/sourceControl/AzureDevOpsCli.test.ts | 2 + .../AzureDevOpsSourceControlProvider.test.ts | 7 ++- .../AzureDevOpsSourceControlProvider.ts | 4 ++ .../src/sourceControl/GitHubCli.test.ts | 10 ++++- apps/server/src/sourceControl/GitHubCli.ts | 6 ++- .../GitHubSourceControlProvider.test.ts | 6 ++- .../GitHubSourceControlProvider.ts | 4 +- .../src/sourceControl/GitLabCli.test.ts | 10 ++++- apps/server/src/sourceControl/GitLabCli.ts | 2 + .../GitLabSourceControlProvider.test.ts | 7 ++- .../GitLabSourceControlProvider.ts | 2 + .../sourceControl/azureDevOpsPullRequests.ts | 11 ++++- .../src/sourceControl/gitHubPullRequests.ts | 5 +++ .../src/sourceControl/gitLabMergeRequests.ts | 6 +++ .../pullRequest/PullRequestDetailPanel.tsx | 8 ++++ packages/contracts/src/git.ts | 8 ++-- packages/contracts/src/pullRequest.ts | 2 + packages/contracts/src/sourceControl.ts | 2 + 30 files changed, 197 insertions(+), 48 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 643e1e7a0d5bae630c8b050b2f6f05407284f44c..b8f4090453bede045e56005f54d93b414f293a0e 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -511,7 +511,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), @@ -555,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), @@ -1148,6 +1148,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "open", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-03T15:00:00.000Z", }); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); @@ -1179,6 +1181,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "develop", headRefName: "main", state: "MERGED", + mergedAt: "2026-04-07T15:00:00Z", updatedAt: "2026-04-08T15:00:00Z", }, ]), @@ -1190,6 +1193,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: "2026-04-07T15:00:00Z", updatedAt: "2026-04-08T15:00:00.000Z", }); }), @@ -1241,6 +1246,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-04T15:00:00.000Z", }); expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( @@ -1305,6 +1312,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-05T15:00:00.000Z", }); expect( @@ -1691,7 +1700,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1757,7 +1766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2142,6 +2151,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-05-02T10:00:00.000Z", }); }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 2d8af0c9e8bb54a5510f299ba6e3438822645e0f..76f2ebc6b51086e9df85b37fc52b147484fd3293 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -100,7 +100,12 @@ export class GitManager extends Context.Service< readonly cwd: string; readonly branch: string; }) => Effect.Effect< - { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, + { + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + } | null, GitManagerServiceError >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; @@ -171,6 +176,8 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; isDraft?: boolean; + closedAt?: string | null; + mergedAt?: string | null; updatedAt: Option.Option; } @@ -406,6 +413,8 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } @@ -2157,7 +2166,12 @@ export const make = Effect.gen(function* () { return null; } const statusPr = toStatusPr(latest); - return { state: statusPr.state, updatedAt: statusPr.updatedAt }; + return { + state: statusPr.state, + updatedAt: statusPr.updatedAt, + closedAt: latest.closedAt ?? null, + mergedAt: latest.mergedAt ?? null, + }; }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 05fb16203e0b39b4316463f5d271a9b13592de36..61c512e6fd91d98ebcf0451041b1d3f33c122218 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -6,7 +6,7 @@ import { TurnId, type OrchestrationThreadShell, } from "@t3tools/contracts"; -import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; +import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; const NOW = "2026-08-28T12:00:00.000Z"; const makeThread = ( @@ -36,7 +36,7 @@ const makeThread = ( const decide = ( thread: OrchestrationThreadShell, - pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + pullRequest: SettlementPullRequest | null = null, settings: { days?: number | null; merge?: boolean } = {}, ) => resolveAutoSettlementAt({ @@ -77,7 +77,7 @@ describe("resolveAutoSettlementAt", () => { latestTurn: null, updatedAt: "2026-08-27T00:00:00.000Z", }), - pullRequest: { state: "closed", updatedAt: NOW }, + pullRequest: { state: "closed", closedAt: NOW }, now: NOW, autoSettleAfterDays: null, autoSettleOnMerge: true, @@ -100,10 +100,10 @@ describe("resolveAutoSettlementAt", () => { }); it("settles closed requests and honors the merge setting", () => { - expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true); - expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "closed", closedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false })).toBe(true); expect( - decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), + decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false, days: null }), ).toBe(false); }); @@ -111,17 +111,36 @@ describe("resolveAutoSettlementAt", () => { expect( decide( makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), - { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, + { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" }, { days: null }, ), ).toBe(false); }); + it.each(["closed", "merged"] as const)( + "ignores metadata edits after resumed work for %s requests", + (state) => { + expect( + decide( + makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), + { + state, + closedAt: "2026-08-26T00:00:00.000Z", + mergedAt: "2026-08-26T00:00:00.000Z", + updatedAt: NOW, + }, + { days: null }, + ), + ).toBe(false); + expect(decide(makeThread(), { state, updatedAt: NOW }, { days: null })).toBe(false); + }, + ); + it("does not inherit a terminal pull request older than the thread", () => { expect( decide( makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }), - { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" }, + { state: "closed", closedAt: "2026-08-19T00:00:00.000Z" }, { days: null }, ), ).toBe(false); @@ -129,9 +148,9 @@ describe("resolveAutoSettlementAt", () => { it("requires a comparable PR timestamp for immediate settlement", () => { const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); - expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false); - expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false); - expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true); + expect(decide(recentThread, { state: "closed", closedAt: null })).toBe(false); + expect(decide(recentThread, { state: "merged", mergedAt: "unknown" })).toBe(false); + expect(decide(makeThread(), { state: "closed", closedAt: null })).toBe(true); }); it("uses user request time instead of completion time as the PR anchor", () => { @@ -145,7 +164,7 @@ describe("resolveAutoSettlementAt", () => { assistantMessageId: null, }, }); - expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); + expect(decide(thread, { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); }); it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index eac5a960a48229d523c83d7555952419401085b6..7c55fa37d1f3246c1bace152ea6d0f7e75a0f9f4 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -2,7 +2,9 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; export interface SettlementPullRequest { readonly state: "open" | "closed" | "merged"; - readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt?: string | null; } const DAY_MS = 24 * 60 * 60 * 1_000; @@ -49,14 +51,15 @@ function pullRequestSettles( if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { return false; } - if (pullRequest.updatedAt === null) return false; + const terminalAt = pullRequest.state === "merged" ? pullRequest.mergedAt : pullRequest.closedAt; + if (terminalAt == null) return false; const userAnchor = latestTimestamp([ thread.createdAt, thread.latestUserMessageAt, thread.latestTurn?.requestedAt, ]); if (userAnchor === null) return false; - const pullRequestAt = Date.parse(pullRequest.updatedAt); + const pullRequestAt = Date.parse(terminalAt); const userAnchorAt = Date.parse(userAnchor); if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; return pullRequestAt >= userAnchorAt; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index d3b24d5f77b10127d2aa75b8792f2178b319fa42..eefc18f7b461689df5ee92e4454caf50627eeab6 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -127,6 +127,8 @@ function makePullRequestSummary(input: { headBranch: "feature", baseBranch: "main", updatedAt: input.updatedAt ?? NOW, + closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, + mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, }; } @@ -372,7 +374,9 @@ describe("ThreadSettlementReactor", () => { }), ]), branchPullRequest: () => - Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), + Ref.get(pullRequest).pipe( + Effect.map((state) => ({ state, updatedAt: NOW, closedAt: NOW, mergedAt: NOW })), + ), }); yield* Effect.gen(function* () { @@ -472,7 +476,12 @@ describe("ThreadSettlementReactor", () => { ]), branchPullRequest: () => Ref.get(state).pipe( - Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + Effect.map((pullRequestState) => ({ + state: pullRequestState, + updatedAt: NOW, + closedAt: NOW, + mergedAt: NOW, + })), ), onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), }); @@ -603,7 +612,12 @@ describe("ThreadSettlementReactor", () => { : Effect.void, ), Effect.andThen(Ref.get(state)), - Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + Effect.map((pullRequestState) => ({ + state: pullRequestState, + updatedAt: NOW, + closedAt: NOW, + mergedAt: NOW, + })), ), }); @@ -749,7 +763,8 @@ describe("ThreadSettlementReactor", () => { makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), ], ), - branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), + branchPullRequest: () => + Effect.succeed({ state: "closed", updatedAt: NOW, closedAt: NOW }), pullRequestSummary: (input) => Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), }); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 6539135adfe6db105d3c6c47d215dfc4a0b5ec72..70de3c41d7e9495bfa8c1c852a9a3013da6f6fdb 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -119,7 +119,7 @@ export const make = Effect.gen(function* () { ) { return { state: "merged", - updatedAt: mergedPullRequest.mergedAt, + mergedAt: mergedPullRequest.mergedAt, } satisfies SettlementPullRequest; } if (!projects.has(thread.linkedPullRequest.projectId)) { @@ -135,7 +135,8 @@ export const make = Effect.gen(function* () { ); return { state: summary.state, - updatedAt: summary.updatedAt, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, } satisfies SettlementPullRequest; } if (thread.branch === null) return null; diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 3d501a32d61bce53738e4c94155d3b1faee5bb52..ae586ee0a61cfffded0dac1fa619f0be7cfaa361 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -90,6 +90,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq additions: 0, deletions: 0, createdAt: pullRequest.createdAt, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, updatedAt: pullRequest.updatedAt, reviewRequestLogins: pullRequest.reviewRequestLogins, // Azure keeps labels on work items rather than on the pull request. diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index 47d41eeee6d9d8853bdd99614371a7e10f101105..3b5b93d11c468751ba92dd44d575c103ffd523bf 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -175,8 +175,8 @@ export const make = Effect.gen(function* () { deletions: diffStat.deletions, changedFiles: diffStat.changedFiles, body: pullRequest.body, - mergedAt: pullRequest.state === "merged" ? pullRequest.updatedAt : null, - closedAt: pullRequest.state === "closed" ? pullRequest.updatedAt : null, + mergedAt: null, + closedAt: null, reviewers: pullRequest.reviewers, checks, // Bitbucket publishes no per-repository list of allowed strategies, so the ones it diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 1e6ca0ed43a62b3960682efda8d73681c8773cf2..f61b7c3233f6bef17736bb9d6add3c4edc790329 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -192,7 +192,9 @@ layer("GitHubPullRequestCli.layer", (it) => { url: "https://github.com/acme/web/pull/7", baseRefName: "main", headRefName: "feat/summary", - state: "open", + state: "merged", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-24T12:34:56.000Z", }), ); @@ -211,7 +213,9 @@ layer("GitHubPullRequestCli.layer", (it) => { url: "https://github.com/acme/web/pull/7", headBranch: "feat/summary", baseBranch: "main", - state: "open", + state: "merged", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-24T12:34:56.000Z", }); expect(mockedGetPullRequest).toHaveBeenCalledOnce(); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 5d5c2062c08c3b11313c4b6b8c99fdc6cb371b37..89a50f93ced788257f0f0b70d031c87f132fbf3b 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -469,6 +469,8 @@ export class GitHubPullRequestCli extends Context.Service< readonly baseBranch: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; }, GitHubPullRequestCliError @@ -1652,6 +1654,8 @@ export const make = Effect.gen(function* () { baseBranch: summary.baseRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, }), ), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 22028ced5ddfba9758697c41a9ba60ea98a8845f..5f1aba8ba9b621e51a188f2d51930085af84d385 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -78,6 +78,8 @@ export interface ProviderChangeRequest { readonly additions: number; readonly deletions: number; readonly createdAt: string; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; /** Accounts with a review requested. Team-level requests are excluded by each provider. */ readonly reviewRequestLogins: ReadonlyArray; @@ -98,6 +100,8 @@ export interface ProviderChangeRequestSummary { readonly state: PullRequestState; /** Present when the host says an open pull request is still a draft. */ readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a8d3dc2fdeaf7dc7d0682c359c50c729fc5fd40d..2229a4f652c0713b0d5b646a1ff459a77bb1eab9 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1257,6 +1257,8 @@ export const make = Effect.gen(function* () { ...(changeRequest.isDraft === true ? { isDraft: true } : {}), headBranch: changeRequest.headBranch, baseBranch: changeRequest.baseBranch, + closedAt: changeRequest.closedAt ?? null, + mergedAt: changeRequest.mergedAt ?? null, updatedAt: changeRequest.updatedAt, })), ); @@ -2316,6 +2318,8 @@ export const make = Effect.gen(function* () { ...(detail.isDraft === true ? { isDraft: true } : {}), headBranch: detail.headBranch, baseBranch: detail.baseBranch, + closedAt: detail.closedAt, + mergedAt: detail.mergedAt, updatedAt: detail.updatedAt, }); const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => { diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index f0cb52003029de06ad66bd7d30df7d4cc8f1baaa..24b28af13fe4e63407bd28c7ccab3a149af9b283 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -163,6 +163,8 @@ describe("AzureDevOpsCli.layer", () => { }); assert.strictEqual(result[0]?.state, "merged"); + assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z"); + assert.strictEqual(result[0]?.closedAt, null); expect(mockRun).toHaveBeenCalledWith({ operation: "AzureDevOpsCli.execute", command: "az", diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index cacdd1a3cd97ae1f568b83995cb49dca1321d536..8e55d453b224b411a0986b4cd153c588a8159f49 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -22,7 +22,8 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), }), }); @@ -39,7 +40,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, updatedAt: Option.none(), isCrossRepository: false, }); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8a840c524ebac618df1bb951f2414f0da0557a1c..20a74cc8a5d74ac179081f6bca6ec12214301b36 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -62,6 +62,8 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; }): ChangeRequest { return { @@ -73,6 +75,8 @@ function toChangeRequest(summary: { headRefName: summary.headRefName, state: summary.state, ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, isCrossRepository: false, }; diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 3f08e92e2c1e97cc9dc44ffaef440a3d93c91b3c..f72259b677ebfc4edc85796659540204bf0f3b40 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -93,6 +93,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isDraft: true, updatedAt: "2026-08-24T12:34:56.000Z", isCrossRepository: true, @@ -107,7 +109,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", timeoutMs: 30_000, @@ -154,6 +156,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", @@ -207,6 +211,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-list", state: "open", + closedAt: null, + mergedAt: null, }, ]); }).pipe(Effect.provide(layer)), @@ -259,6 +265,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "t3code/codex-turn-mapping", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: false, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 49b2ea31a08c19857966468a0b62728b035235b1..85736a95c5ddcc4fb4c6a9d76513ceaa028da898 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -206,6 +206,8 @@ export interface GitHubPullRequestSummary { readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -367,7 +369,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -399,7 +401,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 7faa2fe351efc0835cb109cee751955d8e1a8c61..a025ce5ec8003ddc83130cc182bd71176c1ef0e4 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -60,6 +60,8 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = baseRefName: "main", headRefName: "feature/source-control", state: "open", + closedAt: null, + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", @@ -125,6 +127,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = baseRefName: "main", headRefName: "feature/merged", state: "merged", + mergedAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-02T00:00:00.000Z", }, ]), @@ -150,10 +153,11 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); + assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); assert.deepStrictEqual( changeRequests[0]?.updatedAt, Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 1a20b587256a68c376f3df08445b1056690be030..74f08a9a912732ad3029e2f9652bbe3779a93c0c 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -31,6 +31,8 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt === undefined ? Option.none() @@ -154,7 +156,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index eb56b434b2f8c8b0ff288edc2dd5368f4ee755d6..c5f22fe3088f3498ae45120441aba80bdd2026f7 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -46,7 +46,8 @@ layer("GitLabCli.layer", (it) => { web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", target_branch: "main", source_branch: "feature/mr-threads", - state: "opened", + state: "closed", + closed_at: "2026-08-23T10:00:00Z", source_project_id: 101, target_project_id: 100, source_project: { @@ -71,7 +72,9 @@ layer("GitLabCli.layer", (it) => { url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/mr-threads", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/t3code", headRepositoryOwnerLogin: "octocat", @@ -107,6 +110,7 @@ layer("GitLabCli.layer", (it) => { target_branch: " main ", source_branch: " feature/mr-list ", state: "merged", + merged_at: "2026-08-23T11:00:00Z", }, ]), ), @@ -130,6 +134,8 @@ layer("GitLabCli.layer", (it) => { baseRefName: "main", headRefName: "feature/mr-list", state: "merged", + closedAt: null, + mergedAt: "2026-08-23T11:00:00Z", }, ]); expect(mockedRun).toHaveBeenCalledWith( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index ab8dfbb5f334425b998cccd4b32cb7b68482c676..9f76a6182ce4d07e5fdf3a229187cad7f9c0efcb 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -247,6 +247,8 @@ export interface GitLabMergeRequestSummary { readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 0d06e066521499158b01a214144baf97c3b15897..3cd442a6e1695575fd0343c656bf4d1cb99e3489 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -24,7 +24,8 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", headRepositoryOwnerLogin: "fork", @@ -43,7 +44,9 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2ec1f9b9a228dfe4da304d1196c5f45a573a3890..28211c6b850955ef2ba47001349be48d1ebcfe2b 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -27,6 +27,8 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 8ac682399e1d13eb877c5830d0404eb96eefd724..24c0e49fd8f4b4ede651c567d038f7622d082925 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; } @@ -163,14 +165,21 @@ function normalizeAzureDevOpsPullRequestUrl( function normalizeAzureDevOpsPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedAzureDevOpsPullRequestRecord { + const state = normalizeAzureDevOpsPullRequestState(raw.status); + const terminalAt = Option.match(raw.closedDate ?? Option.none(), { + onNone: () => null, + onSome: DateTime.formatIso, + }); return { number: raw.pullRequestId, title: raw.title, url: normalizeAzureDevOpsPullRequestUrl(raw), baseRefName: normalizeRefName(raw.targetRefName), headRefName: normalizeRefName(raw.sourceRefName), - state: normalizeAzureDevOpsPullRequestState(raw.status), + state, ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: state === "closed" ? terminalAt : null, + mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( Option.orElse(() => raw.creationDate ?? Option.none()), ), diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index 9e4f282e1c8ae3b38680daceef9f08141ad5658c..822de1e02797594a62fe52426087afe2598e2e33 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedGitHubPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({ headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), @@ -96,6 +99,8 @@ function normalizeGitHubPullRequestRecord( headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: raw.closedAt ?? null, + mergedAt: raw.mergedAt ?? null, updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts index 3b032e245bbcc5900a65c0afe4efc92b87034883..0525260df51b2c4ac59ba0202133fcdaf90b94bc 100644 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedGitLabMergeRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -44,6 +46,8 @@ const GitLabMergeRequestSchema = Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), work_in_progress: Schema.optional(Schema.Boolean), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), @@ -112,6 +116,8 @@ function normalizeGitLabMergeRequestRecord( headRefName: raw.source_branch, state: normalizeGitLabMergeRequestState(raw.state), ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), + closedAt: raw.closed_at ?? null, + mergedAt: raw.merged_at ?? null, updatedAt: raw.updated_at ?? Option.none(), ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index e4aa17b383c8f046f202cf15ff1109dd8bae1113..227d13d18bb7e775ccf84c054394133bba22804e 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -627,6 +627,14 @@ export function PullRequestDetailPanel({ : { ...resolvedCoreDetail, ...sharedSummary, + closedAt: + sharedSummary.closedAt === undefined + ? resolvedCoreDetail.closedAt + : sharedSummary.closedAt, + mergedAt: + sharedSummary.mergedAt === undefined + ? resolvedCoreDetail.mergedAt + : sharedSummary.mergedAt, // A summary may come from an older server that does not report draft state. Keep the // detail's required value instead of making the complete detail shape partial. isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 4b63b877923f16fc38eca9f06d8b4d1d0d8f2246..345adcc7849cf0fd911578042097724b7bc8521d 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -201,11 +201,9 @@ const VcsStatusChangeRequest = Schema.Struct({ /** Optional for compatibility with older servers and providers. */ isDraft: Schema.optional(Schema.Boolean), /** - * Last provider-side activity (ISO). For a merged/closed change request - * this bounds when it reached that state, so clients can tell a PR that - * terminated during a thread's life from one that was already history - * when the thread was created. Optional for old servers and providers - * whose lookups do not report it. + * Last provider-side activity (ISO), including comments and metadata edits. + * This is not the time a change request closed or merged. Optional for old + * servers and providers whose lookups do not report it. */ updatedAt: Schema.optional(Schema.NullOr(Schema.String)), }); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index f766578bb1a3d63a52059fa2ac6963b99bbadf24..812489a5fb9f7b61c5ba51ee3ca9459d8e3ff7ee 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -638,6 +638,8 @@ export const PullRequestSummary = Schema.Struct({ isDraft: Schema.optional(Schema.Boolean), headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: IsoDateTime, }); export type PullRequestSummary = typeof PullRequestSummary.Type; diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index be3d70aefadda61fdbd27a1e79b8abc2f4e67037..b013eea3bb6fac49b6a9152f1205aaa33a876afe 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -31,6 +31,8 @@ export const ChangeRequest = Schema.Struct({ state: ChangeRequestState, /** Present when the provider can tell that an open change request is still a draft. */ isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.Option(Schema.DateTimeUtc), isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), -- 2.54.0