Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]),
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
: {}),
Expand Down Expand Up @@ -516,6 +521,7 @@ function toStatusPr(pr: PullRequestInfo): {
baseRef: string;
headRef: string;
state: "open" | "closed" | "merged";
autoMergeEnabled?: boolean;
} {
return {
number: pr.number,
Expand All @@ -524,6 +530,7 @@ function toStatusPr(pr: PullRequestInfo): {
baseRef: pr.baseRefName,
headRef: pr.headRefName,
state: pr.state,
...(pr.autoMergeEnabled !== undefined ? { autoMergeEnabled: pr.autoMergeEnabled } : {}),
};
}

Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
: {}),
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/sourceControl/gitHubPullRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface NormalizedGitHubPullRequestRecord {
readonly headRefName: string;
readonly state: "open" | "closed" | "merged";
readonly updatedAt: Option.Option<DateTime.Utc>;
/** Present only when the read asked for `autoMergeRequest`. */
readonly autoMergeEnabled?: boolean;
readonly isCrossRepository?: boolean;
readonly headRepositoryNameWithOwner?: string | null;
readonly headRepositoryOwnerLogin?: string | null;
Expand All @@ -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(
Expand Down Expand Up @@ -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 }
: {}),
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -575,3 +575,17 @@ export const AntigravityIcon: Icon = (props) => (
<image href={ANTIGRAVITY_ICON_DATA_URL} width="128" height="128" />
</svg>
);

/**
* 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) => (
<svg {...props} viewBox="0 0 16 16" fill="currentColor">
<path d="M3.75 4.5a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM3 7.75a.75.75 0 0 1 1.5 0v2.878a2.251 2.251 0 1 1-1.5 0Zm.75 5.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm5-7.75a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Zm5.75 2.5a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-1.5 0a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z" />
</svg>
);
4 changes: 2 additions & 2 deletions apps/web/src/components/ThreadStatusIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/ComposerPullRequestRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/composerPullRequest.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { PullRequestCheck, PullRequestDetail, PullRequestState } from "@thr

import {
summarizePullRequestChecks,
pullRequestArmedToMerge,
type ThreadPullRequest,
} from "../pull-requests/pullRequests.logic";

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
formatPullRequestBehindLabel,
formatPullRequestChecksHeadline,
pullRequestBadgeTone,
pullRequestArmedToMerge,
pullRequestMergeQueueLabel,
pullRequestUpdateMethodLabel,
resolveDefaultMergeMethod,
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/components/pull-requests/PullRequestHoverCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import { PullRequestActorAvatar } from "./pullRequestPresentation";
import {
projectRepository,
pullRequestArmedToMerge,
pullRequestBadgeTone,
type ThreadPullRequest,
} from "./pullRequests.logic";
Expand All @@ -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. */
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<TooltipWrapper side="bottom" tooltip={`${tab.repository} #${tab.number}`}>
<div
Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/components/pull-requests/PullRequestsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export function PullRequestsView({
number: entry.number,
state: entry.state,
isDraft: entry.isDraft,
autoMergeEnabled: entry.autoMergeEnabled === true,
});
setPressedKey(formatPullRequestSelection(entry));
showPullRequest(entry);
Expand Down Expand Up @@ -425,7 +426,11 @@ export function PullRequestsView({
const tabStatuses = useMemo(() => {
const byId = new Map<string, PullRequestTabStatus>();
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]);
Expand Down Expand Up @@ -920,21 +925,21 @@ 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.
const autoMergeLabel = pullRequestAutoMergeLabel(entry);
const rowLabel = `${[
`${glyphLabel} pull request #${entry.number}`,
...(conflictLabel ? [lowerFirst(conflictLabel)] : []),
...(autoMergeLabel ? [lowerFirst(autoMergeLabel)] : []),
...(checksLabel ? [lowerFirst(checksLabel)] : []),
].join(", ")}: ${entry.title}`;
const reviewTone = pullRequestReviewTone({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading