From 823b0cbe95982d31473949cd7966f38abea7ee86 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:15:18 -0400 Subject: [PATCH 1/2] feat(web): pull requests set to merge when ready show GitHub's merge queue icon An open pull request that the host is landing on its own looked like any other open one: the PR state glyph in the sidebar, the command palette, the Pull Requests page rows, the PR tabs and the PR header only knew open, draft, merged and closed. The git status read that feeds the sidebar badge never asked GitHub for the auto-merge flag at all. The GitHub CLI reads now request `autoMergeRequest` alongside the branch's pull request, and the flag travels through the source-control change request and the VCS status contract to the client. The shared badge tone table gains an armed variant that draws GitHub's own merge queue glyph (Primer Octicons, MIT) in amber, and every surface that draws the state glyph reads it: the PR header also arms it from the queue position, since a queued pull request need not carry the standing instruction any more. --- apps/server/src/git/GitManager.test.ts | 6 ++-- apps/server/src/git/GitManager.ts | 7 ++++ .../src/sourceControl/GitHubCli.test.ts | 4 ++- apps/server/src/sourceControl/GitHubCli.ts | 6 ++-- .../GitHubSourceControlProvider.test.ts | 2 +- .../GitHubSourceControlProvider.ts | 5 ++- .../src/sourceControl/gitHubPullRequests.ts | 7 ++++ apps/web/src/components/Icons.tsx | 14 ++++++++ .../src/components/ThreadStatusIndicators.tsx | 4 +-- .../pull-requests/PullRequestDetailPanel.tsx | 8 ++++- .../pull-requests/PullRequestTabStrip.tsx | 2 +- .../pull-requests/PullRequestsView.tsx | 13 +++++--- .../pullRequestTabsStore.test.ts | 4 ++- .../pull-requests/pullRequestTabsStore.ts | 14 ++++++-- .../pull-requests/pullRequests.logic.test.ts | 33 +++++++++++++++++++ .../pull-requests/pullRequests.logic.ts | 22 ++++++++++++- .../components/sidebar/InboxRows.browser.tsx | 1 + packages/contracts/src/git.ts | 2 ++ packages/contracts/src/sourceControl.ts | 2 ++ 19 files changed, 135 insertions(+), 21 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 212e4578a..42697fe88 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -554,7 +554,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,autoMergeRequest,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), @@ -598,7 +598,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,autoMergeRequest,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), getRepositoryCloneUrls: (input) => @@ -1163,7 +1163,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,autoMergeRequest,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 11acd77ae..9fe6a917d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -140,6 +140,8 @@ interface OpenPrInfo { url: string; baseRefName: string; headRefName: string; + /** Armed to merge on its own; absent where the host did not say. */ + autoMergeEnabled?: boolean; } interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { @@ -339,6 +341,9 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { headRefName: summary.headRefName, state: summary.state ?? "open", updatedAt: summary.updatedAt, + ...(summary.autoMergeEnabled !== undefined + ? { autoMergeEnabled: summary.autoMergeEnabled } + : {}), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), @@ -516,6 +521,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: string; headRef: string; state: "open" | "closed" | "merged"; + autoMergeEnabled?: boolean; } { return { number: pr.number, @@ -524,6 +530,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, + ...(pr.autoMergeEnabled !== undefined ? { autoMergeEnabled: pr.autoMergeEnabled } : {}), }; } diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 227fdf5a4..df8c36649 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -48,6 +48,7 @@ describe("GitHubCli.layer", () => { headRefName: "feature/pr-threads", state: "OPEN", mergedAt: null, + autoMergeRequest: { enabledAt: "2026-08-31T09:00:00Z" }, isCrossRepository: true, headRepository: { nameWithOwner: "octocat/example-app", @@ -73,6 +74,7 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + autoMergeEnabled: true, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/example-app", headRepositoryOwnerLogin: "octocat", @@ -85,7 +87,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,autoMergeRequest,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", env: GITHUB_CLI_BACKGROUND_ENV, diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index f57bdaa22..2cfd89dbe 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -34,6 +34,8 @@ export interface GitHubPullRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + /** Armed to merge on its own once its requirements pass. */ + readonly autoMergeEnabled?: boolean; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; @@ -302,7 +304,7 @@ export const make = Effect.fn("makeGitHubCli")(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,autoMergeRequest,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -337,7 +339,7 @@ export const make = Effect.fn("makeGitHubCli")(function* () { input.reference, ...repositoryFlagArgs(input.repository), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,autoMergeRequest,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 88398e3c7..277d03617 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -140,7 +140,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,autoMergeRequest,isCrossRepository,headRepository,headRepositoryOwner", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 1dfd6d767..e0e332d9d 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -41,6 +41,9 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq headRefName: summary.headRefName, state: summary.state ?? "open", updatedAt: Option.none(), + ...(summary.autoMergeEnabled !== undefined + ? { autoMergeEnabled: summary.autoMergeEnabled } + : {}), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), @@ -181,7 +184,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,autoMergeRequest,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index beec9d9c8..bfa5a8f59 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 updatedAt: Option.Option; + /** Present only when the read asked for `autoMergeRequest`. */ + readonly autoMergeEnabled?: boolean; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; @@ -29,6 +31,8 @@ const GitHubPullRequestSchema = Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + /** Null while nothing is armed; an object (its fields unread here) while auto-merge is on. */ + autoMergeRequest: Schema.optional(Schema.NullOr(Schema.Struct({}))), isCrossRepository: Schema.optional(Schema.Boolean), headRepository: Schema.optional( Schema.NullOr( @@ -86,6 +90,9 @@ function normalizeGitHubPullRequestRecord( headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), updatedAt: raw.updatedAt ?? Option.none(), + ...(raw.autoMergeRequest !== undefined + ? { autoMergeEnabled: raw.autoMergeRequest !== null } + : {}), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } : {}), diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 5c500a741..e5ab17d3e 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -575,3 +575,17 @@ export const AntigravityIcon: Icon = (props) => ( ); + +/** + * A pull request the host is landing on its own: GitHub's own merge queue glyph + * (one change on its way down to the base, two more waiting beside it), so the + * badge reads the same here as on the pull request's page. + * + * From Primer Octicons (git-merge-queue-16), MIT licensed: + * https://github.com/primer/octicons + */ +export const MergeQueueIcon: Icon = (props) => ( + + + +); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 04ad0f89c..2b2731178 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -52,8 +52,8 @@ export function prStatusIndicator( ): PrStatusIndicator | null { if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); - const tone = pullRequestBadgeTone(pr.state, pr.isDraft); - const word = pr.isDraft ? "draft" : pr.state; + const tone = pullRequestBadgeTone(pr.state, pr.isDraft, pr.autoMergeEnabled); + const word = tone.label.toLowerCase(); return { label: `${presentation.shortName} ${word}`, colorClass: tone.className, diff --git a/apps/web/src/components/pull-requests/PullRequestDetailPanel.tsx b/apps/web/src/components/pull-requests/PullRequestDetailPanel.tsx index b70a90025..096fd7899 100644 --- a/apps/web/src/components/pull-requests/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pull-requests/PullRequestDetailPanel.tsx @@ -603,7 +603,13 @@ function PullRequestDetailHeader({ readonly onOpenThread: () => void; readonly handoffs: PullRequestHandoffActions | null; }) { - const tone = pullRequestBadgeTone(detail.state, detail.isDraft); + // A queued pull request need not carry a standing instruction any more, so + // the queue's own position arms the glyph as much as the instruction does. + const tone = pullRequestBadgeTone( + detail.state, + detail.isDraft, + detail.autoMergeEnabled === true || (detail.mergeQueue?.position ?? null) !== null, + ); const actions = usePullRequestActions({ environmentId, reference, detail, handoffs }); // A branch that no longer merges, said where the branches are named rather // than on a line of its own. diff --git a/apps/web/src/components/pull-requests/PullRequestTabStrip.tsx b/apps/web/src/components/pull-requests/PullRequestTabStrip.tsx index 281706627..39930a446 100644 --- a/apps/web/src/components/pull-requests/PullRequestTabStrip.tsx +++ b/apps/web/src/components/pull-requests/PullRequestTabStrip.tsx @@ -120,7 +120,7 @@ function PullRequestTabStripItem({ /** True when the keyboard ran the close, which then has to hand focus on. */ readonly onClose: (fromKeyboard: boolean) => void; }) { - const tone = pullRequestBadgeTone(tab.state, tab.isDraft); + const tone = pullRequestBadgeTone(tab.state, tab.isDraft, tab.autoMergeEnabled); return (
{ const byId = new Map(); for (const entry of [...loadedEntries, ...snapshot.entries]) { - byId.set(pullRequestTabId(entry), { state: entry.state, isDraft: entry.isDraft }); + byId.set(pullRequestTabId(entry), { + state: entry.state, + isDraft: entry.isDraft, + autoMergeEnabled: entry.autoMergeEnabled === true, + }); } return byId; }, [loadedEntries, snapshot.entries]); @@ -920,13 +925,14 @@ function PullRequestRow({ Icon: GlyphIcon, className: glyphClassName, label: glyphLabel, - } = pullRequestBadgeTone(entry.state, entry.isDraft); + } = pullRequestBadgeTone(entry.state, entry.isDraft, entry.autoMergeEnabled === true); // A branch that no longer merges is the one thing about an open row worth // more than its state, so it takes the glyph's place. const conflictLabel = pullRequestConflictLabel(entry); // Everything the row states in a glyph belongs in the name of the button that // opens it, since a glyph in a sibling is not part of that name: the state - // word, then the conflict, then how the checks went. + // word (which already says when it is armed to land on its own), then the + // conflict, then how the checks went. const checksLabel = pullRequestChecksTone(entry.checksState)?.label ?? null; // Armed to land on its own is worth a word on an open row: it is the one that // needs nobody to come back for it. @@ -934,7 +940,6 @@ function PullRequestRow({ const rowLabel = `${[ `${glyphLabel} pull request #${entry.number}`, ...(conflictLabel ? [lowerFirst(conflictLabel)] : []), - ...(autoMergeLabel ? [lowerFirst(autoMergeLabel)] : []), ...(checksLabel ? [lowerFirst(checksLabel)] : []), ].join(", ")}: ${entry.title}`; const reviewTone = pullRequestReviewTone({ diff --git a/apps/web/src/components/pull-requests/pullRequestTabsStore.test.ts b/apps/web/src/components/pull-requests/pullRequestTabsStore.test.ts index 2f646f5a6..2e602ee26 100644 --- a/apps/web/src/components/pull-requests/pullRequestTabsStore.test.ts +++ b/apps/web/src/components/pull-requests/pullRequestTabsStore.test.ts @@ -61,7 +61,9 @@ describe("pullRequestTabsStore", () => { const tab = store().open(target(1)); expect(tab.state).toBe("open"); - store().markStatus(new Map([[tab.id, { state: "merged", isDraft: false }]])); + store().markStatus( + new Map([[tab.id, { state: "merged", isDraft: false, autoMergeEnabled: false }]]), + ); expect(store().tabs[0]?.state).toBe("merged"); // No listing on screen carries the row any more, so the glyph it was last diff --git a/apps/web/src/components/pull-requests/pullRequestTabsStore.ts b/apps/web/src/components/pull-requests/pullRequestTabsStore.ts index 9f9f1df7e..0f770f71f 100644 --- a/apps/web/src/components/pull-requests/pullRequestTabsStore.ts +++ b/apps/web/src/components/pull-requests/pullRequestTabsStore.ts @@ -13,6 +13,8 @@ import { create } from "zustand"; export interface PullRequestTabStatus { readonly state: PullRequestState; readonly isDraft: boolean; + /** Armed to merge on its own once its requirements pass. */ + readonly autoMergeEnabled: boolean; } export interface PullRequestTab extends PullRequestTabStatus { @@ -29,7 +31,7 @@ export interface PullRequestTab extends PullRequestTabStatus { * is optional because the route can open a tab for a row no listing on screen * carries; it then rests on open until a listing says otherwise. */ -export type PullRequestTabTarget = Omit & +export type PullRequestTabTarget = Omit & Partial; /** @@ -81,6 +83,7 @@ export const usePullRequestTabsStore = create((set, get) = number: target.number, state: target.state ?? "open", isDraft: target.isDraft ?? false, + autoMergeEnabled: target.autoMergeEnabled ?? false, }; set({ tabs: [...tabs, tab], activeId: id }); return tab; @@ -105,11 +108,16 @@ export const usePullRequestTabsStore = create((set, get) = let changed = false; const next = tabs.map((tab) => { const status = statusById.get(tab.id); - if (!status || (status.state === tab.state && status.isDraft === tab.isDraft)) { + if ( + !status || + (status.state === tab.state && + status.isDraft === tab.isDraft && + status.autoMergeEnabled === tab.autoMergeEnabled) + ) { return tab; } changed = true; - return { ...tab, state: status.state, isDraft: status.isDraft }; + return { ...tab, ...status }; }); // Only when something moved: this runs off every listing read, and a fresh // array each time would re-render the strip on every poll. diff --git a/apps/web/src/components/pull-requests/pullRequests.logic.test.ts b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts index cf214b9d9..c8e8fa6c3 100644 --- a/apps/web/src/components/pull-requests/pullRequests.logic.test.ts +++ b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts @@ -37,6 +37,7 @@ import { pullRequestFilterChips, pullRequestProjectFacets, pullRequestFiltersFromSearch, + pullRequestBadgeTone, pullRequestFiltersToSearch, pullRequestLabelColor, pullRequestMergeQueueLabel, @@ -502,9 +503,31 @@ describe("resolveThreadPullRequest", () => { url: "https://github.com/threadlines/threadlines/pull/7", repository: "threadlines/threadlines", settledAt: null, + autoMergeEnabled: false, }); }); + it("carries the standing merge instruction from whichever source it reads", () => { + const openStatusPr = { ...MERGED_STATUS_PR, state: "open", autoMergeEnabled: true } as const; + expect( + resolveThreadPullRequest({ + thread: thread(), + gitStatus: gitStatus({ pr: openStatusPr }), + openEntries: [], + projects: PROJECTS, + }), + ).toMatchObject({ number: 7, autoMergeEnabled: true }); + + expect( + resolveThreadPullRequest({ + thread: thread(), + gitStatus: null, + openEntries: [entry({ number: 412, autoMergeEnabled: true })], + projects: PROJECTS, + }), + ).toMatchObject({ number: 412, autoMergeEnabled: true }); + }); + it("falls back to the open listing when the checkout stands on another branch", () => { const resolved = resolveThreadPullRequest({ thread: thread(), @@ -559,6 +582,16 @@ describe("resolveThreadPullRequest", () => { }); }); +describe("pullRequestBadgeTone", () => { + it("wears the armed glyph only while open and not a draft", () => { + expect(pullRequestBadgeTone("open", false, true).label).toBe("Auto-merge"); + expect(pullRequestBadgeTone("open", false, false).label).toBe("Open"); + // A draft cannot be armed, and a settled row has nothing left to land. + expect(pullRequestBadgeTone("open", true, true).label).toBe("Draft"); + expect(pullRequestBadgeTone("merged", false, true).label).toBe("Merged"); + }); +}); + describe("matchesPullRequestQuery", () => { const row = entry({ number: 412, diff --git a/apps/web/src/components/pull-requests/pullRequests.logic.ts b/apps/web/src/components/pull-requests/pullRequests.logic.ts index d7ae7fa40..d44c5c02c 100644 --- a/apps/web/src/components/pull-requests/pullRequests.logic.ts +++ b/apps/web/src/components/pull-requests/pullRequests.logic.ts @@ -30,6 +30,8 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, } from "lucide-react"; +import { MergeQueueIcon } from "../Icons"; +import type { ElementType } from "react"; import type { Project, SidebarThreadSummary } from "../../types"; @@ -492,7 +494,8 @@ export function linkThreadsToPullRequests( /** How one pull request state reads: its glyph, its colour, and its word. */ export interface PullRequestBadgeTone { - readonly Icon: typeof GitPullRequestIcon; + /** Takes the class and `aria-hidden` every surface hands it; a Lucide glyph or one of ours. */ + readonly Icon: ElementType<{ className?: string; "aria-hidden"?: boolean }>; readonly className: string; readonly label: string; } @@ -505,6 +508,11 @@ export interface PullRequestBadgeTone { export function pullRequestBadgeTone( state: PullRequestState, isDraft: boolean, + /** + * Armed to land on its own, or already in the base's merge queue. Only an + * open, non-draft row can be, so the settled and draft glyphs win over it. + */ + autoMergeEnabled = false, ): PullRequestBadgeTone { if (state === "merged") { return { @@ -523,6 +531,14 @@ export function pullRequestBadgeTone( if (isDraft) { return { Icon: GitPullRequestDraftIcon, className: "text-muted-foreground/60", label: "Draft" }; } + if (autoMergeEnabled) { + // The amber of a check still running: the host is on its way to landing it. + return { + Icon: MergeQueueIcon, + className: "text-amber-600/90 dark:text-amber-400/80", + label: "Auto-merge", + }; + } return { Icon: GitPullRequestIcon, className: "text-emerald-600 dark:text-emerald-300/90", @@ -542,6 +558,8 @@ export interface ThreadPullRequest { readonly repository: string | null; /** When it merged or closed; null while open, or where the source did not say. */ readonly settledAt: string | null; + /** Armed to merge on its own once its requirements pass; false where the source did not say. */ + readonly autoMergeEnabled: boolean; } /** @@ -613,6 +631,7 @@ export function pullRequestFromGitStatus( repository, // The status read carries no dates. settledAt: null, + autoMergeEnabled: gitStatus.pr.autoMergeEnabled === true, }; } @@ -669,6 +688,7 @@ export function resolveThreadPullRequest(input: { // A host that does not date the landing gets the row's last update, which // is at or after it. settledAt: entry.settledAt ?? (entry.state === "open" ? null : entry.updatedAt), + autoMergeEnabled: entry.autoMergeEnabled === true, }; } diff --git a/apps/web/src/components/sidebar/InboxRows.browser.tsx b/apps/web/src/components/sidebar/InboxRows.browser.tsx index 1b1e7294e..3afb999b6 100644 --- a/apps/web/src/components/sidebar/InboxRows.browser.tsx +++ b/apps/web/src/components/sidebar/InboxRows.browser.tsx @@ -60,6 +60,7 @@ const OPEN_PULL_REQUEST: ThreadPullRequest = { state: "open", settledAt: null, isDraft: false, + autoMergeEnabled: false, title: "Add the pull requests page", url: "https://github.com/threadlines/threadlines/pull/123", repository: "threadlines/threadlines", diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index a7a42da9e..f620f7084 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -451,6 +451,8 @@ const VcsStatusChangeRequest = Schema.Struct({ baseRef: TrimmedNonEmptyStringSchema, headRef: TrimmedNonEmptyStringSchema, state: VcsStatusChangeRequestState, + /** Armed to merge on its own once its requirements pass; absent where the host does not say. */ + autoMergeEnabled: Schema.optional(Schema.Boolean), }); const VcsStatusLocalShape = { diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index e6f834c1f..7d8b4b202 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -30,6 +30,8 @@ export const ChangeRequest = Schema.Struct({ headRefName: TrimmedNonEmptyString, state: ChangeRequestState, updatedAt: Schema.Option(Schema.DateTimeUtc), + /** Armed to merge on its own once its requirements pass; absent where the host does not say. */ + autoMergeEnabled: Schema.optional(Schema.Boolean), isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), headRepositoryOwnerLogin: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), From 2e3a902fb7881d9852e89c02edf06a89b9140583 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:42:32 -0400 Subject: [PATCH 2/2] fix(web): composer row and hover card show the merge queue glyph too Main gained a pull request row above the composer and a hover card on pull request links (#244) after this branch forked. Its test fixture built a thread pull request without the new auto-merge field, which failed the typecheck on the merge result. Both new surfaces draw the state glyph from the shared tone table, so they now carry the armed flag as well: the composer row and the hover card read it from the detail once it lands (a queue position counts), and from the thread's own resolution before that. The "armed or queued" rule the PR header used inline is now one helper the three of them share. --- apps/web/src/components/ChatMarkdown.tsx | 2 +- .../src/components/chat/ComposerPullRequestRow.tsx | 3 ++- .../chat/composerPullRequest.logic.test.ts | 1 + .../components/chat/composerPullRequest.logic.ts | 4 ++++ .../pull-requests/PullRequestDetailPanel.tsx | 9 ++------- .../pull-requests/PullRequestHoverCard.tsx | 13 ++++++++++++- .../pull-requests/pullRequests.logic.test.ts | 13 +++++++++++++ .../components/pull-requests/pullRequests.logic.ts | 12 ++++++++++++ 8 files changed, 47 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 340b75fc2..f74e5f90f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -614,7 +614,7 @@ function MarkdownPullRequestChip({ }) { const chip = usePullRequestChip(repository, number); const tone = chip.state - ? pullRequestBadgeTone(chip.state.state, chip.state.isDraft) + ? pullRequestBadgeTone(chip.state.state, chip.state.isDraft, chip.state.autoMergeEnabled) : // Nothing here has listed this repository, so the glyph says "a pull // request" without claiming to know how it is going. { Icon: GitPullRequestIcon, className: "text-muted-foreground", label: "Pull request" }; diff --git a/apps/web/src/components/chat/ComposerPullRequestRow.tsx b/apps/web/src/components/chat/ComposerPullRequestRow.tsx index d9a775290..745939ed5 100644 --- a/apps/web/src/components/chat/ComposerPullRequestRow.tsx +++ b/apps/web/src/components/chat/ComposerPullRequestRow.tsx @@ -80,12 +80,13 @@ export function ComposerPullRequestRow({ pullRequest: pullRequest.pullRequest, detail: pullRequest.detail, }); - const tone = pullRequestBadgeTone(row.state, row.isDraft); + const tone = pullRequestBadgeTone(row.state, row.isDraft, row.autoMergeEnabled); const hoverCardPayload: PullRequestHoverCardPayload = { environmentId: pullRequest.environmentId, reference: pullRequest.reference, state: row.state, isDraft: row.isDraft, + autoMergeEnabled: row.autoMergeEnabled, }; return ( diff --git a/apps/web/src/components/chat/composerPullRequest.logic.test.ts b/apps/web/src/components/chat/composerPullRequest.logic.test.ts index 401b11f90..a801ba61c 100644 --- a/apps/web/src/components/chat/composerPullRequest.logic.test.ts +++ b/apps/web/src/components/chat/composerPullRequest.logic.test.ts @@ -17,6 +17,7 @@ const THREAD_PULL_REQUEST: ThreadPullRequest = { url: "https://github.com/Threadlines/threadlines/pull/234", repository: "Threadlines/threadlines", settledAt: null, + autoMergeEnabled: false, }; function check(status: PullRequestCheck["status"], name: string): PullRequestCheck { diff --git a/apps/web/src/components/chat/composerPullRequest.logic.ts b/apps/web/src/components/chat/composerPullRequest.logic.ts index 6c056ace4..407d8109a 100644 --- a/apps/web/src/components/chat/composerPullRequest.logic.ts +++ b/apps/web/src/components/chat/composerPullRequest.logic.ts @@ -13,6 +13,7 @@ import type { PullRequestCheck, PullRequestDetail, PullRequestState } from "@thr import { summarizePullRequestChecks, + pullRequestArmedToMerge, type ThreadPullRequest, } from "../pull-requests/pullRequests.logic"; @@ -55,6 +56,8 @@ export interface ComposerPullRequestRowModel { readonly number: number; readonly state: PullRequestState; readonly isDraft: boolean; + /** The host is landing it on its own: armed, or already in the merge queue. */ + readonly autoMergeEnabled: boolean; readonly title: string; readonly url: string; /** Absent until the detail arrives. */ @@ -134,6 +137,7 @@ export function composerPullRequestRow(input: { number: pullRequest.number, state, isDraft: detail?.isDraft ?? pullRequest.isDraft, + autoMergeEnabled: detail ? pullRequestArmedToMerge(detail) : pullRequest.autoMergeEnabled, title: detail?.title ?? pullRequest.title, url: detail?.url ?? pullRequest.url, projectTitle: detail?.projectTitle ?? null, diff --git a/apps/web/src/components/pull-requests/PullRequestDetailPanel.tsx b/apps/web/src/components/pull-requests/PullRequestDetailPanel.tsx index 096fd7899..76087ab14 100644 --- a/apps/web/src/components/pull-requests/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pull-requests/PullRequestDetailPanel.tsx @@ -102,6 +102,7 @@ import { formatPullRequestBehindLabel, formatPullRequestChecksHeadline, pullRequestBadgeTone, + pullRequestArmedToMerge, pullRequestMergeQueueLabel, pullRequestUpdateMethodLabel, resolveDefaultMergeMethod, @@ -603,13 +604,7 @@ function PullRequestDetailHeader({ readonly onOpenThread: () => void; readonly handoffs: PullRequestHandoffActions | null; }) { - // A queued pull request need not carry a standing instruction any more, so - // the queue's own position arms the glyph as much as the instruction does. - const tone = pullRequestBadgeTone( - detail.state, - detail.isDraft, - detail.autoMergeEnabled === true || (detail.mergeQueue?.position ?? null) !== null, - ); + const tone = pullRequestBadgeTone(detail.state, detail.isDraft, pullRequestArmedToMerge(detail)); const actions = usePullRequestActions({ environmentId, reference, detail, handoffs }); // A branch that no longer merges, said where the branches are named rather // than on a line of its own. diff --git a/apps/web/src/components/pull-requests/PullRequestHoverCard.tsx b/apps/web/src/components/pull-requests/PullRequestHoverCard.tsx index 58ef6a0e4..28e1909e8 100644 --- a/apps/web/src/components/pull-requests/PullRequestHoverCard.tsx +++ b/apps/web/src/components/pull-requests/PullRequestHoverCard.tsx @@ -36,6 +36,7 @@ import { import { PullRequestActorAvatar } from "./pullRequestPresentation"; import { projectRepository, + pullRequestArmedToMerge, pullRequestBadgeTone, type ThreadPullRequest, } from "./pullRequests.logic"; @@ -44,6 +45,8 @@ import { export interface PullRequestChipState { readonly state: PullRequestState; readonly isDraft: boolean; + /** The host is landing it on its own: armed, or already in the merge queue. */ + readonly autoMergeEnabled: boolean; } /** Everything the card needs to draw itself and then read the rest. */ @@ -103,6 +106,7 @@ export function usePullRequestChip( reference: { projectId: scope.projectId, repository: scope.repository, number }, state: state?.state ?? "open", isDraft: state?.isDraft ?? false, + autoMergeEnabled: state?.autoMergeEnabled ?? false, }, }), [number, scope, state], @@ -162,6 +166,7 @@ export function PullRequestHoverCardProvider({ byKey.set(chipKey(entry.repository, entry.number), { state: entry.state, isDraft: entry.isDraft, + autoMergeEnabled: entry.autoMergeEnabled === true, }); } // The thread's own resolution wins: it is the one read that can see a @@ -170,6 +175,7 @@ export function PullRequestHoverCardProvider({ byKey.set(chipKey(threadPullRequest.repository, threadPullRequest.number), { state: threadPullRequest.state, isDraft: threadPullRequest.isDraft, + autoMergeEnabled: threadPullRequest.autoMergeEnabled, }); } return byKey; @@ -221,9 +227,14 @@ function PullRequestHoverCardContent({ reference, state, isDraft, + autoMergeEnabled, }: PullRequestHoverCardPayload) { const detail = useQuery(pullRequestDetailQueryOptions({ environmentId, reference })).data; - const tone = pullRequestBadgeTone(detail?.state ?? state, detail?.isDraft ?? isDraft); + const tone = pullRequestBadgeTone( + detail?.state ?? state, + detail?.isDraft ?? isDraft, + detail ? pullRequestArmedToMerge(detail) : autoMergeEnabled, + ); const settledAt = detail ? (detail.mergedAt ?? detail.closedAt) : null; const timestamp = detail ? formatRelativeTimeLabel(settledAt ?? detail.updatedAt) : null; diff --git a/apps/web/src/components/pull-requests/pullRequests.logic.test.ts b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts index 28b2869f6..009133e0d 100644 --- a/apps/web/src/components/pull-requests/pullRequests.logic.test.ts +++ b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts @@ -37,6 +37,7 @@ import { pullRequestFilterChips, pullRequestProjectFacets, pullRequestFiltersFromSearch, + pullRequestArmedToMerge, pullRequestBadgeTone, pullRequestFiltersToSearch, pullRequestLabelColor, @@ -603,6 +604,18 @@ describe("pullRequestBadgeTone", () => { }); }); +describe("pullRequestArmedToMerge", () => { + it("counts a queue position as armed even once the instruction is gone", () => { + expect(pullRequestArmedToMerge({ autoMergeEnabled: null, mergeQueue: { position: 2 } })).toBe( + true, + ); + expect( + pullRequestArmedToMerge({ autoMergeEnabled: true, mergeQueue: { position: null } }), + ).toBe(true); + expect(pullRequestArmedToMerge({ autoMergeEnabled: false })).toBe(false); + }); +}); + describe("matchesPullRequestQuery", () => { const row = entry({ number: 412, diff --git a/apps/web/src/components/pull-requests/pullRequests.logic.ts b/apps/web/src/components/pull-requests/pullRequests.logic.ts index 8154ba6f2..8d440947d 100644 --- a/apps/web/src/components/pull-requests/pullRequests.logic.ts +++ b/apps/web/src/components/pull-requests/pullRequests.logic.ts @@ -1564,6 +1564,18 @@ export function pullRequestMergeQueueLabel( : null; } +/** + * Whether the host is landing this pull request on its own: armed by the + * standing instruction, or already taken into the base's merge queue. A queued + * pull request need not carry the instruction any more, so the queue's own + * position counts as much as the instruction does. + */ +export function pullRequestArmedToMerge( + detail: Pick, +): boolean { + return detail.autoMergeEnabled === true || (detail.mergeQueue?.position ?? null) !== null; +} + /** How long after a push the header waits for the host to register the new commit's checks. */ export const PULL_REQUEST_FRESH_PUSH_WATCH_MS = 120_000;