diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 212e4578..42697fe8 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 11acd77a..9fe6a917 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 227fdf5a..df8c3664 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 f57bdaa2..2cfd89db 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 88398e3c..277d0361 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 1dfd6d76..e0e332d9 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 beec9d9c..bfa5a8f5 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/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 340b75fc..f74e5f90 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/Icons.tsx b/apps/web/src/components/Icons.tsx index 5c500a74..e5ab17d3 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 04ad0f89..2b273117 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/chat/ComposerPullRequestRow.tsx b/apps/web/src/components/chat/ComposerPullRequestRow.tsx index d9a77529..745939ed 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 401b11f9..a801ba61 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 6c056ace..407d8109 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 b70a9002..76087ab1 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,7 +604,7 @@ function PullRequestDetailHeader({ readonly onOpenThread: () => void; readonly handoffs: PullRequestHandoffActions | null; }) { - const tone = pullRequestBadgeTone(detail.state, detail.isDraft); + 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 58ef6a0e..28e1909e 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/PullRequestTabStrip.tsx b/apps/web/src/components/pull-requests/PullRequestTabStrip.tsx index 28170662..39930a44 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 2f646f5a..2e602ee2 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 9f9f1df7..0f770f71 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 fe78720d..009133e0 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,8 @@ import { pullRequestFilterChips, pullRequestProjectFacets, pullRequestFiltersFromSearch, + pullRequestArmedToMerge, + pullRequestBadgeTone, pullRequestFiltersToSearch, pullRequestLabelColor, pullRequestMergeQueueLabel, @@ -513,9 +515,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(), @@ -570,6 +594,28 @@ 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("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 64a195cf..8d440947 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"; @@ -517,7 +519,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; } @@ -530,6 +533,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 { @@ -548,6 +556,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", @@ -567,6 +583,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; } /** @@ -638,6 +656,7 @@ export function pullRequestFromGitStatus( repository, // The status read carries no dates. settledAt: null, + autoMergeEnabled: gitStatus.pr.autoMergeEnabled === true, }; } @@ -694,6 +713,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, }; } @@ -1544,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; diff --git a/apps/web/src/components/sidebar/InboxRows.browser.tsx b/apps/web/src/components/sidebar/InboxRows.browser.tsx index 1b1e7294..3afb999b 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 a7a42da9..f620f708 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 e6f834c1..7d8b4b20 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)),