From 7b680cba3488e4f80362766d2b3c3e821a05b229 Mon Sep 17 00:00:00 2001 From: Terry Crapts Date: Tue, 3 Nov 2026 20:10:22 +0100 Subject: [PATCH 1/6] fix(server): wake GitLab MR watches when a rebase is required --- .../src/orchestration-v2/Orchestrator.ts | 1 + .../PullRequestSyncReactor.test.ts | 33 ++++++ .../PullRequestSyncReactor.ts | 4 +- .../PullRequestWatchReactor.ts | 2 + .../orchestration-v2/pullRequestWatch.test.ts | 31 +++++ .../src/orchestration-v2/pullRequestWatch.ts | 18 ++- .../src/orchestration-v2/runtimeLayer.test.ts | 110 ++++++++++++++++++ .../pullRequest/PullRequestService.test.ts | 4 + .../src/pullRequest/PullRequestService.ts | 10 ++ packages/contracts/src/pullRequest.ts | 3 + packages/contracts/src/threadPullRequest.ts | 2 + .../src/server/PullRequestProvider.ts | 2 + .../src/server/gitLabMergeRequestJson.test.ts | 31 +++++ .../src/server/gitLabMergeRequestJson.ts | 18 +++ 14 files changed, 265 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1059c8823078..757266fb3232 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3838,6 +3838,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio passed: existing.endedWatch.passed, passedChecks: existing.endedWatch.passedChecks, conflicting: existing.endedWatch.conflicting, + rebaseRequired: existing.endedWatch.rebaseRequired, }), ...(existing.endedWatch?.owedChecks !== undefined && { owedChecks: existing.endedWatch.owedChecks, diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index a1bacda0806e..d947c69207f8 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -595,6 +595,39 @@ describe("PullRequestSyncReactor", () => { }), ), ); + it.effect("syncs a rebase blocker even when the PR timestamp is unchanged", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const required = yield* Ref.make(false); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("rebase", { pullRequests: [makeLink(7)] })]), + summary: (input) => + Ref.get(required).pipe( + Effect.map((rebaseRequired) => makeSummary(input, { rebaseRequired })), + ), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* applyRecordedSyncs(fixture); + yield* Ref.set(required, true); + yield* reactor.requestSync({ + host: "github.com", + repository: "owner/repository", + number: 7, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + const commands = yield* Ref.get(fixture.syncCommands); + assert.equal(commands.length, 2); + assert.equal(commands[0]?.snapshot?.rebaseRequired, false); + assert.equal(commands[1]?.snapshot?.rebaseRequired, true); + assert.equal(commands[0]?.snapshot?.updatedAt, commands[1]?.snapshot?.updatedAt); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("snapshots an unsynced link once and writes it to the thread", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 25c062301e35..45ae6b792d86 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -211,6 +211,7 @@ function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields { ...(summary.reviewDecision === undefined ? {} : { reviewDecision: summary.reviewDecision }), ...(summary.checksState === undefined ? {} : { checksState: summary.checksState }), ...(summary.mergeability === undefined ? {} : { mergeability: summary.mergeability }), + ...(summary.rebaseRequired === undefined ? {} : { rebaseRequired: summary.rebaseRequired }), }; } @@ -259,7 +260,8 @@ function snapshotFieldsEqual(left: SnapshotFields, right: SnapshotFields): boole left.changedFiles === right.changedFiles && (left.reviewDecision ?? null) === (right.reviewDecision ?? null) && (left.checksState ?? null) === (right.checksState ?? null) && - left.mergeability === right.mergeability + left.mergeability === right.mergeability && + left.rebaseRequired === right.rebaseRequired ); } diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index e1669f011e6c..0a6071d51136 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -129,6 +129,7 @@ const snapshotFingerprint = ({ link }: WatchTarget) => { snapshot.updatedAt, snapshot.checksState, snapshot.mergeability, + snapshot.rebaseRequired, snapshot.reviewDecision, snapshot.isDraft, ].join(" "); @@ -178,6 +179,7 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc left.remarksThrough === right.remarksThrough && left.remarkIds.join("\n") === right.remarkIds.join("\n") && left.conflicting === right.conflicting && + left.rebaseRequired === right.rebaseRequired && left.wakes === right.wakes && left.checksAwaitedSince === right.checksAwaitedSince ); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 0b6070f36466..68e4aa72ae10 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -78,6 +78,37 @@ const remark = ( const noRemarks: ReadonlyArray = []; describe("evaluatePullRequestWatch", () => { + it.each(["pending", "success"] as const)( + "reports a required rebase once with %s CI", + (status) => { + const blocked = detail({ + mergeability: "unknown", + rebaseRequired: true, + checks: [check("test", status)], + }); + const first = evaluatePullRequestWatch(watch(), blocked, noRemarks); + assert.include( + first.changes.map((change) => change.kind), + "needs-rebase", + ); + assert.deepEqual(evaluatePullRequestWatch(first.next, blocked, noRemarks).changes, []); + const unknown = evaluatePullRequestWatch( + first.next, + { ...blocked, rebaseRequired: undefined }, + noRemarks, + ); + assert.deepEqual(evaluatePullRequestWatch(unknown.next, blocked, noRemarks).changes, []); + const clean = evaluatePullRequestWatch( + first.next, + { ...blocked, rebaseRequired: false }, + noRemarks, + ); + assert.deepEqual(evaluatePullRequestWatch(clean.next, blocked, noRemarks).changes, [ + { kind: "needs-rebase" }, + ]); + }, + ); + it("reports each failure at once, even while another check never finishes", () => { const bot = check("CodeRabbit", "pending"); const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 033125a6d6c9..e895fb4d1385 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -19,7 +19,8 @@ export type PullRequestWatchChange = | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean } | { readonly kind: "remarks"; readonly remarks: ReadonlyArray } | { readonly kind: "no-checks"; readonly minutes: number } - | { readonly kind: "conflicting" }; + | { readonly kind: "conflicting" } + | { readonly kind: "needs-rebase" }; export interface PullRequestWatchReport { readonly changes: ReadonlyArray; @@ -35,7 +36,7 @@ export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, detail: Pick< PullRequestDetail, - "capabilities" | "headSha" | "checks" | "mergeability" | "viewer" | "author" + "capabilities" | "headSha" | "checks" | "mergeability" | "rebaseRequired" | "viewer" | "author" >, remarks: ReadonlyArray | null, now: number, @@ -129,6 +130,10 @@ export function evaluatePullRequestWatch( if (detail.mergeability === "conflicting" && !watch.conflicting) { changes.push({ kind: "conflicting" }); } + if (detail.rebaseRequired === true && !watch.rebaseRequired) { + changes.push({ kind: "needs-rebase" }); + } + const rebaseRequired = detail.rebaseRequired ?? watch.rebaseRequired; // "unknown" is a host still computing after a push; only a clean answer clears a conflict. const conflicting = detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting"; @@ -148,6 +153,7 @@ export function evaluatePullRequestWatch( remarksThrough, remarkIds, conflicting, + rebaseRequired, wakes, checksAwaitedSince, }, @@ -201,6 +207,8 @@ function changeLines( return [ `- No checks have reported${context.commit} after ${change.minutes} minutes. None may be configured to run on this change.`, ]; + case "needs-rebase": + return [`- The branch requires a rebase onto ${context.baseBranch}.`]; case "conflicting": return [`- The branch now conflicts with ${context.baseBranch}.`]; } @@ -212,6 +220,7 @@ const SUMMARY: Record = { remarks: "new comments", "no-checks": "no checks reported", conflicting: "merge conflict", + "needs-rebase": "rebase required", }; export function pullRequestWatchMessage(input: { @@ -235,7 +244,10 @@ export function pullRequestWatchMessage(input: { : "Look into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", ].join("\n"); const failed = changes.some( - (change) => change.kind === "checks-failed" || change.kind === "conflicting", + (change) => + change.kind === "checks-failed" || + change.kind === "conflicting" || + change.kind === "needs-rebase", ); const summary = changes.map((change) => SUMMARY[change.kind]); if (exhausted) summary.push("stopped watching"); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b38fb737c91d..1faf5faa42fb 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3426,6 +3426,116 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each(["pending", "success"] as const)( + "wakes once when GitLab requires a rebase with %s CI", + (status) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const threadId = ThreadId.make(`watch-rebase-${status}`); + const projectId = ProjectId.make(`watch-rebase-${status}`); + yield* seedProject({ + projectId, + title: "Rebase wake", + workspaceRoot: "/workspace/watch-rebase", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`watch-rebase-create-${status}`), + threadId, + projectId, + title: "Rebase wake", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "gitlab.example.test", repository: "acme/app", number: 7 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`watch-rebase-start-${status}`), + threadId, + ...key, + watching: true, + link: { url: "https://gitlab.example.test/acme/app/-/merge_requests/7", source: "agent" }, + }); + let rebaseRequired = false; + const detail = { + ...watchedPullRequestDetail({ projectId, number: 7, at: "2026-10-02T12:00:00.000Z" }), + provider: SourceControlProviderKind.make("gitlab"), + repository: key.repository, + checks: [{ name: "test", status, description: null, url: null }], + }; + let reads = 0; + const host = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: () => Effect.succeed(null), + detail: () => + Effect.sync(() => { + reads += 1; + return { ...detail, rebaseRequired }; + }), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + const before = yield* orchestrator.getThreadProjection(threadId); + const readsBefore = reads; + rebaseRequired = true; + yield* orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make(`watch-rebase-snapshot-${status}`), + threadId, + ...key, + snapshot: { + state: "open", + title: detail.title, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + isDraft: false, + updatedAt: detail.updatedAt, + syncedAt: detail.updatedAt, + rebaseRequired, + }, + stack: null, + }); + yield* reactor.sweep; + assert.equal(reads, readsBefore + 1); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal(after.messages.length, before.messages.length + 1); + const wake = after.messages.find( + (message) => !before.messages.some((seen) => seen.id === message.id), + ); + assert.equal(wake?.notification?.summary, "#7: rebase required"); + assert.equal(wake?.notification?.outcome, "failed"); + assert.equal(after.runs.length, before.runs.length + 1); + assert.isTrue(after.thread.pullRequests?.[0]?.watch?.rebaseRequired); + yield* reactor.sweep; + assert.isTrue((yield* maintenance.rebuild).valid); + const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* restarted.sweep; + const replayed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(replayed.messages.length, after.messages.length); + assert.equal(replayed.runs.length, after.runs.length); + assert.isTrue(replayed.thread.pullRequests?.[0]?.watch?.rebaseRequired); + }), + ); + it.effect("finishes owed checks once after pending sweeps, a restart, and a re-watch", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 0f068df5ef82..6123ccf7ec50 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2960,6 +2960,7 @@ it.effect( Effect.succeed({ ...changeRequest(1, "2026-07-02T00:00:00Z"), isDraft: true, + rebaseRequired: false, reviewDecision: "approved", checksState: "passing", }), @@ -2969,6 +2970,7 @@ it.effect( deletions: 3, changedFiles: 5, mergeability: "conflicting", + rebaseRequired: true, }), }), ], @@ -2976,6 +2978,8 @@ it.effect( yield* service.summary(reference); const detail = yield* service.detail(reference); const summary = yield* service.summary(reference); + assert.strictEqual(detail.rebaseRequired, true); + assert.strictEqual(summary.rebaseRequired, true); assert.strictEqual(summary.isDraft, false); assert.deepStrictEqual(summary.author, detail.author); assert.strictEqual(summary.additions, 14); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index e77434c1697b..dd733021f153 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1240,6 +1240,9 @@ export const make = Effect.gen(function* () { state: input.item.state, isDraft: input.item.isDraft, mergeability: input.item.mergeability, + ...(input.item.rebaseRequired === undefined + ? {} + : { rebaseRequired: input.item.rebaseRequired }), additions: input.item.additions, deletions: input.item.deletions, createdAt: input.item.createdAt, @@ -1700,6 +1703,9 @@ export const make = Effect.gen(function* () { ...(changeRequest.mergeability === undefined ? {} : { mergeability: changeRequest.mergeability }), + ...(changeRequest.rebaseRequired === undefined + ? {} + : { rebaseRequired: changeRequest.rebaseRequired }), ...(changeRequest.stack === undefined ? {} : { stack: changeRequest.stack }), })), ); @@ -1771,6 +1777,9 @@ export const make = Effect.gen(function* () { state: changeRequest.state, isDraft: changeRequest.isDraft, mergeability: changeRequest.mergeability, + ...(changeRequest.rebaseRequired === undefined + ? {} + : { rebaseRequired: changeRequest.rebaseRequired }), additions: changeRequest.additions, deletions: changeRequest.deletions, changedFiles: changeRequest.changedFiles, @@ -3152,6 +3161,7 @@ export const make = Effect.gen(function* () { deletions: detail.deletions, changedFiles: detail.changedFiles, mergeability: detail.mergeability, + ...(detail.rebaseRequired === undefined ? {} : { rebaseRequired: detail.rebaseRequired }), headBranch: detail.headBranch, baseBranch: detail.baseBranch, closedAt: detail.closedAt, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 64111ab4742b..e5d4cdff5197 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -331,6 +331,7 @@ export const PullRequestListEntry = Schema.Struct({ state: PullRequestState, isDraft: Schema.Boolean, mergeability: PullRequestMergeability, + rebaseRequired: Schema.optional(Schema.Boolean), /** Zero until fetched through `PullRequestListStatsInput`; GitHub listings omit counts. */ additions: NonNegativeInt, deletions: NonNegativeInt, @@ -473,6 +474,7 @@ export const PullRequestSummary = Schema.Struct({ reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)), checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)), mergeability: Schema.optional(PullRequestMergeability), + rebaseRequired: Schema.optional(Schema.Boolean), /** Null when the host says the pull request is in no stack; absent when the read did not ask. */ stack: Schema.optional(Schema.NullOr(PullRequestStackMembership)), }); @@ -568,6 +570,7 @@ export const PullRequestDetail = Schema.Struct({ state: PullRequestState, isDraft: Schema.Boolean, mergeability: PullRequestMergeability, + rebaseRequired: Schema.optional(Schema.Boolean), additions: NonNegativeInt, deletions: NonNegativeInt, changedFiles: NonNegativeInt, diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 3daee9ce197e..57d42599f59e 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -51,6 +51,7 @@ export const ThreadPullRequestSnapshot = Schema.Struct({ reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)), checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)), mergeability: Schema.optional(PullRequestMergeability), + rebaseRequired: Schema.optional(Schema.Boolean), }); export type ThreadPullRequestSnapshot = typeof ThreadPullRequestSnapshot.Type; @@ -93,6 +94,7 @@ export const ThreadPullRequestWatch = Schema.Struct({ /** Reported remarks at remarksThrough, so late arrivals at that timestamp still count. */ remarkIds: Schema.Array(TrimmedNonEmptyString), conflicting: Schema.Boolean, + rebaseRequired: Schema.optional(Schema.Boolean), /** Consecutive comment-only wakes; limited to prevent bot loops. */ wakes: NonNegativeInt, /** diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 3156efdd7d80..7447864fb8ad 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -90,6 +90,7 @@ export interface ProviderChangeRequest { readonly state: PullRequestState; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; + readonly rebaseRequired?: boolean | undefined; readonly additions: number; readonly deletions: number; readonly createdAt: string; @@ -120,6 +121,7 @@ export interface ProviderChangeRequestSummary { readonly reviewDecision?: PullRequestReviewDecision | null | undefined; readonly checksState?: PullRequestChecksState | null | undefined; readonly mergeability?: PullRequestMergeability | undefined; + readonly rebaseRequired?: boolean | undefined; /** Null means no stack; absent means not requested. */ readonly stack?: PullRequestStackMembership | null | undefined; } diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts index 371c2e3f3c70..5eefa06c2b0f 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts @@ -120,6 +120,37 @@ describe("decodeMergeRequestListJson", () => { }); describe("decodeMergeRequestDetailJson", () => { + it("preserves a required rebase while CI is running or successful", () => { + for (const status of ["running", "success"]) { + const detail = expectSuccess( + decodeMergeRequestDetailJson( + detailJson({ + merge_status: "cannot_be_merged", + detailed_merge_status: "need_rebase", + has_conflicts: false, + head_pipeline: { status }, + }), + ), + ); + expect(detail.rebaseRequired).toBe(true); + expect(detail.mergeability).toBe("unknown"); + } + }); + + it("distinguishes a cleared rebase blocker from an inconclusive merge check", () => { + expect( + expectSuccess( + decodeMergeRequestDetailJson(detailJson({ detailed_merge_status: "mergeable" })), + ).rebaseRequired, + ).toBe(false); + for (const detailed_merge_status of [undefined, "checking", "ci_still_running"]) { + expect( + expectSuccess(decodeMergeRequestDetailJson(detailJson({ detailed_merge_status }))) + .rebaseRequired, + ).toBeUndefined(); + } + }); + it("reads the description, file count and pipeline", () => { const detail = expectSuccess( decodeMergeRequestDetailJson( diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index 79ab379ffbd4..28a75bc5eec1 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -58,6 +58,7 @@ const RawMergeRequestSchema = Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), work_in_progress: Schema.optional(Schema.Boolean), + detailed_merge_status: Schema.optional(Schema.NullOr(Schema.String)), merge_status: Schema.optional(Schema.NullOr(Schema.String)), has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), created_at: Schema.String, @@ -185,6 +186,7 @@ export interface GitLabMergeRequestListItem { readonly state: PullRequestState; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; + readonly rebaseRequired?: boolean | undefined; readonly additions: number; readonly deletions: number; readonly createdAt: string; @@ -234,6 +236,15 @@ function toMergeability( raw: Schema.Schema.Type, ): PullRequestMergeability { if (raw.has_conflicts === true) return "conflicting"; + switch (raw.detailed_merge_status?.trim().toLowerCase()) { + case "need_rebase": + // The branch needs updating; GitLab reports actual content conflicts separately. + return "unknown"; + case "conflict": + return "conflicting"; + case "mergeable": + return "mergeable"; + } switch (raw.merge_status?.trim().toLowerCase()) { case "can_be_merged": return "mergeable"; @@ -292,6 +303,7 @@ function toChecks( function toListItem( raw: Schema.Schema.Type, ): GitLabMergeRequestListItem { + const detailedMergeStatus = raw.detailed_merge_status?.trim().toLowerCase(); return { number: raw.iid, title: raw.title, @@ -302,6 +314,12 @@ function toListItem( state: toState(raw), isDraft: raw.draft ?? raw.work_in_progress ?? false, mergeability: toMergeability(raw), + rebaseRequired: + detailedMergeStatus === "need_rebase" + ? true + : detailedMergeStatus === "mergeable" + ? false + : undefined, additions: 0, deletions: 0, createdAt: raw.created_at, From ff36ecf997f0c26888ab492c1e0f0640ceaad683 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 09:09:52 +0000 Subject: [PATCH 2/6] test(server): use ProjectionReplay in rebase watch test after merging main --- apps/server/src/orchestration-v2/runtimeLayer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 66644a1ce39f..34b51f1a5562 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3508,7 +3508,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { (status) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const maintenance = yield* ProjectionReplay.ProjectionReplay; const threadId = ThreadId.make(`watch-rebase-${status}`); const projectId = ProjectId.make(`watch-rebase-${status}`); yield* seedProject({ From 0360512399e14ba69f8f79859e076fae93712b7f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 09:12:02 +0000 Subject: [PATCH 3/6] fix(server): keep detailed_merge_status in GitLab mergeability input after merging main --- .../src/server/gitLabMergeRequestJson.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index 4de5abe6c01c..c6dddff27c62 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -260,7 +260,10 @@ function toState( } function toMergeability( - raw: Pick, "has_conflicts" | "merge_status">, + raw: Pick< + Schema.Schema.Type, + "has_conflicts" | "merge_status" | "detailed_merge_status" + >, ): PullRequestMergeability { if (raw.has_conflicts === true) return "conflicting"; switch (raw.detailed_merge_status?.trim().toLowerCase()) { From 3c7dc9eb3899a1f61a2552f499a46cf9f5949b0d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 22:55:01 +0000 Subject: [PATCH 4/6] fix(server): keep GitLab mergeability when a rebase is required need_rebase no longer turns a mergeable MR into unknown, so the base freshness row and its Rebase control stay on fast-forward-only projects. rebaseRequired alone drives the watch wake. Co-Authored-By: Claude Opus 5.5 --- .../src/server/gitLabMergeRequestJson.test.ts | 4 ++-- .../src/server/gitLabMergeRequestJson.ts | 14 +------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts index d75fa9a65381..4ecfa5768b0a 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts @@ -131,7 +131,7 @@ describe("decodeMergeRequestDetailJson", () => { const detail = expectSuccess( decodeMergeRequestDetailJson( detailJson({ - merge_status: "cannot_be_merged", + merge_status: "can_be_merged", detailed_merge_status: "need_rebase", has_conflicts: false, head_pipeline: { status }, @@ -139,7 +139,7 @@ describe("decodeMergeRequestDetailJson", () => { ), ); expect(detail.rebaseRequired).toBe(true); - expect(detail.mergeability).toBe("unknown"); + expect(detail.mergeability).toBe("mergeable"); } }); diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index 1d1362a57f2a..f5b8afa879ff 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -269,21 +269,9 @@ function toState( } function toMergeability( - raw: Pick< - Schema.Schema.Type, - "has_conflicts" | "merge_status" | "detailed_merge_status" - >, + raw: Pick, "has_conflicts" | "merge_status">, ): PullRequestMergeability { if (raw.has_conflicts === true) return "conflicting"; - switch (raw.detailed_merge_status?.trim().toLowerCase()) { - case "need_rebase": - // The branch needs updating; GitLab reports actual content conflicts separately. - return "unknown"; - case "conflict": - return "conflicting"; - case "mergeable": - return "mergeable"; - } switch (raw.merge_status?.trim().toLowerCase()) { case "can_be_merged": return "mergeable"; From e17f8dbc27f19b523c950b532b9864ca5ad830e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 23:00:57 +0000 Subject: [PATCH 5/6] fix(server): re-report a required rebase a push did not clear A wake queued for the old head is dropped as stale once a push lands, so a rebase that is still required is news for the new head, like a conflict. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/pullRequestWatch.test.ts | 8 ++++++++ apps/server/src/orchestration-v2/pullRequestWatch.ts | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index de829516c85b..5a836898bb66 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -109,6 +109,14 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(evaluatePullRequestWatch(clean.next, blocked, noRemarks).changes, [ { kind: "needs-rebase" }, ]); + assert.include( + evaluatePullRequestWatch( + first.next, + { ...blocked, headSha: "bbbbbbbbbb" }, + noRemarks, + ).changes.map((change) => change.kind), + "needs-rebase", + ); }, ); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 1e1fab8a7d7a..a6603aad97c3 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -198,12 +198,13 @@ export function evaluatePullRequestWatch( ...atLatest.map((remark) => remark.id), ]; - // Like a failed check, a conflict a push did not resolve is news for the new head. + // Like a failed check, a conflict or rebase a push did not resolve is news for the new head. const toldConflict = !headMoved && watch.conflicting; if (watch.awaitingBaseline !== "all" && detail.mergeability === "conflicting" && !toldConflict) { changes.push({ kind: "conflicting" }); } - if (watch.awaitingBaseline !== "all" && detail.rebaseRequired === true && !watch.rebaseRequired) { + const toldRebase = !headMoved && watch.rebaseRequired === true; + if (watch.awaitingBaseline !== "all" && detail.rebaseRequired === true && !toldRebase) { changes.push({ kind: "needs-rebase" }); } const rebaseRequired = detail.rebaseRequired ?? watch.rebaseRequired; From b701a16f128402e32dbc25dc6685cc33857f9ce4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 00:09:34 +0000 Subject: [PATCH 6/6] fix(server): wake GitLab watches when a target branch moves on The watch fingerprint read only mergeStatusEnum, which a fast-forward project leaves at CAN_BE_MERGED once GitLab settles need_rebase. A sweep that missed the short UNCHECKED window saw no change and skipped the detail read for 30 minutes. detailedMergeStatus now counts. Co-Authored-By: Claude Opus 5.5 --- .../src/server/gitLabMergeRequestJson.test.ts | 3 +++ .../src/server/gitLabMergeRequestJson.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts index 4ecfa5768b0a..4ec7ce9a33bc 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts @@ -1297,6 +1297,7 @@ describe("decodeMergeRequestWatchFingerprintJson", () => { mergeRequest: { state: "opened", mergeStatusEnum: "CAN_BE_MERGED", + detailedMergeStatus: "MERGEABLE", conflicts: false, diffHeadSha: "a".repeat(40), userPermissions: { updateMergeRequest: true }, @@ -1338,6 +1339,8 @@ describe("decodeMergeRequestWatchFingerprintJson", () => { expect(edited?.remarks).not.toBe(base?.remarks); expect(resolved?.remarks).toBe(base?.remarks); expect(read({ userNotesCount: 3 })?.remarks).not.toBe(base?.remarks); + // A target branch moving on leaves merge_status at can_be_merged on fast-forward projects. + expect(read({ detailedMergeStatus: "NEED_REBASE" })?.status).not.toBe(base?.status); }); // Only the REST read asks GitLab to recheck mergeability, and only for viewers who can update. diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index f5b8afa879ff..24ad19248eaf 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -2072,7 +2072,7 @@ export function decodeMergeRequestFileStatsJson(raw: string): Result.Result< export const MERGE_REQUEST_WATCH_FINGERPRINT_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!) { project(fullPath: $fullPath) { mergeRequest(iid: $iid) { - state mergeStatusEnum conflicts diffHeadSha + state mergeStatusEnum detailedMergeStatus conflicts diffHeadSha userPermissions { updateMergeRequest } headPipeline { id status @@ -2106,6 +2106,7 @@ const decodeMergeRequestWatchFingerprint = decodeJsonResult( Schema.Struct({ state: Schema.String, mergeStatusEnum: Schema.NullOr(Schema.String), + detailedMergeStatus: Schema.optional(Schema.NullOr(Schema.String)), conflicts: Schema.Boolean, diffHeadSha: Schema.NullOr(Schema.String), userPermissions: Schema.Struct({ updateMergeRequest: Schema.Boolean }), @@ -2191,6 +2192,7 @@ export function decodeMergeRequestWatchFingerprintJson( status: [ mr.state, mr.mergeStatusEnum ?? "", + mr.detailedMergeStatus ?? "", mr.conflicts, mr.diffHeadSha ?? "", pipeline?.id ?? "",