diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 5050ac3272894..5cb19157e7085 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -4,6 +4,7 @@ import { type OrchestrationV2Notification, type PullRequestActivity, type PullRequestComment, + type PullRequestDetail, type PullRequestRef, type PullRequestThreadCommentsResult, type ThreadPullRequestLink, @@ -38,6 +39,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import { evaluatePullRequestWatch, PULL_REQUEST_WATCH_NO_CHECKS_MS, + pullRequestWatchEndMessage, pullRequestWatchMessage, } from "./pullRequestWatch.ts"; @@ -399,27 +401,23 @@ export const make = Effect.gen(function* () { ), ); - const closed = (target: WatchTarget) => - record(target, null, { - text: `Pull request #${target.link.number} (${target.link.url}) was closed, so T3 Code stopped watching it. Call watch_pull_request if it reopens.`, - notification: { - source: { kind: "monitor" }, - outcome: "updated", - summary: `#${target.link.number}: closed, stopped watching`, - }, - }).pipe( + const finished = (target: WatchTarget, detail: PullRequestDetail) => + record( + target, + null, + pullRequestWatchEndMessage({ number: target.link.number, url: target.link.url, detail }), + ).pipe( Effect.tap(() => woke(target)), - Effect.tap(() => ended(target, "closed")), + Effect.tap(() => ended(target, detail.state === "merged" ? "merged" : "closed")), ); - const endsWithoutRead = ({ thread, link }: WatchTarget): WatchEndReason | undefined => - link.snapshot?.state === "merged" - ? "merged" - : thread.settledOverride === "settled" || thread.settledAt !== null - ? "settled" - : thread.lineage.relationshipToParent === "subagent" - ? "subagent" - : undefined; + // A merged snapshot still gets one read, so the wake can name the merge commit. + const endsWithoutRead = ({ thread }: WatchTarget): WatchEndReason | undefined => + thread.settledOverride === "settled" || thread.settledAt !== null + ? "settled" + : thread.lineage.relationshipToParent === "subagent" + ? "subagent" + : undefined; const eachTarget = ( group: WatchGroup, @@ -528,11 +526,7 @@ export const make = Effect.gen(function* () { } if (detail.state !== "open") { lastReads.delete(group.key); - return yield* eachTarget(group, (target) => - detail.state === "closed" - ? closed(target) - : record(target, null).pipe(Effect.tap(() => ended(target, "merged"))), - ); + return yield* eachTarget(group, (target) => finished(target, detail)); } // Never advance the remark watermark past comments an incomplete read could have missed. diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 6ab0191649490..72614e330b6c9 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -8,6 +8,7 @@ import { assert, describe, it } from "@effect/vitest"; import { PULL_REQUEST_WATCH_WAKE_LIMIT, evaluatePullRequestWatch as evaluate, + pullRequestWatchEndMessage, pullRequestWatchMessage, } from "./pullRequestWatch.ts"; @@ -436,3 +437,42 @@ describe("evaluatePullRequestWatch", () => { assert.isTrue(anHourLater(watch(), silent).every((kinds) => kinds.length === 0)); }); }); + +describe("pullRequestWatchEndMessage", () => { + const url = "https://git.cafe/pingdotgg/t3code/pull/7"; + const end = (overrides: Partial[0]["detail"]>) => + pullRequestWatchEndMessage({ + number: 7, + url, + detail: { state: "merged", baseBranch: "main", ...overrides }, + }); + const mergeCommitSha = "3b2f958603a51d8c42c4ede02eee7b8cb310b2d9"; + + it("says how the pull request ended, with the merge commit and merger the host reports", () => { + const merged = end({ + mergeCommitSha, + mergedBy: { login: "juliusmarminge", name: null, avatarUrl: null }, + }); + assert.equal( + merged.text, + `Pull request #7 (${url}) was merged into main by juliusmarminge as 3b2f958, so T3 Code stopped watching it.`, + ); + assert.deepEqual(merged.notification, { + source: { kind: "monitor" }, + outcome: "completed", + summary: "#7: merged, stopped watching", + }); + // GitCafe reports a merge made on GitHub without who made it. + assert.equal( + end({ mergeCommitSha }).text, + `Pull request #7 (${url}) was merged into main as 3b2f958, so T3 Code stopped watching it.`, + ); + + const closed = end({ state: "closed" }); + assert.equal( + closed.text, + `Pull request #7 (${url}) was closed without merging, so T3 Code stopped watching it. If it is reopened, call watch_pull_request to watch it again.`, + ); + assert.equal(closed.notification.summary, "#7: closed, stopped watching"); + }); +}); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index f4c0000847236..f4b799dcc3328 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -221,6 +221,33 @@ const SUMMARY: Record = { conflicting: "merge conflict", }; +export function pullRequestWatchEndMessage(input: { + readonly number: number; + readonly url: string; + readonly detail: Pick; +}): { readonly text: string; readonly notification: OrchestrationV2Notification } { + const { detail } = input; + const merged = detail.state === "merged"; + const how = merged + ? [ + `was merged into ${detail.baseBranch}`, + detail.mergedBy ? ` by ${detail.mergedBy.login}` : "", + detail.mergeCommitSha ? ` as ${detail.mergeCommitSha.slice(0, 7)}` : "", + ].join("") + : "was closed without merging"; + return { + text: [ + `Pull request #${input.number} (${input.url}) ${how}, so T3 Code stopped watching it.`, + ...(merged ? [] : ["If it is reopened, call watch_pull_request to watch it again."]), + ].join(" "), + notification: { + source: { kind: "monitor" }, + outcome: merged ? "completed" : "updated", + summary: `#${input.number}: ${merged ? "merged" : "closed"}, stopped watching`, + }, + }; +} + export function pullRequestWatchMessage(input: { readonly number: number; readonly url: string; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b9f5cd049eec9..5101371b3d83d 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3416,6 +3416,202 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + // regression: watched #26248 merged and its agent kept saying it was still watching + it.effect("wakes once when a watched pull request merges, naming the merge commit", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-merged"); + const projectId = ProjectId.make("pr-watch-merged-project"); + yield* seedProject({ + projectId, + title: "Watch merged", + workspaceRoot: "/workspace/watch-merged", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-merged-create"), + threadId, + projectId, + title: "Watch merged", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-merged-start"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 15, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/15", source: "agent" }, + }); + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.succeed({ + ...watchedPullRequestDetail({ + projectId, + number: 15, + at: "2026-10-02T12:00:00.000Z", + }), + state: "merged" as const, + mergedAt: "2026-10-02T12:00:00.000Z", + mergeCommitSha: "3b2f958603a51d8c42c4ede02eee7b8cb310b2d9", + mergedBy: { login: "juliusmarminge", name: null, avatarUrl: null }, + }), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ), + ), + ); + yield* reactor.sweep; + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + + assert.isUndefined((yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch); + const wakes = (yield* orchestrator.getThreadRecords(threadId, ["messages"])).messages.filter( + (message) => message.notification != null, + ); + assert.deepEqual( + wakes.map((message) => message.notification?.summary), + ["#15: merged, stopped watching"], + ); + assert.include(wakes[0]!.text, "merged into main by juliusmarminge as 3b2f958"); + }), + ); + + it.effect("a pull request closed, reopened, and then merged wakes once for each end", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-reopened"); + const projectId = ProjectId.make("pr-watch-reopened-project"); + yield* seedProject({ + projectId, + title: "Watch reopened", + workspaceRoot: "/workspace/watch-reopened", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-reopened-create"), + threadId, + projectId, + title: "Watch reopened", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 16 }; + const watch = (id: string) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-reopened-${id}`), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/16", source: "agent" }, + }); + yield* watch("start"); + + let state: PullRequestDetail["state"] = "closed"; + const host = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => ({ + ...watchedPullRequestDetail({ + projectId, + number: 16, + at: "2026-10-02T12:00:00.000Z", + }), + state, + checks: [{ name: "lint", status: "pending" as const, description: null, url: null }], + })), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const summaries = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ); + const watching = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch !== undefined, + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + assert.deepEqual(yield* summaries, ["#16: closed, stopped watching"]); + assert.isFalse(yield* watching); + + // Reopening does not restart the watch; the agent was told to watch again. + state = "open"; + yield* watch("again"); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.isTrue(yield* watching); + assert.deepEqual(yield* summaries, ["#16: closed, stopped watching"]); + + // The link's own sync can see the merge before the watch reads it. + state = "merged"; + yield* orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make("pr-watch-reopened-synced"), + threadId, + ...key, + snapshot: { + state: "merged", + title: "Watched pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-10-02T12:30:00.000Z", + mergedAt: "2026-10-02T12:30:00.000Z", + }, + stack: null, + }); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* summaries, [ + "#16: closed, stopped watching", + "#16: merged, stopped watching", + ]); + assert.isFalse(yield* watching); + }), + ); + it.effect("wakes once for a state it reported, across a restart and a new watch", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 0140a3aa96e2c..4baf9e242b78f 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1833,6 +1833,10 @@ export const make = Effect.gen(function* () { updatedAt: changeRequest.updatedAt, observedAt, mergedAt: changeRequest.mergedAt, + ...(changeRequest.mergeCommitSha + ? { mergeCommitSha: changeRequest.mergeCommitSha } + : {}), + ...(changeRequest.mergedBy ? { mergedBy: changeRequest.mergedBy } : {}), closedAt: changeRequest.closedAt, reviewers: changeRequest.reviewers, labels: changeRequest.labels, diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 949bddd8afbf0..4b70d8273527d 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -306,8 +306,9 @@ the thread is active, the server checks the pull request every two minutes and w check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict. Threads in a project that watch the same pull request share one check. On GitHub, a check first asks whether anything changed and reads the pull request only when it did, which keeps -watching inside GitHub's rate limit. Comments from your own account do not wake it. Watching ends -when the pull request merges or closes, after 10 wakes in a row that bring only comments, after 8 +watching inside GitHub's rate limit. Comments from your own account do not wake it. When the pull +request merges or closes, the agent is woken once and watching ends; a reopened pull request needs a +new watch. Watching also ends after 10 wakes in a row that bring only comments, after 8 failed reads in a row, or when you press Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all its watches. Unsettle the thread before starting a new watch. Watching a pull request again does not repeat check or conflict news its last diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 72199673f7880..9f3b8dbf9e65b 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -622,6 +622,8 @@ export const PullRequestDetail = Schema.Struct({ updatedAt: IsoDateTime, observedAt: Schema.optional(Schema.Finite), mergedAt: Schema.NullOr(IsoDateTime), + mergeCommitSha: Schema.optional(TrimmedNonEmptyString), + mergedBy: Schema.optional(PullRequestActor), closedAt: Schema.NullOr(IsoDateTime), reviewers: Schema.Array(PullRequestActor), labels: Schema.Array(PullRequestLabel), diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 6dc635b5153a6..b55c29477ae65 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -293,7 +293,7 @@ function pullRequestWatchTasks( pullRequests: ReadonlyArray | undefined, ): Array { return (pullRequests ?? []).flatMap((link) => - // The watch reactor ends a merged PR's watch without waking the agent, so it holds nothing. + // A merged PR's watch ends at the next sweep, whose wake starts its own run, so it holds nothing. link.watch === undefined || link.source === "stack-dismissed" || link.snapshot?.state === "merged" diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 0c58f64c47a1f..5062afd22153c 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -200,6 +200,8 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest { readonly body: string; readonly changedFiles: number; readonly mergedAt: string | null; + readonly mergeCommitSha?: string | null; + readonly mergedBy?: PullRequestActor | null; readonly closedAt: string | null; readonly reviewers: ReadonlyArray; readonly checks: ReadonlyArray; diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index 3a089e65c04bf..244c3a888feab 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -368,6 +368,8 @@ export const make = Effect.gen(function* () { return { ...forgejoChangeRequest(pr), headSha: pr.head.sha, + mergeCommitSha: pr.merge_commit_sha || null, + mergedBy: forgejoActor(pr.merged_by), body: pr.body ?? "", changedFiles: pr.changed_files ?? 0, reviewers: (pr.requested_reviewers ?? []).flatMap((user) => { diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index fbfe278b32ba4..aadcc63a7fba4 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -136,6 +136,7 @@ const requiredChecksInput = { const requiredChecksLayer = ( branch: Effect.Effect, statuses = requiredChecksStatuses, + pull: Readonly> = requiredChecksPullRequest, ) => Layer.mock(ForgejoCli.ForgejoCli)({ api: (input) => { @@ -144,7 +145,7 @@ const requiredChecksLayer = ( encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput)); switch (path) { case "repos/acme/web/pulls/7": - return respond(requiredChecksPullRequest); + return respond(pull); case "repos/acme/web/statuses/head": return respond(new URLSearchParams(query).get("page") === "1" ? statuses : []); case "repos/acme/web/branches/release/1.x": @@ -419,6 +420,28 @@ it.effect.each([ }).pipe(Effect.provide(requiredChecksLayer(Effect.fail(cause)))); }); +it.effect("reads a merged pull request's merge commit and who merged it", () => + Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const detail = yield* provider.getChangeRequest(requiredChecksInput); + assert.strictEqual(detail.state, "merged"); + assert.strictEqual(detail.mergeCommitSha, "394a757942c5c3a9fb3cca31e348849e8c6a6dbe"); + assert.deepStrictEqual(detail.mergedBy, { login: "gusted", name: null, avatarUrl: null }); + }).pipe( + Effect.provide( + requiredChecksLayer(Effect.succeed({ name: "release/1.x" }), [], { + ...requiredChecksPullRequest, + state: "closed", + merged: true, + closed_at: "2026-10-27T00:08:58+01:00", + merged_at: "2026-10-27T00:08:58+01:00", + merge_commit_sha: "394a757942c5c3a9fb3cca31e348849e8c6a6dbe", + merged_by: { login: "gusted" }, + }), + ), + ), +); + it.effect("reads Forgejo jobs that did not run as finished checks", () => Effect.gen(function* () { const provider = yield* ForgejoPullRequestProvider.make; diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts index 7a7daeac64f00..a2d31295bdd09 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts @@ -57,6 +57,8 @@ export const ForgejoPullRequest = Schema.Struct({ updated_at: Schema.String, closed_at: Schema.NullOr(Schema.String), merged_at: Schema.NullOr(Schema.String), + merge_commit_sha: Schema.optional(Schema.NullOr(Schema.String)), + merged_by: Schema.optional(Schema.NullOr(ForgejoUser)), additions: Schema.optional(Schema.NullOr(Schema.Int)), deletions: Schema.optional(Schema.NullOr(Schema.Int)), changed_files: Schema.optional(Schema.NullOr(Schema.Int)), diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts index cb2bf219c0b5e..4ddea55335bb2 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -1097,7 +1097,8 @@ describe("GitCafePullRequestProvider", () => { it.effect("reads a pull request GitHub merged as merged before the sync reports it", () => { const mergedAt = "2026-09-12T13:00:00Z"; - const server = detailWith({ providerMerge: { mergedAt } }); + const mergeCommitOid = "3b2f958603a51d8c42c4ede02eee7b8cb310b2d9"; + const server = detailWith({ providerMerge: { mergedAt, mergeCommitOid } }); return Effect.gen(function* () { const provider = yield* GitCafePullRequestProvider.make; const detail = yield* provider.getChangeRequest(target); @@ -1105,6 +1106,7 @@ describe("GitCafePullRequestProvider", () => { assert.strictEqual(detail.mergedAt, mergedAt); const preview = yield* provider.getChangeRequestPreview!(target); assert.strictEqual(preview.state, "merged"); + assert.strictEqual(detail.mergeCommitSha, mergeCommitOid); }).pipe(Effect.provide(server.layer)); }); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 314fc30902751..71ff3a317caa8 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -994,6 +994,7 @@ export const make = Effect.gen(function* () { changedFiles: changes?.changedFiles ?? 0, closedAt: pull.closedAt, mergedAt: pull.mergedAt, + mergeCommitSha: pull.mergeResultOid || pull.providerMerge?.mergeCommitOid || null, ...settledMerge(pull), reviewers: Json.toReviewers(listed, input.host), checks: checkList, diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts index cc692637b82fe..a874a83a92c55 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -88,9 +88,17 @@ export const GitCafePullDetail = Schema.Struct({ mergeRoute: Schema.optional(Schema.Literals(["native", "provider", "unsupported"])), lockedAt: Schema.optional(Schema.NullOr(IsoDateTime)), viewerActorId: Schema.optional(Schema.NullOr(Schema.String)), + mergeResultOid: Schema.optional(Schema.NullOr(Schema.String)), // Set once GitHub has merged a pull request GitCafe routed there, which can be before the // sync brings the merged state back. - providerMerge: Schema.optional(Schema.NullOr(Schema.Struct({ mergedAt: IsoDateTime }))), + providerMerge: Schema.optional( + Schema.NullOr( + Schema.Struct({ + mergedAt: IsoDateTime, + mergeCommitOid: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), capabilities: Schema.Struct({ comment: Schema.Boolean, review: Schema.Boolean, diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 8daf82277a083..cacfa40933bf3 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -70,6 +70,8 @@ const coreResponse = (pullRequest: Readonly> = {}) => ({ deletions: 0, changedFiles: 0, mergedAt: null, + mergeCommit: null, + mergedBy: null, closedAt: null, isCrossRepository: false, maintainerCanModify: true, diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index acccedcb40f38..e891551a15d8b 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -392,6 +392,8 @@ describe("pull request detail decoding", () => { createdAt: "2026-07-01T00:00:00Z", updatedAt: "2026-07-05T00:00:00Z", mergedAt: null, + mergeCommit: null, + mergedBy: null, closedAt: null, headRefName: "feat/detail", baseRefName: "main", @@ -550,6 +552,26 @@ describe("pull request detail decoding", () => { expect(detail.checksState).toBe("pending"); }); + it("reads a merged pull request's merge commit and who merged it", () => { + expect( + detailOf([], { + state: "MERGED", + mergedAt: "2026-07-05T00:00:00Z", + mergeCommit: { oid: "3b2f958603a51d8c42c4ede02eee7b8cb310b2d9" }, + mergedBy: { + __typename: "User", + login: "octocat", + avatarUrl: "https://avatars.githubusercontent.com/u/1", + name: "Octo Cat", + }, + }), + ).toMatchObject({ + state: "merged", + mergeCommitSha: "3b2f958603a51d8c42c4ede02eee7b8cb310b2d9", + mergedBy: { login: "octocat", name: "Octo Cat" }, + }); + }); + it("reads an auto-merge request and strategy, and its null as off", () => { expect(detailOf([], { autoMergeRequest: { mergeMethod: "SQUASH" } })).toMatchObject({ autoMergeEnabled: true, diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index df4756fcc0dda..a758e1418f3a2 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -1024,6 +1024,8 @@ export function toPullRequestWatchFingerprint( export interface GitHubPullRequestCore extends GitHubPullRequestDetail { readonly maintainerCanModify: boolean; readonly canEditMaintainerEdits: boolean; + readonly mergeCommitSha?: string | null; + readonly mergedBy?: PullRequestActor | null; readonly reviewRequestTeams: ReadonlyArray; readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; readonly comparison: GitHubBaseComparison | null; @@ -1077,6 +1079,8 @@ export function toPullRequestCore(result: PullRequestCore.Result): GitHubPullReq pr.headRepositoryOwner?.__typename === "User" && pr.headRepository !== null && pr.headRef !== null, + mergeCommitSha: trimmed(pr.mergeCommit?.oid), + mergedBy: toActor(pr.mergedBy), viewerAccess: { canWrite: toCanWrite(repository.viewerPermission), canDeleteBranch: toCanWrite(pr.headRepository?.viewerPermission) && pr.headRef !== null, diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql b/packages/source-control-github/src/server/pullRequestReads.graphql index ef40a4ab46bcd..f56c711125302 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql +++ b/packages/source-control-github/src/server/pullRequestReads.graphql @@ -82,6 +82,16 @@ query PullRequestCore( createdAt updatedAt mergedAt + mergeCommit { + oid + } + mergedBy { + login + avatarUrl + ... on User { + name + } + } closedAt headRefName baseRefName diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index 0a0f1aec68a3a..cdf1cde9ef5d1 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -206,6 +206,35 @@ export declare namespace ActorAvatars { export type Result = typeof ActorAvatars.result.Type } +/** Represents a Git commit. */ +export const MergeCommitCommit = Schema.Struct({ + /** The Git object ID */ + oid: Shared.GitObjectID +}) +export interface MergeCommitCommit extends Schema.Struct.Type {} + +/** A user is an individual's account on GitHub that owns repositories and can make new content. */ +export const MergedByUser = Schema.Struct({ + __typename: Schema.Literal("User"), + /** The username used to login. */ + login: Schema.String, + /** A URL pointing to the user's public avatar. */ + avatarUrl: Shared.URI, + /** The user's public profile name. */ + name: Schema.NullOr(Schema.String) +}) +export interface MergedByUser extends Schema.Struct.Type {} + +/** Represents an object which can take actions on GitHub. Typically a User or Bot. */ +export const ActorOther = Schema.Struct({ + __typename: GraphQL.otherTypename()(["User"]), + /** The username of the actor. */ + login: Schema.String, + /** A URL pointing to the actor's public avatar. */ + avatarUrl: Shared.URI +}) +export interface ActorOther extends Schema.Struct.Type {} + /** Represents an owner of a Repository. */ export const RepositoryOwner = Schema.Struct({ __typename: GraphQL.otherTypename()([]), @@ -231,7 +260,7 @@ export const HeadRefRef = Schema.Struct({ export interface HeadRefRef extends Schema.Struct.Type {} /** A user is an individual's account on GitHub that owns repositories and can make new content. */ -export const PullRequestCoreRepositoryPullRequestAuthorUser = Schema.Struct({ +export const AuthorUser = Schema.Struct({ __typename: Schema.Literal("User"), /** The username used to login. */ login: Schema.String, @@ -242,17 +271,7 @@ export const PullRequestCoreRepositoryPullRequestAuthorUser = Schema.Struct({ /** The user's public profile name. */ name: Schema.NullOr(Schema.String) }) -export interface PullRequestCoreRepositoryPullRequestAuthorUser extends Schema.Struct.Type {} - -/** Represents an object which can take actions on GitHub. Typically a User or Bot. */ -export const ActorOther = Schema.Struct({ - __typename: GraphQL.otherTypename()(["User"]), - /** The username of the actor. */ - login: Schema.String, - /** A URL pointing to the actor's public avatar. */ - avatarUrl: Shared.URI -}) -export interface ActorOther extends Schema.Struct.Type {} +export interface AuthorUser extends Schema.Struct.Type {} /** Represents an auto-merge request for a pull request */ export const AutoMergeRequest = Schema.Struct({ @@ -429,6 +448,13 @@ export const PullRequestCoreRepositoryPullRequestPullRequest = Schema.Struct({ updatedAt: Shared.DateTime, /** The date and time that the pull request was merged. */ mergedAt: Schema.NullOr(Shared.DateTime), + /** The commit that was created when this pull request was merged. */ + mergeCommit: Schema.NullOr(MergeCommitCommit), + /** The actor who merged the pull request. */ + mergedBy: Schema.NullOr(Schema.Union([ + MergedByUser, + ActorOther + ])), /** Identifies the date and time when the object was closed. */ closedAt: Schema.NullOr(Shared.DateTime), /** Identifies the name of the head Ref associated with the pull request, even if the ref has been deleted. */ @@ -449,7 +475,7 @@ export const PullRequestCoreRepositoryPullRequestPullRequest = Schema.Struct({ headRef: Schema.NullOr(HeadRefRef), /** The actor who authored the comment. */ author: Schema.NullOr(Schema.Union([ - PullRequestCoreRepositoryPullRequestAuthorUser, + AuthorUser, ActorOther ])), /** Returns the auto-merge request object if one exists for this pull request. */ @@ -503,7 +529,7 @@ export const PullRequestCore = GraphQL.query("PullRequestCore", { { name: "requestedReviewers", body: "Int!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed autoMergeAllowed viewerPermission pullRequest(number:$", "number", "){number title url body state isDraft mergeable mergeStateStatus reviewDecision additions deletions changedFiles createdAt updatedAt mergedAt closedAt headRefName baseRefName headRefOid isCrossRepository maintainerCanModify headRepositoryOwner{__typename login}headRepository{nameWithOwner viewerPermission}headRef{id}author{__typename login avatarUrl...on User{id name}}autoMergeRequest{mergeMethod}viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch baseRef{compare(headRef:$", "headRef", "){behindBy}}reviewRequests(first:$", "requestedReviewers", "){nodes{requestedReviewer{__typename...on User{login name}...on Bot{login}...on Team{slug combinedSlug teamName:name teamAvatarUrl:avatarUrl}}}}labels(first:100){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage endCursor}}}}}}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed autoMergeAllowed viewerPermission pullRequest(number:$", "number", "){number title url body state isDraft mergeable mergeStateStatus reviewDecision additions deletions changedFiles createdAt updatedAt mergedAt mergeCommit{oid}mergedBy{__typename login avatarUrl...on User{name}}closedAt headRefName baseRefName headRefOid isCrossRepository maintainerCanModify headRepositoryOwner{__typename login}headRepository{nameWithOwner viewerPermission}headRef{id}author{__typename login avatarUrl...on User{id name}}autoMergeRequest{mergeMethod}viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch baseRef{compare(headRef:$", "headRef", "){behindBy}}reviewRequests(first:$", "requestedReviewers", "){nodes{requestedReviewer{__typename...on User{login name}...on Bot{login}...on Team{slug combinedSlug teamName:name teamAvatarUrl:avatarUrl}}}}labels(first:100){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage endCursor}}}}}}}}"] } ], fragments: [ { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{name status conclusion startedAt completedAt detailsUrl isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")checkSuite{workflowRun{workflow{name}}}}}"] } @@ -577,18 +603,6 @@ export declare namespace PullRequestCheckContexts { export type Result = typeof PullRequestCheckContexts.result.Type } -/** A user is an individual's account on GitHub that owns repositories and can make new content. */ -export const PullRequestPreviewRepositoryPullRequestAuthorUser = Schema.Struct({ - __typename: Schema.Literal("User"), - /** The username used to login. */ - login: Schema.String, - /** A URL pointing to the user's public avatar. */ - avatarUrl: Shared.URI, - /** The user's public profile name. */ - name: Schema.NullOr(Schema.String) -}) -export interface PullRequestPreviewRepositoryPullRequestAuthorUser extends Schema.Struct.Type {} - /** A repository pull request. */ export const PullRequestPreviewRepositoryPullRequestPullRequest = Schema.Struct({ /** Identifies the pull request number. */ @@ -605,7 +619,7 @@ export const PullRequestPreviewRepositoryPullRequestPullRequest = Schema.Struct( createdAt: Shared.DateTime, /** The actor who authored the comment. */ author: Schema.NullOr(Schema.Union([ - PullRequestPreviewRepositoryPullRequestAuthorUser, + MergedByUser, ActorOther ])) }) @@ -728,7 +742,7 @@ export const NodesIssueComment = Schema.Struct({ isMinimized: Schema.Boolean, /** The actor who authored the comment. */ author: Schema.NullOr(Schema.Union([ - PullRequestPreviewRepositoryPullRequestAuthorUser, + MergedByUser, ActorOther ])) }) @@ -761,7 +775,7 @@ export const NodesPullRequestReview = Schema.Struct({ isMinimized: Schema.Boolean, /** The actor who authored the comment. */ author: Schema.NullOr(Schema.Union([ - PullRequestPreviewRepositoryPullRequestAuthorUser, + MergedByUser, ActorOther ])) }) @@ -780,7 +794,7 @@ export interface PullRequestReviewConnection extends Schema.Struct.Type {} export const ReviewerCandidatesRepositoryPullRequestReviewRequestsNodesReviewRequest = Schema.Struct({ /** The reviewer that is requested. */ requestedReviewer: Schema.NullOr(Schema.Union([ - PullRequestPreviewRepositoryPullRequestAuthorUser, + MergedByUser, Team, NodesBot, RequestedReviewerOther