From edb6110df4a1baf194558fc8783f4f403f4fefd2 Mon Sep 17 00:00:00 2001
From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:34:21 +0000
Subject: [PATCH 01/10] feat(service,features,desktop): a merge box on the Pull
request tab, merging as the member who presses
The header's merge line now ends in the one thing that may be done from
where an open pull request stands: Merge by a method the repository allows
(asked first, the method chosen kept per repository), Enable auto-merge while
checks run where the repository allows it, Merge when ready on a base merged
through a merge queue, or Disable auto-merge and Remove from queue once one
was asked for. Each is made on GitHub as the member through the W1 seam, a
merge and auto-merge against the head the header read. The header's read
carries how the pull request may be merged, read in one GraphQL query on the
App's token beside it, and a blocked merge says what it waits on.
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
---
PRIVACY.md | 8 +-
apps/desktop/src/renderer/styles/tasks.css | 57 ++++
.../tasks/agent-pull-request-tab.test.ts | 140 +++++++++
.../renderer/tasks/agent-pull-request-tab.tsx | 37 ++-
apps/desktop/src/renderer/tasks/merge-box.tsx | 278 ++++++++++++++++++
.../renderer/tasks/pull-request-writes.tsx | 30 +-
...ntOverviewPullRequestAnswerViewSchema.json | 112 +++++++
...ingAgentPullRequestActionParamsSchema.json | 87 ++++++
...gAgentOverviewPullRequestAnswerSchema.json | 112 +++++++
...-request-write-wire-mergeMethodSchema.json | 8 +
...e-wire-pullRequestActionRequestSchema.json | 87 ++++++
packages/features/src/coding-agent-wire.ts | 36 +++
.../features/src/hosted-wire-schemas.test.ts | 1 +
.../features/src/pull-request-write-wire.ts | 47 ++-
.../src/agents/coding-agents-app.test.ts | 143 ++++++++-
.../service/src/agents/coding-agents-app.ts | 68 ++++-
.../src/github/pull-request-merging.ts | 143 +++++++++
.../service/src/github/pull-request-writes.ts | 95 +++++-
18 files changed, 1462 insertions(+), 27 deletions(-)
create mode 100644 apps/desktop/src/renderer/tasks/merge-box.tsx
create mode 100644 packages/features/fixtures/json-schema/pull-request-write-wire-mergeMethodSchema.json
create mode 100644 packages/service/src/github/pull-request-merging.ts
diff --git a/PRIVACY.md b/PRIVACY.md
index e87a4482dd..84fe4b9408 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -406,7 +406,10 @@ nothing of the file is stored.
pull request as GitHub holds it, in two views. Each time a view first opens, or
you refresh it, our service reads it from GitHub with a token the Luke GitHub
App mints for that one repository on the workspace's installation. The overview
-reads its title, branches, and whether it would merge; its description and up
+reads its title, branches, and whether it would merge, with how it may be
+merged: the merge methods and auto-merge its repository allows, whether a merge
+queue merges its base and its place in that queue, whether auto-merge is on,
+its head commit, what its required reviews say, and its checks as one word; its description and up
to its 100 newest comments and reviews, each whole with its author, as GitHub
renders them; the name, App, state, and times of up to 100 checks on its head;
its reviewers; and up to its 100 newest review threads on lines, each with the
@@ -420,7 +423,8 @@ Files changed reads each changed file's name and patch, for up to 300 files,
leaving out a patch over 100,000 characters. It stores none of it, and Luke
holds it in memory while the tab is open, never on disk, except that
downloading a patch writes it where you save it. A button in the tab that
-changes the pull request, such as Ready for review or Convert to draft, is
+changes the pull request, such as Ready for review, Convert to draft, Merge,
+or Enable auto-merge, is
sent to GitHub by our service as you, on the token of your own GitHub sign-in,
whoever started the agent, so it can do only what your GitHub account may; it
is never sent as the Luke GitHub App, and where you hold no GitHub sign-in
diff --git a/apps/desktop/src/renderer/styles/tasks.css b/apps/desktop/src/renderer/styles/tasks.css
index 0a1d4ef0a7..ed14c764c9 100644
--- a/apps/desktop/src/renderer/styles/tasks.css
+++ b/apps/desktop/src/renderer/styles/tasks.css
@@ -1952,6 +1952,63 @@ button.file-ref:hover .file-ref-path {
color: var(--merge-tone);
}
+.pull-request-merge-words {
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+/* The merge box's presses at the line's end (`merge-box.tsx`): what stands
+ of a merge already asked for, quiet, then the one press, a method's
+ press joined to the chevron that chooses the method. */
+.merge-box-actions {
+ display: inline-flex;
+ flex: 0 0 auto;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--space-8);
+ margin-inline-start: auto;
+}
+
+.merge-box-note {
+ color: var(--text-secondary);
+}
+
+.merge-box-split {
+ display: inline-flex;
+ align-items: stretch;
+}
+
+.merge-box-press {
+ height: 26px;
+ padding: 0 var(--space-8);
+ gap: var(--space-4);
+ font-size: 12px;
+}
+
+.merge-box-press svg {
+ width: 13px;
+ height: 13px;
+}
+
+.merge-box-split > .merge-box-press:not(:last-child) {
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+}
+
+.merge-box-picker {
+ height: 26px;
+ padding: 0 var(--space-4);
+ border-start-start-radius: 0;
+ border-end-start-radius: 0;
+ margin-inline-start: -1px;
+}
+
+.merge-box-picker svg {
+ width: 13px;
+ height: 13px;
+}
+
[data-merge-tone="ready"] {
--merge-tone: var(--state-complete);
}
diff --git a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts
index 24449e2bbf..cb68f6b483 100644
--- a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts
+++ b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts
@@ -20,12 +20,16 @@ import {
DEPLOYMENT_STATE,
DIFF_SIDE,
MERGE_STATE,
+ type MergeState,
PULL_REQUEST_STATE,
+ type PullRequestMerging,
type PullRequestState,
+ REVIEW_DECISION,
REVIEW_STATE,
type ReviewThread,
} from "@luke/features/coding-agent-wire";
import {
+ MERGE_METHOD,
PULL_REQUEST_ACTION,
PULL_REQUEST_WRITE_OUTCOME,
type PullRequestActionRequest,
@@ -1448,3 +1452,139 @@ test("a change GitHub refused says so in GitHub's own words until dismissed, and
act(() => dismiss.click());
assert.equal(tab.querySelector(".pull-request-write-note"), null);
});
+
+/** How the pull request may be merged where a test says nothing else: squash or rebase, nothing asked for yet, checks passing. */
+const MERGING: PullRequestMerging = {
+ methods: [MERGE_METHOD.SQUASH, MERGE_METHOD.REBASE],
+ autoMergeAllowed: true,
+ queue: false,
+ queued: null,
+ autoMerge: null,
+ headSha: "c0ffee1",
+ review: null,
+ checks: CHECK_SUMMARY.PASSING,
+};
+
+/** The plan's agents, its pull request standing as given and merged as given. */
+function merging(merge: MergeState, merged: Partial = {}): CodingAgentsControl {
+ return controlAnswering({
+ pullRequest: () =>
+ Promise.resolve({
+ pullRequest: { ...PULL_REQUEST, merge, merging: { ...MERGING, ...merged } },
+ }),
+ });
+}
+
+/** The merge box's presses, by their words. */
+function boxPresses(tab: HTMLElement): string[] {
+ return words(tab, ".merge-box-press");
+}
+
+/** The merge box's press of the words given, pressed. */
+async function pressBox(tab: HTMLElement, label: string): Promise {
+ const press =
+ [...tab.querySelectorAll(".merge-box-press")].find(
+ (each) => each.textContent === label,
+ ) ?? assert.fail(`the merge box offers ${label}`);
+ act(() => press.click());
+ await settle();
+}
+
+test("a pull request ready to merge offers Merge by the first method the repository allows, keeps the method chosen for the repository, and asks before merging against the head it read", async () => {
+ const first = await draw(merging(MERGE_STATE.CLEAN));
+ assert.deepEqual(boxPresses(first), ["Squash and merge"]);
+ act(() =>
+ (
+ first.querySelector('[aria-label="Choose how to merge"]') ??
+ assert.fail("the box offers the repository's other methods")
+ ).click(),
+ );
+ const rebase =
+ [
+ ...document.querySelectorAll(
+ '[role="menuitemcheckbox"], [role="menuitem"]',
+ ),
+ ].find((each) => each.textContent?.startsWith("Rebase and merge")) ??
+ assert.fail("the methods are offered");
+ act(() => rebase.click());
+ assert.deepEqual(boxPresses(first), ["Rebase and merge"]);
+ act(() => root?.unmount());
+ root = undefined;
+
+ const again = await draw(merging(MERGE_STATE.CLEAN));
+ assert.deepEqual(boxPresses(again), ["Rebase and merge"], "the method chosen is kept");
+ await pressBox(again, "Rebase and merge");
+ assert.deepEqual(changes, [], "a merge asks first");
+ const dialog =
+ document.querySelector('[role="dialog"], [role="alertdialog"]') ??
+ assert.fail("the merge is asked about");
+ const merge =
+ [...dialog.querySelectorAll("button")].find(
+ (each) => each.textContent === "Rebase and merge",
+ ) ?? assert.fail("the dialog offers the merge");
+ act(() => merge.click());
+ await settle();
+ assert.deepEqual(changes, [
+ [
+ AGENT.id,
+ { action: PULL_REQUEST_ACTION.MERGE, method: MERGE_METHOD.REBASE, headSha: "c0ffee1" },
+ ],
+ ]);
+});
+
+test("auto-merge is offered while checks run, and turned off once on; a merge queue's base offers Merge when ready, and a queued pull request Remove from queue; a conflict offers nothing", async () => {
+ const cases: readonly (readonly [CodingAgentsControl, string, PullRequestActionRequest])[] = [
+ [
+ merging(MERGE_STATE.BLOCKED, { checks: CHECK_SUMMARY.PENDING }),
+ "Enable auto-merge",
+ {
+ action: PULL_REQUEST_ACTION.ENABLE_AUTO_MERGE,
+ method: MERGE_METHOD.SQUASH,
+ headSha: "c0ffee1",
+ },
+ ],
+ [
+ merging(MERGE_STATE.BLOCKED, { autoMerge: { method: MERGE_METHOD.SQUASH } }),
+ "Disable auto-merge",
+ { action: PULL_REQUEST_ACTION.DISABLE_AUTO_MERGE },
+ ],
+ [
+ merging(MERGE_STATE.CLEAN, { queue: true }),
+ "Merge when ready",
+ { action: PULL_REQUEST_ACTION.ENABLE_AUTO_MERGE, headSha: "c0ffee1" },
+ ],
+ [
+ merging(MERGE_STATE.CLEAN, {
+ queue: true,
+ queued: { position: 2 },
+ autoMerge: { method: null },
+ }),
+ "Remove from queue",
+ { action: PULL_REQUEST_ACTION.DEQUEUE },
+ ],
+ ];
+ for (const [control, label, request] of cases) {
+ changes = [];
+ const tab = await draw(control);
+ assert.deepEqual(boxPresses(tab), [label]);
+ await pressBox(tab, label);
+ assert.deepEqual(changes, [[AGENT.id, request]]);
+ act(() => root?.unmount());
+ root = undefined;
+ }
+
+ const blocked = await draw(
+ merging(MERGE_STATE.BLOCKED, {
+ review: REVIEW_DECISION.REVIEW_REQUIRED,
+ checks: CHECK_SUMMARY.PENDING,
+ }),
+ );
+ assert.match(
+ blocked.querySelector(".pull-request-merge")?.textContent ?? "",
+ /A required review is missing/u,
+ );
+ act(() => root?.unmount());
+ root = undefined;
+ const conflicted = await draw(merging(MERGE_STATE.CONFLICTS));
+ assert.deepEqual(boxPresses(conflicted), []);
+});
diff --git a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx
index 9fc7b0fbc1..142969f429 100644
--- a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx
+++ b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx
@@ -66,6 +66,7 @@ import {
useConversationFilters,
} from "./conversation-filters";
import { PierreDiff, useDiffLook } from "./file-diff";
+import { blockedWords, MergeActions } from "./merge-box";
import { OpenedBy, PersonRef, ReviewersLine, ReviewStanding } from "./pull-request-people";
import {
PullRequestStateMenu,
@@ -625,17 +626,23 @@ function Checks({
/** Whether the pull request would merge as it stands, in its tone's colour; drawn only while it is open. */
function MergeLine({
pullRequest,
+ actions,
}: {
pullRequest: CodingAgentOverviewPullRequest;
+ /** The merge box's presses at the line's end (`merge-box.tsx`). */
+ actions: ReactNode;
}): React.JSX.Element {
const { line, tone } = mergeWords(pullRequest.merge, pullRequest.base);
+ const blocked =
+ pullRequest.merge === MERGE_STATE.BLOCKED ? blockedWords(pullRequest.merging) : undefined;
return (
-
);
}
@@ -857,7 +864,18 @@ interface OverviewParts {
}
/** Under the title: the branches, who opened it and when, its reviewers, and, while it is open, whether it merges; a skeleton while its read is out, and its failure with Retry. */
-function PullRequestHead({ parts, now }: { parts: OverviewParts; now: number }): React.JSX.Element {
+function PullRequestHead({
+ parts,
+ now,
+ repository,
+ writes,
+}: {
+ parts: OverviewParts;
+ now: number;
+ /** The agent's repository, which the merge box keeps the method chosen by. */
+ repository: string | null;
+ writes: PullRequestWrites;
+}): React.JSX.Element {
const { pullRequest: part, conversation } = parts;
if (part.kind === PART.PENDING) {
return (
@@ -894,7 +912,14 @@ function PullRequestHead({ parts, now }: { parts: OverviewParts; now: number }):
{reviewers.length === 0 ? null : }
- {open ? : null}
+ {open ? (
+
+ }
+ />
+ ) : null}
>
);
}
@@ -1543,7 +1568,7 @@ export function AgentPullRequestTab({
<>
-
+ ;
+
+/** Each method's word where it says how auto-merge will merge. */
+const METHOD_NOUNS = {
+ [MERGE_METHOD.MERGE]: "merge commit",
+ [MERGE_METHOD.SQUASH]: "squash",
+ [MERGE_METHOD.REBASE]: "rebase",
+} as const satisfies Record;
+
+/** The box's own words. */
+const BOX_WORDS = {
+ MERGE_WHEN_READY: "Merge when ready",
+ ENABLE_AUTO_MERGE: "Enable auto-merge",
+ DISABLE_AUTO_MERGE: "Disable auto-merge",
+ DEQUEUE: "Remove from queue",
+ CHOOSE_METHOD: "Choose how to merge",
+ queued: (position: number) => `#${position} in the merge queue`,
+ autoMerge: (method: MergeMethod | null) =>
+ method === null ? "Auto-merge is on" : `Auto-merge is on (${METHOD_NOUNS[method]})`,
+} as const;
+
+/** The states GitHub would merge in now: ready, or with checks nothing requires short of passing. */
+const MERGEABLE: ReadonlySet = new Set([MERGE_STATE.CLEAN, MERGE_STATE.UNSTABLE]);
+
+/** The one thing the box offers from where the pull request stands, or nothing. */
+function mergeActionOf(merge: MergeState, merging: PullRequestMerging): MergeAction | undefined {
+ if (merging.queued !== null) return MERGE_ACTION.DEQUEUE;
+ if (merging.autoMerge !== null) return MERGE_ACTION.DISABLE_AUTO_MERGE;
+ if (merge === MERGE_STATE.CONFLICTS || merge === MERGE_STATE.DRAFT) return undefined;
+ if (merging.queue) return MERGE_ACTION.MERGE_WHEN_READY;
+ if (merging.methods.length === 0) return undefined;
+ if (MERGEABLE.has(merge)) return MERGE_ACTION.MERGE;
+ const waiting = merging.checks === CHECK_SUMMARY.PENDING;
+ return merging.autoMergeAllowed && waiting ? MERGE_ACTION.ENABLE_AUTO_MERGE : undefined;
+}
+
+/** What a merge GitHub says is blocked waits on, where the reviews or checks say; none where neither does. */
+export function blockedWords(merging: PullRequestMerging | undefined): string | undefined {
+ if (merging === undefined) return undefined;
+ if (merging.review === REVIEW_DECISION.CHANGES_REQUESTED) return "Changes were requested";
+ if (merging.review === REVIEW_DECISION.REVIEW_REQUIRED) return "A required review is missing";
+ if (merging.checks === CHECK_SUMMARY.PENDING) return "Waiting on required checks";
+ if (merging.checks === CHECK_SUMMARY.FAILING) return "Required checks are failing";
+ return undefined;
+}
+
+/** Where the method chosen is kept, by repository. */
+const METHODS_KEY = "luke.pullRequestMergeMethods";
+
+const storedMethods = Schema.fromJsonString(
+ Schema.Record(Schema.String, Schema.Literals(Object.values(MERGE_METHOD))),
+);
+
+/** The method chosen for the repository, kept across launches: the one last chosen where the repository still allows it, else the first it allows. */
+function useMergeMethod(
+ repository: string | null,
+ allowed: readonly MergeMethod[],
+): readonly [MergeMethod | undefined, (method: MergeMethod) => void] {
+ const [kept, setKept] = useState(() => readKept(METHODS_KEY, storedMethods, {}));
+ const chosen = repository === null ? undefined : kept[repository];
+ const method = chosen !== undefined && allowed.includes(chosen) ? chosen : allowed[0];
+ const choose = (next: MergeMethod) => {
+ if (repository === null) return;
+ const now = { ...readKept(METHODS_KEY, storedMethods, {}), [repository]: next };
+ writeKept(METHODS_KEY, storedMethods, now);
+ setKept(now);
+ };
+ return [method, choose];
+}
+
+/** The chevron beside a press that names a method, opening the methods the repository allows. */
+function MethodPicker({
+ methods,
+ method,
+ disabled,
+ onChoose,
+}: {
+ methods: readonly MergeMethod[];
+ method: MergeMethod;
+ disabled: boolean;
+ onChoose: (method: MergeMethod) => void;
+}): React.JSX.Element {
+ const menu = useActionMenu(true);
+ const items: MenuAction[] = methods.map((each) => ({
+ label: METHOD_WORDS[each],
+ checked: each === method,
+ onSelect: () => onChoose(each),
+ }));
+ return (
+ <>
+
+ {menu.drawn === undefined ? null : (
+
+ )}
+ >
+ );
+}
+
+/**
+ * The box's presses for an open pull request, with what stands of a merge
+ * already asked for: its place in the queue, or the auto-merge on.
+ */
+export function MergeActions({
+ pullRequest,
+ repository,
+ writes,
+}: {
+ pullRequest: CodingAgentOverviewPullRequest;
+ /** The agent's repository, which the method chosen is kept by. */
+ repository: string | null;
+ writes: PullRequestWrites;
+}): React.JSX.Element | null {
+ const { merging } = pullRequest;
+ const [method, choose] = useMergeMethod(repository, merging?.methods ?? []);
+ const confirm = useConfirmDialog({ subject: true, surfaceOpen: true }, async () => {
+ if (merging !== undefined && method !== undefined) {
+ await writes.write({ action: PULL_REQUEST_ACTION.MERGE, method, headSha: merging.headSha });
+ }
+ return { status: ACTION_RESULT_STATUS.ACCEPTED };
+ });
+ if (merging === undefined || pullRequest.state !== PULL_REQUEST_STATE.OPEN) return null;
+ const action = mergeActionOf(pullRequest.merge, merging);
+ const busy = writes.pending !== undefined;
+ const { headSha } = merging;
+ const picker =
+ method !== undefined && merging.methods.length > 1 ? (
+
+ ) : null;
+ const press = (label: string, onPress: () => void, primary: boolean) => (
+
+ );
+ return (
+
+ {merging.queued === null ? null : (
+ {BOX_WORDS.queued(merging.queued.position)}
+ )}
+ {merging.autoMerge === null || merging.queued !== null ? null : (
+ {BOX_WORDS.autoMerge(merging.autoMerge.method)}
+ )}
+ {action === MERGE_ACTION.DEQUEUE
+ ? press(
+ BOX_WORDS.DEQUEUE,
+ () => void writes.write({ action: PULL_REQUEST_ACTION.DEQUEUE }),
+ false,
+ )
+ : null}
+ {action === MERGE_ACTION.DISABLE_AUTO_MERGE
+ ? press(
+ BOX_WORDS.DISABLE_AUTO_MERGE,
+ () => void writes.write({ action: PULL_REQUEST_ACTION.DISABLE_AUTO_MERGE }),
+ false,
+ )
+ : null}
+ {action === MERGE_ACTION.MERGE_WHEN_READY
+ ? press(
+ BOX_WORDS.MERGE_WHEN_READY,
+ () => void writes.write({ action: PULL_REQUEST_ACTION.ENABLE_AUTO_MERGE, headSha }),
+ true,
+ )
+ : null}
+ {action === MERGE_ACTION.MERGE && method !== undefined ? (
+
+ {press(METHOD_WORDS[method], confirm.ask, true)}
+ {picker}
+
+ ) : null}
+ {action === MERGE_ACTION.ENABLE_AUTO_MERGE && method !== undefined ? (
+
+ {press(
+ BOX_WORDS.ENABLE_AUTO_MERGE,
+ () =>
+ void writes.write({ action: PULL_REQUEST_ACTION.ENABLE_AUTO_MERGE, method, headSha }),
+ false,
+ )}
+ {picker}
+
+ ) : null}
+ {method === undefined ? null : (
+
+ )}
+
+ );
+}
diff --git a/apps/desktop/src/renderer/tasks/pull-request-writes.tsx b/apps/desktop/src/renderer/tasks/pull-request-writes.tsx
index bf9e690f33..cf9301767b 100644
--- a/apps/desktop/src/renderer/tasks/pull-request-writes.tsx
+++ b/apps/desktop/src/renderer/tasks/pull-request-writes.tsx
@@ -45,10 +45,25 @@ import { PullRequestMark } from "./pull-request-mark";
* drawn by whatever bar the tab wears, so they move with it.
*/
-/** What each change says on the menu, and while it is out. */
+/** The changes the pill's own menu makes. */
+type StateAction =
+ | typeof PULL_REQUEST_ACTION.READY_FOR_REVIEW
+ | typeof PULL_REQUEST_ACTION.CONVERT_TO_DRAFT;
+
+/** What each of the pill's changes says on its menu. */
const ACTION_WORDS = {
[PULL_REQUEST_ACTION.READY_FOR_REVIEW]: "Ready for review",
[PULL_REQUEST_ACTION.CONVERT_TO_DRAFT]: "Convert to draft",
+} as const satisfies Record;
+
+/** What the pill says while a change is out, whichever control sent it. */
+const PENDING_WORDS = {
+ [PULL_REQUEST_ACTION.READY_FOR_REVIEW]: "Marking ready",
+ [PULL_REQUEST_ACTION.CONVERT_TO_DRAFT]: "Converting to draft",
+ [PULL_REQUEST_ACTION.MERGE]: "Merging",
+ [PULL_REQUEST_ACTION.ENABLE_AUTO_MERGE]: "Enabling auto-merge",
+ [PULL_REQUEST_ACTION.DISABLE_AUTO_MERGE]: "Disabling auto-merge",
+ [PULL_REQUEST_ACTION.DEQUEUE]: "Leaving the queue",
} as const satisfies Record;
/** The word for where the pull request stands, which the pill says. */
@@ -95,7 +110,8 @@ export interface PullRequestWrites {
/** The change out now; none while nothing is. */
readonly pending: PullRequestAction | undefined;
readonly note: WriteNote | undefined;
- readonly write: (request: PullRequestActionRequest) => void;
+ /** Sends the change; settles once GitHub answered and what it said is drawn. */
+ readonly write: (request: PullRequestActionRequest) => Promise;
readonly dismiss: () => void;
}
@@ -125,7 +141,7 @@ export function usePullRequestWrites(agentId: string, onWritten: () => void): Pu
(request: PullRequestActionRequest) => {
setPending(request.action);
setNote(undefined);
- void act(ACT_KIND.CODING_AGENTS_PULL_REQUEST_ACTION, { agentId, request })
+ return act(ACT_KIND.CODING_AGENTS_PULL_REQUEST_ACTION, { agentId, request })
.catch(
(): CodingAgentPullRequestWriteAnswerView => ({
failure: CODING_AGENT_CALL_FAILURE.UNANSWERED,
@@ -145,7 +161,7 @@ export function usePullRequestWrites(agentId: string, onWritten: () => void): Pu
}
/** The changes the pill's menu offers from where the pull request stands. */
-function actionsFrom(state: PullRequestState): readonly PullRequestAction[] {
+function actionsFrom(state: PullRequestState): readonly StateAction[] {
switch (state) {
case PULL_REQUEST_STATE.DRAFT:
return [PULL_REQUEST_ACTION.READY_FOR_REVIEW];
@@ -160,7 +176,7 @@ function actionsFrom(state: PullRequestState): readonly PullRequestAction[] {
const ACTION_GLYPHS = {
[PULL_REQUEST_ACTION.READY_FOR_REVIEW]: ,
[PULL_REQUEST_ACTION.CONVERT_TO_DRAFT]: ,
-} as const satisfies Record;
+} as const satisfies Record;
/**
* The pill saying where the pull request stands, in its colour; a press
@@ -182,7 +198,7 @@ export function PullRequestStateMenu({
{writes.pending === undefined
? STATE_WORDS[pullRequest.state]
- : `${ACTION_WORDS[writes.pending]}…`}
+ : `${PENDING_WORDS[writes.pending]}…`}
>
);
if (actions.length === 0) {
@@ -196,7 +212,7 @@ export function PullRequestStateMenu({
label: ACTION_WORDS[action],
icon: ACTION_GLYPHS[action],
disabled: writes.pending !== undefined,
- onSelect: () => writes.write({ action }),
+ onSelect: () => void writes.write({ action }),
}));
return (
<>
diff --git a/packages/features/fixtures/json-schema/coding-agent-view-codingAgentOverviewPullRequestAnswerViewSchema.json b/packages/features/fixtures/json-schema/coding-agent-view-codingAgentOverviewPullRequestAnswerViewSchema.json
index 6938dc8687..b44c895b69 100644
--- a/packages/features/fixtures/json-schema/coding-agent-view-codingAgentOverviewPullRequestAnswerViewSchema.json
+++ b/packages/features/fixtures/json-schema/coding-agent-view-codingAgentOverviewPullRequestAnswerViewSchema.json
@@ -122,6 +122,118 @@
"additionalProperties": false
},
"maxItems": 50
+ },
+ "merging": {
+ "type": "object",
+ "properties": {
+ "methods": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "merge",
+ "squash",
+ "rebase"
+ ]
+ }
+ },
+ "autoMergeAllowed": {
+ "type": "boolean"
+ },
+ "queue": {
+ "type": "boolean"
+ },
+ "queued": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "position": {
+ "type": "number",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "position"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "autoMerge": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "method": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "merge",
+ "squash",
+ "rebase"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "method"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "headSha": {
+ "type": "string",
+ "maxLength": 64
+ },
+ "review": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "approved",
+ "changes-requested",
+ "review-required"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "checks": {
+ "type": "string",
+ "enum": [
+ "pending",
+ "passing",
+ "failing",
+ "none"
+ ]
+ }
+ },
+ "required": [
+ "methods",
+ "autoMergeAllowed",
+ "queue",
+ "queued",
+ "autoMerge",
+ "headSha",
+ "review",
+ "checks"
+ ],
+ "additionalProperties": false
}
},
"required": [
diff --git a/packages/features/fixtures/json-schema/coding-agent-view-codingAgentPullRequestActionParamsSchema.json b/packages/features/fixtures/json-schema/coding-agent-view-codingAgentPullRequestActionParamsSchema.json
index 8c07d31924..f47281c25f 100644
--- a/packages/features/fixtures/json-schema/coding-agent-view-codingAgentPullRequestActionParamsSchema.json
+++ b/packages/features/fixtures/json-schema/coding-agent-view-codingAgentPullRequestActionParamsSchema.json
@@ -36,6 +36,93 @@
"action"
],
"additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "merge"
+ ]
+ },
+ "method": {
+ "type": "string",
+ "enum": [
+ "merge",
+ "squash",
+ "rebase"
+ ]
+ },
+ "headSha": {
+ "type": "string",
+ "maxLength": 64
+ }
+ },
+ "required": [
+ "action",
+ "method",
+ "headSha"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "enable-auto-merge"
+ ]
+ },
+ "method": {
+ "type": "string",
+ "enum": [
+ "merge",
+ "squash",
+ "rebase"
+ ]
+ },
+ "headSha": {
+ "type": "string",
+ "maxLength": 64
+ }
+ },
+ "required": [
+ "action",
+ "headSha"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "disable-auto-merge"
+ ]
+ }
+ },
+ "required": [
+ "action"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "dequeue"
+ ]
+ }
+ },
+ "required": [
+ "action"
+ ],
+ "additionalProperties": false
}
]
}
diff --git a/packages/features/fixtures/json-schema/coding-agent-wire-codingAgentOverviewPullRequestAnswerSchema.json b/packages/features/fixtures/json-schema/coding-agent-wire-codingAgentOverviewPullRequestAnswerSchema.json
index b5db5d4393..669f5324aa 100644
--- a/packages/features/fixtures/json-schema/coding-agent-wire-codingAgentOverviewPullRequestAnswerSchema.json
+++ b/packages/features/fixtures/json-schema/coding-agent-wire-codingAgentOverviewPullRequestAnswerSchema.json
@@ -120,6 +120,118 @@
"additionalProperties": false
},
"maxItems": 50
+ },
+ "merging": {
+ "type": "object",
+ "properties": {
+ "methods": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "merge",
+ "squash",
+ "rebase"
+ ]
+ }
+ },
+ "autoMergeAllowed": {
+ "type": "boolean"
+ },
+ "queue": {
+ "type": "boolean"
+ },
+ "queued": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "position": {
+ "type": "number",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "position"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "autoMerge": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "method": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "merge",
+ "squash",
+ "rebase"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "method"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "headSha": {
+ "type": "string",
+ "maxLength": 64
+ },
+ "review": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "approved",
+ "changes-requested",
+ "review-required"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "checks": {
+ "type": "string",
+ "enum": [
+ "pending",
+ "passing",
+ "failing",
+ "none"
+ ]
+ }
+ },
+ "required": [
+ "methods",
+ "autoMergeAllowed",
+ "queue",
+ "queued",
+ "autoMerge",
+ "headSha",
+ "review",
+ "checks"
+ ],
+ "additionalProperties": false
}
},
"required": [
diff --git a/packages/features/fixtures/json-schema/pull-request-write-wire-mergeMethodSchema.json b/packages/features/fixtures/json-schema/pull-request-write-wire-mergeMethodSchema.json
new file mode 100644
index 0000000000..31166e6eff
--- /dev/null
+++ b/packages/features/fixtures/json-schema/pull-request-write-wire-mergeMethodSchema.json
@@ -0,0 +1,8 @@
+{
+ "type": "string",
+ "enum": [
+ "merge",
+ "squash",
+ "rebase"
+ ]
+}
diff --git a/packages/features/fixtures/json-schema/pull-request-write-wire-pullRequestActionRequestSchema.json b/packages/features/fixtures/json-schema/pull-request-write-wire-pullRequestActionRequestSchema.json
index 9822e12e88..36bb7d2936 100644
--- a/packages/features/fixtures/json-schema/pull-request-write-wire-pullRequestActionRequestSchema.json
+++ b/packages/features/fixtures/json-schema/pull-request-write-wire-pullRequestActionRequestSchema.json
@@ -29,6 +29,93 @@
"action"
],
"additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "merge"
+ ]
+ },
+ "method": {
+ "type": "string",
+ "enum": [
+ "merge",
+ "squash",
+ "rebase"
+ ]
+ },
+ "headSha": {
+ "type": "string",
+ "maxLength": 64
+ }
+ },
+ "required": [
+ "action",
+ "method",
+ "headSha"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "enable-auto-merge"
+ ]
+ },
+ "method": {
+ "type": "string",
+ "enum": [
+ "merge",
+ "squash",
+ "rebase"
+ ]
+ },
+ "headSha": {
+ "type": "string",
+ "maxLength": 64
+ }
+ },
+ "required": [
+ "action",
+ "headSha"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "disable-auto-merge"
+ ]
+ }
+ },
+ "required": [
+ "action"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "dequeue"
+ ]
+ }
+ },
+ "required": [
+ "action"
+ ],
+ "additionalProperties": false
}
]
}
diff --git a/packages/features/src/coding-agent-wire.ts b/packages/features/src/coding-agent-wire.ts
index cef2136209..73e68dbebb 100644
--- a/packages/features/src/coding-agent-wire.ts
+++ b/packages/features/src/coding-agent-wire.ts
@@ -3,6 +3,7 @@ import { readEither, verbatimJsonSchema, wireKeys } from "@luke/wire/effect";
import { Schema as EffectSchema, Result, SchemaTransformation } from "effect";
import { integrationConnectionNameSchema, integrationConnectionOf } from "./integration-wire.js";
import { pinPositionSchema } from "./pin-order.js";
+import { mergeMethodSchema, PULL_REQUEST_WRITE_BOUNDS } from "./pull-request-write-wire.js";
import { countedNumber, memberRefSchema, wireUuidSchema } from "./service-wire.js";
/**
@@ -852,6 +853,39 @@ export const MERGE_STATE = {
export type MergeState = (typeof MERGE_STATE)[keyof typeof MERGE_STATE];
+/** What the reviews a branch requires say of a pull request, as GitHub sums them up. */
+export const REVIEW_DECISION = {
+ APPROVED: "approved",
+ CHANGES_REQUESTED: "changes-requested",
+ REVIEW_REQUIRED: "review-required",
+} as const;
+
+export type ReviewDecision = (typeof REVIEW_DECISION)[keyof typeof REVIEW_DECISION];
+
+/**
+ * How an open pull request may be merged, as its merge box offers it: the
+ * methods the repository allows, whether it allows auto-merge, whether its
+ * base is merged through a merge queue, where the pull request stands in that
+ * queue or whether auto-merge is on for it, by which method, the head a merge
+ * is made against, what its required reviews say, and its checks as one word.
+ */
+const pullRequestMergingSchema = EffectSchema.Struct({
+ methods: EffectSchema.Array(mergeMethodSchema),
+ autoMergeAllowed: EffectSchema.Boolean,
+ queue: EffectSchema.Boolean,
+ queued: EffectSchema.NullOr(EffectSchema.Struct({ position: countedNumber })),
+ autoMerge: EffectSchema.NullOr(
+ EffectSchema.Struct({ method: EffectSchema.NullOr(mergeMethodSchema) }),
+ ),
+ headSha: EffectSchema.String.check(
+ EffectSchema.isMaxLength(PULL_REQUEST_WRITE_BOUNDS.MAX_COMMIT_CHARS),
+ ),
+ review: EffectSchema.NullOr(EffectSchema.Literals(Object.values(REVIEW_DECISION))),
+ checks: EffectSchema.Literals(Object.values(CHECK_SUMMARY)),
+});
+
+export type PullRequestMerging = typeof pullRequestMergingSchema.Type;
+
/** Where one check on a pull request's head stands: running or queued, passed, failed, or skipped or neutral, which passes nothing and fails nothing. */
export const CHECK_STATE = {
PENDING: "pending",
@@ -1042,6 +1076,8 @@ export const codingAgentOverviewPullRequestAnswerSchema = EffectSchema.Struct({
requested: EffectSchema.Array(githubPersonSchema).check(
EffectSchema.isMaxLength(CHANGES_BOUNDS.MAX_PEOPLE),
),
+ /** How it may be merged; absent where GitHub did not say, or from a service before the merge box. */
+ merging: EffectSchema.optionalKey(pullRequestMergingSchema),
}),
),
});
diff --git a/packages/features/src/hosted-wire-schemas.test.ts b/packages/features/src/hosted-wire-schemas.test.ts
index 419822308e..4b4d057ad3 100644
--- a/packages/features/src/hosted-wire-schemas.test.ts
+++ b/packages/features/src/hosted-wire-schemas.test.ts
@@ -396,6 +396,7 @@ const EFFECT_MODULE_SCHEMAS = {
providerKeySaveRequestSchema: providerKeyWire.providerKeySaveRequestSchema,
} satisfies RecordedEffectJsonSchemas,
"pull-request-write-wire": {
+ mergeMethodSchema: pullRequestWriteWire.mergeMethodSchema,
pullRequestActionRequestSchema: pullRequestWriteWire.pullRequestActionRequestSchema,
pullRequestWriteAnswerSchema: pullRequestWriteWire.pullRequestWriteAnswerSchema,
} satisfies RecordedEffectJsonSchemas,
diff --git a/packages/features/src/pull-request-write-wire.ts b/packages/features/src/pull-request-write-wire.ts
index 245ed0d366..f30e492264 100644
--- a/packages/features/src/pull-request-write-wire.ts
+++ b/packages/features/src/pull-request-write-wire.ts
@@ -10,7 +10,9 @@ import { Schema as EffectSchema } from "effect";
* answer says it was made, that the member must sign in with GitHub again
* because they hold no sign-in GitHub takes, or that GitHub refused the
* change itself, in GitHub's own words: a branch protection, a conflict, or
- * a permission the member's account does not hold. A refusal of Luke's own,
+ * a permission the member's account does not hold. A merge, and turning
+ * auto-merge on, name the head commit the member saw, so GitHub refuses one
+ * made against a head pushed since. A refusal of Luke's own,
* an agent out of reach or a service that could not answer, travels as the
* hosted vocabulary's refusal as every other call's does.
*/
@@ -18,14 +20,45 @@ import { Schema as EffectSchema } from "effect";
export const PULL_REQUEST_WRITE_BOUNDS = {
/** The most of GitHub's own words on a refused change the answer carries. */
MAX_REFUSAL_CHARS: 1_000,
+ /** A commit's id as GitHub spells one: forty hex digits for SHA-1, sixty-four for SHA-256. */
+ MAX_COMMIT_CHARS: 64,
} as const;
+/** How a pull request is merged, as GitHub offers each where the repository allows it. */
+export const MERGE_METHOD = {
+ MERGE: "merge",
+ SQUASH: "squash",
+ REBASE: "rebase",
+} as const;
+
+export type MergeMethod = (typeof MERGE_METHOD)[keyof typeof MERGE_METHOD];
+
+export const mergeMethodSchema = EffectSchema.Literals(Object.values(MERGE_METHOD));
+
+/** The head commit a merge is made against, as GitHub spells it. */
+const headShaSchema = EffectSchema.String.check(
+ EffectSchema.isMaxLength(PULL_REQUEST_WRITE_BOUNDS.MAX_COMMIT_CHARS),
+ EffectSchema.isPattern(/^[0-9a-f]+$/u),
+);
+
/** The changes the Pull request tab makes, each one press. */
export const PULL_REQUEST_ACTION = {
/** A draft marked ready for review. */
READY_FOR_REVIEW: "ready-for-review",
/** An open pull request turned back into a draft. */
CONVERT_TO_DRAFT: "convert-to-draft",
+ /** Merged now, by the method named, against the head named. */
+ MERGE: "merge",
+ /**
+ * Merged by GitHub once what it waits on passes, against the head named: by
+ * the method named, or, on a base merged through a merge queue, added to
+ * the queue once ready, by the queue's own method.
+ */
+ ENABLE_AUTO_MERGE: "enable-auto-merge",
+ /** Auto-merge turned off again. */
+ DISABLE_AUTO_MERGE: "disable-auto-merge",
+ /** Taken out of the merge queue. */
+ DEQUEUE: "dequeue",
} as const;
export type PullRequestAction = (typeof PULL_REQUEST_ACTION)[keyof typeof PULL_REQUEST_ACTION];
@@ -34,6 +67,18 @@ export type PullRequestAction = (typeof PULL_REQUEST_ACTION)[keyof typeof PULL_R
export const pullRequestActionRequestSchema = EffectSchema.Union([
EffectSchema.Struct({ action: EffectSchema.Literal(PULL_REQUEST_ACTION.READY_FOR_REVIEW) }),
EffectSchema.Struct({ action: EffectSchema.Literal(PULL_REQUEST_ACTION.CONVERT_TO_DRAFT) }),
+ EffectSchema.Struct({
+ action: EffectSchema.Literal(PULL_REQUEST_ACTION.MERGE),
+ method: mergeMethodSchema,
+ headSha: headShaSchema,
+ }),
+ EffectSchema.Struct({
+ action: EffectSchema.Literal(PULL_REQUEST_ACTION.ENABLE_AUTO_MERGE),
+ method: EffectSchema.optionalKey(mergeMethodSchema),
+ headSha: headShaSchema,
+ }),
+ EffectSchema.Struct({ action: EffectSchema.Literal(PULL_REQUEST_ACTION.DISABLE_AUTO_MERGE) }),
+ EffectSchema.Struct({ action: EffectSchema.Literal(PULL_REQUEST_ACTION.DEQUEUE) }),
]);
export type PullRequestActionRequest = typeof pullRequestActionRequestSchema.Type;
diff --git a/packages/service/src/agents/coding-agents-app.test.ts b/packages/service/src/agents/coding-agents-app.test.ts
index 55ffc9966c..0534cadd20 100644
--- a/packages/service/src/agents/coding-agents-app.test.ts
+++ b/packages/service/src/agents/coding-agents-app.test.ts
@@ -79,6 +79,7 @@ import {
codingAgentAnswerSchema,
codingAgentListAnswerSchema,
codingAgentMessagesAnswerSchema,
+ codingAgentOverviewPullRequestAnswerSchema,
codingAgentPageAnswerSchema,
codingAgentPullRequestAnswerSchema,
PULL_REQUEST_STATE,
@@ -144,6 +145,7 @@ import { tasksApp } from "../tasks/tasks-app.js";
import {
connectWorkspaceGitHub,
type FakeGitHub,
+ GITHUB_FIXTURE_INSTALLATION_TOKEN,
githubReaching,
type InstallationFixture,
openGithubUser,
@@ -1176,6 +1178,7 @@ it.layer(getTestDB())("the coding-agent routes", (it) => {
const AGENT_PULL_REQUEST_ACTION = "/api/agents/pull-request-action";
const AGENT_PULL_REQUEST = "/api/agents/pull-request";
+const AGENT_OVERVIEW_PULL_REQUEST = AGENT_OVERVIEW[0];
/** The GraphQL id GitHub gives the published pull request. */
const PULL_REQUEST_NODE_ID = "PR_kwDOfixture12";
@@ -1212,15 +1215,45 @@ function graphqlAnswering(
};
}
-/** A change to the agent's pull request as the bearer given. */
-function actionAs(bearer: string, agentId: string, action: string): Request {
+/** A change to the agent's pull request as the bearer given, with the fields beside its action given. */
+function actionAs(
+ bearer: string,
+ agentId: string,
+ action: string,
+ fields: Readonly> = {},
+): Request {
return request(AGENT_PULL_REQUEST_ACTION, bearer, {
method: "POST",
id: agentId,
- body: { action },
+ body: { action, ...fields },
});
}
+/** How the published pull request may be merged, as GitHub's GraphQL answers the merge box's read. */
+const MERGING_ANSWER = {
+ data: {
+ repository: {
+ mergeCommitAllowed: false,
+ squashMergeAllowed: true,
+ rebaseMergeAllowed: true,
+ autoMergeAllowed: true,
+ pullRequest: {
+ headRefOid: PUBLISHED.headSha,
+ isMergeQueueEnabled: false,
+ mergeQueueEntry: null,
+ autoMergeRequest: { mergeMethod: "SQUASH" },
+ reviewDecision: "REVIEW_REQUIRED",
+ commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] },
+ },
+ },
+ },
+} as const;
+
+/** The mutation a GraphQL call made, by the field it named; none for a read. */
+function mutationOf(call: GraphqlCall): string | undefined {
+ return /mutation[^{]*\{\s*(\w+)/u.exec(call.query)?.[1];
+}
+
it.layer(getTestDB())("changing an agent's pull request", (it) => {
/** The owner's plan with an agent that published a pull request, and the other account where the test makes it a member. */
const published = (options: {
@@ -1277,6 +1310,110 @@ it.layer(getTestDB())("changing an agent's pull request", (it) => {
}),
);
+ it.effect(
+ "a merge, auto-merge on and off, and leaving the merge queue are each made on GitHub as the member, a merge and auto-merge against the head they saw",
+ () =>
+ Effect.gen(function* () {
+ const { other, agent, calls, ask } = yield* published({ otherToken: "ghu_teammate" });
+ const head = { headSha: PUBLISHED.headSha };
+
+ const answers = [
+ yield* ask(actionAs(other, agent.id, "merge", { method: "squash", ...head })),
+ yield* ask(actionAs(other, agent.id, "enable-auto-merge", { method: "rebase", ...head })),
+ yield* ask(actionAs(other, agent.id, "enable-auto-merge", head)),
+ yield* ask(actionAs(other, agent.id, "disable-auto-merge")),
+ yield* ask(actionAs(other, agent.id, "dequeue")),
+ ];
+
+ assert.deepEqual(
+ answers.map((answer) => answer.body),
+ answers.map(() => ({ outcome: "done" })),
+ );
+ assert.ok(calls.every((call) => call.authorization === "Bearer ghu_teammate"));
+ const id = PULL_REQUEST_NODE_ID;
+ assert.deepEqual(
+ calls.flatMap((call) => {
+ const mutation = mutationOf(call);
+ return mutation === undefined ? [] : [[mutation, call.variables]];
+ }),
+ [
+ ["mergePullRequest", { id, method: "SQUASH", head: PUBLISHED.headSha }],
+ ["enablePullRequestAutoMerge", { id, head: PUBLISHED.headSha, method: "REBASE" }],
+ ["enablePullRequestAutoMerge", { id, head: PUBLISHED.headSha }],
+ ["disablePullRequestAutoMerge", { id }],
+ ["dequeuePullRequest", { id }],
+ ],
+ );
+ }),
+ );
+
+ it.effect(
+ "a merge naming no head, or a head that is no commit, is refused before anything reaches GitHub",
+ () =>
+ Effect.gen(function* () {
+ const { other, agent, calls, ask } = yield* published({});
+ const invalid = refusal(HOSTED_HTTP_STATUS.BAD_REQUEST, HOSTED_API_ERROR.INVALID_REQUEST);
+
+ assert.deepEqual(
+ yield* ask(actionAs(other, agent.id, "merge", { method: "squash" })),
+ invalid,
+ );
+ assert.deepEqual(
+ yield* ask(
+ actionAs(other, agent.id, "merge", { method: "squash", headSha: "main; drop" }),
+ ),
+ invalid,
+ );
+ assert.deepEqual(calls, []);
+ }),
+ );
+
+ it.effect(
+ "the pull request's header read says how it may be merged, read on the App's token, and stands without it where GitHub would not say",
+ () =>
+ Effect.gen(function* () {
+ let answering = true;
+ const { owner, agent, calls, ask } = yield* published({
+ answer: () =>
+ answering
+ ? Response.json(MERGING_ANSWER)
+ : Response.json({ message: "Server Error" }, { status: 502 }),
+ });
+ const header = () =>
+ Effect.map(
+ ask(request(AGENT_OVERVIEW_PULL_REQUEST, owner, { id: agent.id })),
+ (answered) =>
+ readAnswer(
+ codingAgentOverviewPullRequestAnswerSchema,
+ HOSTED_HTTP_STATUS.OK,
+ answered,
+ ).pullRequest,
+ );
+
+ const read = yield* header();
+ answering = false;
+ const without = yield* header();
+
+ assert.deepEqual(read?.merging, {
+ methods: ["squash", "rebase"],
+ autoMergeAllowed: true,
+ queue: false,
+ queued: null,
+ autoMerge: { method: "squash" },
+ headSha: PUBLISHED.headSha,
+ review: "review-required",
+ checks: "pending",
+ });
+ assert.ok(
+ calls.every(
+ (call) => call.authorization === `Bearer ${GITHUB_FIXTURE_INSTALLATION_TOKEN}`,
+ ),
+ );
+ assert.equal(without?.title, read?.title);
+ assert.equal(without?.merging, undefined);
+ }),
+ );
+
it.effect(
"a member with no GitHub sign-in is asked to sign in with GitHub, and nothing is sent to GitHub as anyone else",
() =>
diff --git a/packages/service/src/agents/coding-agents-app.ts b/packages/service/src/agents/coding-agents-app.ts
index 82088d39b3..10a3fef943 100644
--- a/packages/service/src/agents/coding-agents-app.ts
+++ b/packages/service/src/agents/coding-agents-app.ts
@@ -39,6 +39,7 @@ import {
codingAgentRenameRequestSchema,
codingAgentServersRequestSchema,
codingAgentStartRequestSchema,
+ type PullRequestMerging,
} from "@luke/features/coding-agent-wire";
import { githubImageOf } from "@luke/features/github-image";
import {
@@ -71,8 +72,16 @@ import {
namedOverviewConversation,
namedOverviewPullRequest,
} from "../github/github-people.js";
-import { githubReadOrRefusal } from "../github/github-refusal.js";
-import { pullRequestNodeId, setPullRequestDraft } from "../github/pull-request-writes.js";
+import { githubReadOrRefusal, logGitHubReadFailure } from "../github/github-refusal.js";
+import { pullRequestMerging } from "../github/pull-request-merging.js";
+import {
+ type GitHubWriteFailure,
+ mergePullRequest,
+ pullRequestNodeId,
+ setAutoMerge,
+ setPullRequestDraft,
+ takeOutOfMergeQueue,
+} from "../github/pull-request-writes.js";
import {
type PullRequestReadFailure,
pullRequestChecks,
@@ -931,12 +940,33 @@ const overviewPullRequestEndpoint = /* @__PURE__ */ Effect.fn(
"web/agentOverviewPullRequestEndpoint",
)(function* (seams: CodingAgentsAppSeams, published: PublishedCache) {
return yield* overviewPart(seams, published, (token, agent, pullRequest) =>
- Effect.flatMap(pullRequestOverview(token, agent.repository, pullRequest.number), (read) =>
- Option.isNone(read) ? Effect.succeed(null) : namedOverviewPullRequest(agent, read.value),
- ),
+ Effect.gen(function* () {
+ const [read, merging] = yield* Effect.all(
+ [
+ pullRequestOverview(token, agent.repository, pullRequest.number),
+ mergingOrNone(token, agent.repository, pullRequest.number),
+ ],
+ { concurrency: "unbounded" },
+ );
+ if (Option.isNone(read)) return null;
+ const named = yield* namedOverviewPullRequest(agent, read.value);
+ return Option.isNone(merging) ? named : { ...named, merging: merging.value };
+ }),
);
});
+/**
+ * How the pull request may be merged, for its merge box; none where GitHub
+ * would not say, logged by its kind, since the header it rides with stands
+ * without it.
+ */
+const mergingOrNone = (token: Redacted.Redacted, repository: string, number: number) =>
+ pullRequestMerging(token, repository, number).pipe(
+ Effect.catch((failure) =>
+ Effect.as(logGitHubReadFailure(failure), Option.none()),
+ ),
+ );
+
/**
* GET reads the checks on the agent's pull request's head, failures first,
* up to the bound, and its head's deployments beside them: three requests
@@ -975,13 +1005,37 @@ const overviewConversationEndpoint = /* @__PURE__ */ Effect.fn(
);
});
+/** The write a change makes on the pull request, on the member's token, by the pull request's GraphQL id. */
+function writeOf(
+ request: PullRequestActionRequest,
+ token: Redacted.Redacted,
+ id: string,
+): Effect.Effect {
+ switch (request.action) {
+ case PULL_REQUEST_ACTION.READY_FOR_REVIEW:
+ return setPullRequestDraft(token, id, false);
+ case PULL_REQUEST_ACTION.CONVERT_TO_DRAFT:
+ return setPullRequestDraft(token, id, true);
+ case PULL_REQUEST_ACTION.MERGE:
+ return mergePullRequest(token, id, request.method, request.headSha);
+ case PULL_REQUEST_ACTION.ENABLE_AUTO_MERGE:
+ return setAutoMerge(token, id, { method: request.method, headSha: request.headSha });
+ case PULL_REQUEST_ACTION.DISABLE_AUTO_MERGE:
+ return setAutoMerge(token, id, undefined);
+ case PULL_REQUEST_ACTION.DEQUEUE:
+ return takeOutOfMergeQueue(token, id);
+ }
+}
+
/** The write a change makes on the pull request, on the member's token. */
function actionWrite(
request: PullRequestActionRequest,
-): (pullRequest: MemberPullRequest) => ReturnType {
+): (
+ pullRequest: MemberPullRequest,
+) => Effect.Effect {
return ({ token, repository, number }) =>
Effect.flatMap(pullRequestNodeId(token, repository, number), (id) =>
- setPullRequestDraft(token, id, request.action === PULL_REQUEST_ACTION.CONVERT_TO_DRAFT),
+ writeOf(request, token, id),
);
}
diff --git a/packages/service/src/github/pull-request-merging.ts b/packages/service/src/github/pull-request-merging.ts
new file mode 100644
index 0000000000..045b1bea3b
--- /dev/null
+++ b/packages/service/src/github/pull-request-merging.ts
@@ -0,0 +1,143 @@
+/**
+ * pull-request-merging.ts -- how an open pull request may be merged, as its merge box offers it, read from GitHub's GraphQL API on the token the overview reads on.
+ *
+ * One query reads what the REST pull request does not carry: the merge
+ * methods the repository allows, whether it allows auto-merge, whether the
+ * pull request's base is merged through a merge queue and where it stands
+ * in it, whether auto-merge is on for it and by which method, its head
+ * commit, what its required reviews say, and its head's checks as one word.
+ * It runs on the installation token the App minted for the agent's one
+ * repository (`github-app.ts`'s `repositoryPullRequestToken`), as every
+ * other overview read does, over the App module's own kit (`githubPost`,
+ * `send`, `readBody`). An answer GitHub gave in no shape this reads, errors
+ * among it, is unreadable, which the caller answers as no merge box rather
+ * than failing the header it rides with.
+ */
+
+import {
+ CHECK_SUMMARY,
+ type CheckSummary,
+ type PullRequestMerging,
+ REVIEW_DECISION,
+ type ReviewDecision,
+} from "@luke/features/coding-agent-wire";
+import { MERGE_METHOD, type MergeMethod } from "@luke/features/pull-request-write-wire";
+import { Effect, Option, type Redacted, Schema } from "effect";
+import type * as HttpClient from "effect/http/HttpClient";
+import { type GitHubUnavailable, githubPost, readBody, send } from "./github-app.js";
+
+const MERGING_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
+ repository(owner: $owner, name: $name) {
+ mergeCommitAllowed
+ squashMergeAllowed
+ rebaseMergeAllowed
+ autoMergeAllowed
+ pullRequest(number: $number) {
+ headRefOid
+ isMergeQueueEnabled
+ mergeQueueEntry { position }
+ autoMergeRequest { mergeMethod }
+ reviewDecision
+ commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
+ }
+ }
+}`;
+
+const MergingAnswerSchema = Schema.Struct({
+ data: Schema.Struct({
+ repository: Schema.NullOr(
+ Schema.Struct({
+ mergeCommitAllowed: Schema.Boolean,
+ squashMergeAllowed: Schema.Boolean,
+ rebaseMergeAllowed: Schema.Boolean,
+ autoMergeAllowed: Schema.Boolean,
+ pullRequest: Schema.NullOr(
+ Schema.Struct({
+ headRefOid: Schema.String,
+ isMergeQueueEnabled: Schema.Boolean,
+ mergeQueueEntry: Schema.NullOr(Schema.Struct({ position: Schema.Number })),
+ autoMergeRequest: Schema.NullOr(
+ Schema.Struct({ mergeMethod: Schema.NullOr(Schema.String) }),
+ ),
+ reviewDecision: Schema.NullOr(Schema.String),
+ commits: Schema.Struct({
+ nodes: Schema.Array(
+ Schema.Struct({
+ commit: Schema.Struct({
+ statusCheckRollup: Schema.NullOr(Schema.Struct({ state: Schema.String })),
+ }),
+ }),
+ ),
+ }),
+ }),
+ ),
+ }),
+ ),
+ }),
+});
+
+/** GitHub's word for each merge method, as GraphQL spells it. */
+const GITHUB_MERGE_METHOD = new Map([
+ ["MERGE", MERGE_METHOD.MERGE],
+ ["SQUASH", MERGE_METHOD.SQUASH],
+ ["REBASE", MERGE_METHOD.REBASE],
+]);
+
+const GITHUB_REVIEW_DECISION = new Map([
+ ["APPROVED", REVIEW_DECISION.APPROVED],
+ ["CHANGES_REQUESTED", REVIEW_DECISION.CHANGES_REQUESTED],
+ ["REVIEW_REQUIRED", REVIEW_DECISION.REVIEW_REQUIRED],
+]);
+
+/** A head's status rollup as one word; one GitHub has not rolled up is none run. */
+const GITHUB_ROLLUP = new Map([
+ ["SUCCESS", CHECK_SUMMARY.PASSING],
+ ["PENDING", CHECK_SUMMARY.PENDING],
+ ["EXPECTED", CHECK_SUMMARY.PENDING],
+ ["FAILURE", CHECK_SUMMARY.FAILING],
+ ["ERROR", CHECK_SUMMARY.FAILING],
+]);
+
+/**
+ * How the pull request may be merged, read on the token; none where the
+ * repository or the pull request reads as none. Note that the methods are
+ * the repository's own settings alone: a ruleset narrowing them on the base
+ * is GitHub's to refuse, in its own words, when a merge names one.
+ */
+export const pullRequestMerging = /* @__PURE__ */ Effect.fn("web/pullRequestMerging")(function* (
+ token: Redacted.Redacted,
+ fullName: string,
+ number: number,
+): Effect.fn.Return, GitHubUnavailable, HttpClient.HttpClient> {
+ const [owner = "", name = ""] = fullName.split("/");
+ const request = githubPost("/graphql", token, {
+ query: MERGING_QUERY,
+ variables: { owner, name, number },
+ });
+ const answer = yield* Effect.scoped(
+ Effect.flatMap(send(request), (response) => readBody(response, MergingAnswerSchema)),
+ );
+ const repository = answer.data.repository;
+ const pull = repository?.pullRequest;
+ if (repository === null || pull === null || pull === undefined) return Option.none();
+ const methods = [
+ ...(repository.mergeCommitAllowed ? [MERGE_METHOD.MERGE] : []),
+ ...(repository.squashMergeAllowed ? [MERGE_METHOD.SQUASH] : []),
+ ...(repository.rebaseMergeAllowed ? [MERGE_METHOD.REBASE] : []),
+ ];
+ const rollup = pull.commits.nodes[0]?.commit.statusCheckRollup?.state;
+ return Option.some({
+ methods,
+ autoMergeAllowed: repository.autoMergeAllowed,
+ queue: pull.isMergeQueueEnabled,
+ queued: pull.mergeQueueEntry === null ? null : { position: pull.mergeQueueEntry.position },
+ autoMerge:
+ pull.autoMergeRequest === null
+ ? null
+ : { method: GITHUB_MERGE_METHOD.get(pull.autoMergeRequest.mergeMethod ?? "") ?? null },
+ headSha: pull.headRefOid,
+ review: GITHUB_REVIEW_DECISION.get(pull.reviewDecision ?? "") ?? null,
+ checks:
+ rollup === undefined ? CHECK_SUMMARY.NONE : (GITHUB_ROLLUP.get(rollup) ?? CHECK_SUMMARY.NONE),
+ });
+});
diff --git a/packages/service/src/github/pull-request-writes.ts b/packages/service/src/github/pull-request-writes.ts
index 7a2232747a..e4d2d1dbfd 100644
--- a/packages/service/src/github/pull-request-writes.ts
+++ b/packages/service/src/github/pull-request-writes.ts
@@ -16,7 +16,11 @@
* Nothing of the token, the request, or GitHub's body reaches a log line.
*/
-import { PULL_REQUEST_WRITE_BOUNDS } from "@luke/features/pull-request-write-wire";
+import {
+ MERGE_METHOD,
+ type MergeMethod,
+ PULL_REQUEST_WRITE_BOUNDS,
+} from "@luke/features/pull-request-write-wire";
import { Data, Effect, type Redacted, Schema } from "effect";
import type * as HttpClient from "effect/http/HttpClient";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
@@ -171,3 +175,92 @@ export const setPullRequestDraft = /* @__PURE__ */ Effect.fn("web/setPullRequest
Schema.Unknown,
);
});
+
+/** Each merge method as GitHub's GraphQL spells it. */
+const GRAPHQL_MERGE_METHOD = {
+ [MERGE_METHOD.MERGE]: "MERGE",
+ [MERGE_METHOD.SQUASH]: "SQUASH",
+ [MERGE_METHOD.REBASE]: "REBASE",
+} as const satisfies Record;
+
+const MERGE_MUTATION = `mutation($id: ID!, $method: PullRequestMergeMethod!, $head: GitObjectID!) {
+ mergePullRequest(input: { pullRequestId: $id, mergeMethod: $method, expectedHeadOid: $head }) {
+ pullRequest { merged }
+ }
+}`;
+
+/**
+ * The pull request merged now by the method given, on the member's token,
+ * against the head they saw: GitHub refuses it where the head has moved
+ * since, the method is not allowed, or a rule still stands in the way.
+ */
+export const mergePullRequest = /* @__PURE__ */ Effect.fn("web/mergePullRequest")(function* (
+ token: Redacted.Redacted,
+ pullRequestId: string,
+ method: MergeMethod,
+ headSha: string,
+): Effect.fn.Return {
+ yield* githubWrite(
+ token,
+ MERGE_MUTATION,
+ { id: pullRequestId, method: GRAPHQL_MERGE_METHOD[method], head: headSha },
+ Schema.Unknown,
+ );
+});
+
+const ENABLE_AUTO_MERGE_MUTATION = `mutation($id: ID!, $method: PullRequestMergeMethod, $head: GitObjectID!) {
+ enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: $method, expectedHeadOid: $head }) {
+ clientMutationId
+ }
+}`;
+
+const DISABLE_AUTO_MERGE_MUTATION = `mutation($id: ID!) {
+ disablePullRequestAutoMerge(input: { pullRequestId: $id }) { clientMutationId }
+}`;
+
+/** What auto-merge is turned on with: the method, none on a base a merge queue merges, and the head the member saw. */
+interface AutoMerge {
+ readonly method: MergeMethod | undefined;
+ readonly headSha: string;
+}
+
+/**
+ * Auto-merge turned on for the pull request as given, or off where none is
+ * given, on the member's token. On a base merged through a merge queue,
+ * turning it on is how a pull request is added to the queue once it is
+ * ready, as `gh pr merge --auto` adds one, the queue's method standing.
+ */
+export const setAutoMerge = /* @__PURE__ */ Effect.fn("web/setAutoMerge")(function* (
+ token: Redacted.Redacted,
+ pullRequestId: string,
+ autoMerge: AutoMerge | undefined,
+): Effect.fn.Return {
+ if (autoMerge === undefined) {
+ yield* githubWrite(token, DISABLE_AUTO_MERGE_MUTATION, { id: pullRequestId }, Schema.Unknown);
+ return;
+ }
+ yield* githubWrite(
+ token,
+ ENABLE_AUTO_MERGE_MUTATION,
+ {
+ id: pullRequestId,
+ head: autoMerge.headSha,
+ ...(autoMerge.method === undefined
+ ? undefined
+ : { method: GRAPHQL_MERGE_METHOD[autoMerge.method] }),
+ },
+ Schema.Unknown,
+ );
+});
+
+const DEQUEUE_MUTATION = `mutation($id: ID!) {
+ dequeuePullRequest(input: { id: $id }) { clientMutationId }
+}`;
+
+/** The pull request taken out of its base's merge queue, on the member's token. */
+export const takeOutOfMergeQueue = /* @__PURE__ */ Effect.fn("web/takeOutOfMergeQueue")(function* (
+ token: Redacted.Redacted,
+ pullRequestId: string,
+): Effect.fn.Return {
+ yield* githubWrite(token, DEQUEUE_MUTATION, { id: pullRequestId }, Schema.Unknown);
+});
From 9e5f60d0f1d949bfb877f19ddac810b1a920f753 Mon Sep 17 00:00:00 2001
From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:45:01 +0000
Subject: [PATCH 02/10] fix(desktop): a merge merges the head it was asked
about, and its question's act is red
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
---
.../tasks/agent-pull-request-tab.test.ts | 54 +++++++++++++++++++
apps/desktop/src/renderer/tasks/merge-box.tsx | 32 +++++++----
2 files changed, 75 insertions(+), 11 deletions(-)
diff --git a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts
index cb68f6b483..e5c4cbfa6b 100644
--- a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts
+++ b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts
@@ -1588,3 +1588,57 @@ test("auto-merge is offered while checks run, and turned off once on; a merge qu
const conflicted = await draw(merging(MERGE_STATE.CONFLICTS));
assert.deepEqual(boxPresses(conflicted), []);
});
+
+test("a merge asked about merges the head it was asked about, though a read landing under the question names a newer one", async () => {
+ let reads = 0;
+ const tab = await draw(
+ controlAnswering({
+ pullRequest: () => {
+ reads += 1;
+ const headSha = reads === 1 ? "c0ffee1" : "beef002";
+ return Promise.resolve({
+ pullRequest: {
+ ...PULL_REQUEST,
+ merge: MERGE_STATE.CLEAN,
+ merging: { ...MERGING, headSha },
+ },
+ });
+ },
+ }),
+ );
+ await pressBox(tab, "Squash and merge");
+ const dialog =
+ document.querySelector('[role="dialog"], [role="alertdialog"]') ??
+ assert.fail("the merge is asked about");
+ assert.match(dialog.textContent ?? "", /at commit c0ffee1/u);
+
+ act(() =>
+ (
+ tab.querySelector('[aria-label="Pull request actions"]') ??
+ assert.fail("the bar has a ⋯")
+ ).click(),
+ );
+ const refresh =
+ [...document.querySelectorAll('[role="menuitem"]')].find(
+ (each) => each.textContent === "Refresh pull request",
+ ) ?? assert.fail("the ⋯ offers Refresh");
+ await act(async () => refresh.click());
+ await settle();
+ assert.ok(reads > 1, "a newer head was read under the question");
+
+ const merge =
+ [
+ ...document.querySelectorAll(
+ '[role="dialog"] button, [role="alertdialog"] button',
+ ),
+ ].find((each) => each.textContent === "Squash and merge") ??
+ assert.fail("the dialog still offers the merge");
+ act(() => merge.click());
+ await settle();
+ assert.deepEqual(changes, [
+ [
+ AGENT.id,
+ { action: PULL_REQUEST_ACTION.MERGE, method: MERGE_METHOD.SQUASH, headSha: "c0ffee1" },
+ ],
+ ]);
+});
diff --git a/apps/desktop/src/renderer/tasks/merge-box.tsx b/apps/desktop/src/renderer/tasks/merge-box.tsx
index 6216f134e1..051d97416c 100644
--- a/apps/desktop/src/renderer/tasks/merge-box.tsx
+++ b/apps/desktop/src/renderer/tasks/merge-box.tsx
@@ -190,12 +190,15 @@ export function MergeActions({
}): React.JSX.Element | null {
const { merging } = pullRequest;
const [method, choose] = useMergeMethod(repository, merging?.methods ?? []);
- const confirm = useConfirmDialog({ subject: true, surfaceOpen: true }, async () => {
- if (merging !== undefined && method !== undefined) {
- await writes.write({ action: PULL_REQUEST_ACTION.MERGE, method, headSha: merging.headSha });
- }
- return { status: ACTION_RESULT_STATUS.ACCEPTED };
- });
+ // The merge asked about, as it stood when Merge was pressed: a read landing under the question never changes what it merges.
+ const [asked, setAsked] = useState<{ method: MergeMethod; headSha: string } | undefined>();
+ const confirm = useConfirmDialog(
+ { subject: asked !== undefined, surfaceOpen: true },
+ async () => {
+ if (asked !== undefined) await writes.write({ action: PULL_REQUEST_ACTION.MERGE, ...asked });
+ return { status: ACTION_RESULT_STATUS.ACCEPTED };
+ },
+ );
if (merging === undefined || pullRequest.state !== PULL_REQUEST_STATE.OPEN) return null;
const action = mergeActionOf(pullRequest.merge, merging);
const busy = writes.pending !== undefined;
@@ -246,7 +249,14 @@ export function MergeActions({
: null}
{action === MERGE_ACTION.MERGE && method !== undefined ? (
- {press(METHOD_WORDS[method], confirm.ask, true)}
+ {press(
+ METHOD_WORDS[method],
+ () => {
+ setAsked({ method, headSha });
+ confirm.ask();
+ },
+ true,
+ )}
{picker}
) : null}
@@ -261,15 +271,15 @@ export function MergeActions({
{picker}
) : null}
- {method === undefined ? null : (
+ {asked === undefined ? null : (
)}
From 884f34b08f992f459dcf82a0c377ea9e901d0bde Mon Sep 17 00:00:00 2001
From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:47:09 +0000
Subject: [PATCH 03/10] feat(service,features,desktop): close, reopen, and
retitle an agent's pull request as the member who presses
The pill's menu now offers Close pull request beside Ready for review or
Convert to draft, and Reopen pull request once closed. The title is edited
in place, as stage-cli edits it: its pencil opens a field, Enter or the check
keeps new words, Escape or the cross gives them up. Each is made on GitHub as
the member through the W1 seam (closePullRequest, reopenPullRequest,
updatePullRequest).
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
---
apps/desktop/src/renderer/styles/tasks.css | 38 +++++
.../tasks/agent-pull-request-tab.test.ts | 84 +++++++++-
.../renderer/tasks/agent-pull-request-tab.tsx | 9 +-
.../renderer/tasks/pull-request-writes.tsx | 146 ++++++++++++++++--
...ingAgentPullRequestActionParamsSchema.json | 51 ++++++
...e-wire-pullRequestActionRequestSchema.json | 51 ++++++
.../features/src/pull-request-write-wire.ts | 17 ++
.../src/agents/coding-agents-app.test.ts | 39 +++++
.../service/src/agents/coding-agents-app.ts | 8 +
.../service/src/github/pull-request-writes.ts | 35 +++++
10 files changed, 461 insertions(+), 17 deletions(-)
diff --git a/apps/desktop/src/renderer/styles/tasks.css b/apps/desktop/src/renderer/styles/tasks.css
index ed14c764c9..35ad1b223f 100644
--- a/apps/desktop/src/renderer/styles/tasks.css
+++ b/apps/desktop/src/renderer/styles/tasks.css
@@ -1914,6 +1914,44 @@ button.file-ref:hover .file-ref-path {
user-select: text;
}
+/* The title edited in place (`pull-request-writes.tsx`): its pencil stands
+ faint after the number until the title is hovered or the pencil focused,
+ and the field takes the title's own type and room. */
+.pull-request-title-edit {
+ width: 22px;
+ height: 22px;
+ margin-inline-start: var(--space-2);
+ vertical-align: middle;
+ opacity: 0;
+}
+
+.pull-request-title-edit svg {
+ width: 13px;
+ height: 13px;
+}
+
+.pull-request-title-editable:hover .pull-request-title-edit,
+.pull-request-title-edit:focus-visible {
+ opacity: 1;
+}
+
+.pull-request-title-field {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.pull-request-title-input {
+ min-width: 0;
+ flex: 1 1 auto;
+ padding: var(--space-2) var(--space-4);
+ border: 1px solid var(--hairline-strong);
+ border-radius: var(--control-radius);
+ background: var(--raised);
+ color: inherit;
+ font: inherit;
+}
+
.pull-request-number {
color: var(--text-tertiary);
font-weight: 400;
diff --git a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts
index e5c4cbfa6b..1268d577b7 100644
--- a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts
+++ b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts
@@ -1375,13 +1375,13 @@ test("a draft's pill offers Ready for review, which is asked of the service for
),
);
assert.equal(pill(tab).textContent, "Draft");
- assert.deepEqual(offered(tab), ["Ready for review"]);
+ assert.deepEqual(offered(tab), ["Ready for review", "Close pull request"]);
await pressAction(tab, "Ready for review");
assert.deepEqual(changes, [[AGENT.id, { action: PULL_REQUEST_ACTION.READY_FOR_REVIEW }]]);
assert.equal(pill(tab).textContent, "Open");
- assert.deepEqual(offered(tab), ["Convert to draft"]);
+ assert.deepEqual(offered(tab), ["Convert to draft", "Close pull request"]);
assert.equal(tab.querySelector(".pull-request-write-note"), null);
});
@@ -1642,3 +1642,83 @@ test("a merge asked about merges the head it was asked about, though a read land
],
]);
});
+
+test("an open pull request's pill offers Close and a closed one's Reopen, each asked of the service for the agent", async () => {
+ const open = await draw(controlAnswering());
+ await pressAction(open, "Close pull request");
+ act(() => root?.unmount());
+ root = undefined;
+ const closed = await draw(
+ controlAnswering({
+ pullRequest: () =>
+ Promise.resolve({ pullRequest: { ...PULL_REQUEST, state: PULL_REQUEST_STATE.CLOSED } }),
+ }),
+ );
+ assert.deepEqual(offered(closed), ["Reopen pull request"]);
+ await pressAction(closed, "Reopen pull request");
+
+ assert.deepEqual(changes, [
+ [AGENT.id, { action: PULL_REQUEST_ACTION.CLOSE }],
+ [AGENT.id, { action: PULL_REQUEST_ACTION.REOPEN }],
+ ]);
+});
+
+/** The title's field, opened by its pencil. */
+function editTitle(tab: HTMLElement): HTMLInputElement {
+ act(() =>
+ (
+ tab.querySelector('[aria-label="Edit title"]') ??
+ assert.fail("the title has a pencil")
+ ).click(),
+ );
+ return (
+ tab.querySelector('[aria-label="Pull request title"]') ??
+ assert.fail("the pencil opens a field")
+ );
+}
+
+/** Words typed into a field, as React hears them. */
+function type(field: HTMLInputElement, words: string): void {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+ act(() => {
+ setter?.call(field, words);
+ field.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+}
+
+/** A key pressed in a field. */
+async function key(field: HTMLInputElement, name: string): Promise {
+ act(() => {
+ field.dispatchEvent(new KeyboardEvent("keydown", { key: name, bubbles: true }));
+ });
+ await settle();
+}
+
+test("the title is edited in place: Enter keeps new words trimmed, and Escape, or words unchanged, sends nothing", async () => {
+ const tab = await draw(controlAnswering());
+
+ const escaped = editTitle(tab);
+ type(escaped, "Something else");
+ await key(escaped, "Escape");
+ assert.equal(tab.querySelector('[aria-label="Pull request title"]'), null);
+
+ const unchanged = editTitle(tab);
+ assert.equal(unchanged.value, PULL_REQUEST.title);
+ await key(unchanged, "Enter");
+ assert.deepEqual(changes, [], "neither Escape nor the same words change anything");
+
+ const renamed = editTitle(tab);
+ type(renamed, " Expire invitations after a week ");
+ await key(renamed, "Enter");
+ assert.deepEqual(changes, [
+ [
+ AGENT.id,
+ { action: PULL_REQUEST_ACTION.EDIT_TITLE, title: "Expire invitations after a week" },
+ ],
+ ]);
+ assert.equal(
+ tab.querySelector('[aria-label="Pull request title"]'),
+ null,
+ "the field closes once GitHub answered",
+ );
+});
diff --git a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx
index 142969f429..2efc3d7bc4 100644
--- a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx
+++ b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx
@@ -70,6 +70,7 @@ import { blockedWords, MergeActions } from "./merge-box";
import { OpenedBy, PersonRef, ReviewersLine, ReviewStanding } from "./pull-request-people";
import {
PullRequestStateMenu,
+ PullRequestTitle,
PullRequestWriteNote,
type PullRequestWrites,
usePullRequestWrites,
@@ -1123,8 +1124,10 @@ function ConversationPart({
/** The fixed header's title and number, as the pull request's read says or, until it lands, as Luke held them; a skeleton where neither has. */
function HeaderTitle({
title,
+ writes,
}: {
title: Pick | undefined;
+ writes: PullRequestWrites;
}): React.JSX.Element {
if (title === undefined) {
return (
@@ -1136,9 +1139,7 @@ function HeaderTitle({
);
}
return (
-
- {title.title} {pullRequestNumberLabel(title)}
-
+
);
}
@@ -1567,7 +1568,7 @@ export function AgentPullRequestTab({
) : (
<>
-
+ ;
/** What the pill says while a change is out, whichever control sent it. */
@@ -64,6 +80,9 @@ const PENDING_WORDS = {
[PULL_REQUEST_ACTION.ENABLE_AUTO_MERGE]: "Enabling auto-merge",
[PULL_REQUEST_ACTION.DISABLE_AUTO_MERGE]: "Disabling auto-merge",
[PULL_REQUEST_ACTION.DEQUEUE]: "Leaving the queue",
+ [PULL_REQUEST_ACTION.CLOSE]: "Closing",
+ [PULL_REQUEST_ACTION.REOPEN]: "Reopening",
+ [PULL_REQUEST_ACTION.EDIT_TITLE]: "Renaming",
} as const satisfies Record;
/** The word for where the pull request stands, which the pill says. */
@@ -164,10 +183,12 @@ export function usePullRequestWrites(agentId: string, onWritten: () => void): Pu
function actionsFrom(state: PullRequestState): readonly StateAction[] {
switch (state) {
case PULL_REQUEST_STATE.DRAFT:
- return [PULL_REQUEST_ACTION.READY_FOR_REVIEW];
+ return [PULL_REQUEST_ACTION.READY_FOR_REVIEW, PULL_REQUEST_ACTION.CLOSE];
case PULL_REQUEST_STATE.OPEN:
- return [PULL_REQUEST_ACTION.CONVERT_TO_DRAFT];
- default:
+ return [PULL_REQUEST_ACTION.CONVERT_TO_DRAFT, PULL_REQUEST_ACTION.CLOSE];
+ case PULL_REQUEST_STATE.CLOSED:
+ return [PULL_REQUEST_ACTION.REOPEN];
+ case PULL_REQUEST_STATE.MERGED:
return [];
}
}
@@ -176,6 +197,8 @@ function actionsFrom(state: PullRequestState): readonly StateAction[] {
const ACTION_GLYPHS = {
[PULL_REQUEST_ACTION.READY_FOR_REVIEW]: ,
[PULL_REQUEST_ACTION.CONVERT_TO_DRAFT]: ,
+ [PULL_REQUEST_ACTION.CLOSE]: ,
+ [PULL_REQUEST_ACTION.REOPEN]: ,
} as const satisfies Record;
/**
@@ -211,6 +234,7 @@ export function PullRequestStateMenu({
const items: MenuAction[] = actions.map((action) => ({
label: ACTION_WORDS[action],
icon: ACTION_GLYPHS[action],
+ danger: action === PULL_REQUEST_ACTION.CLOSE,
disabled: writes.pending !== undefined,
onSelect: () => void writes.write({ action }),
}));
@@ -271,3 +295,103 @@ export function PullRequestWriteNote({
);
}
+
+/** The title's own words. */
+const TITLE_WORDS = {
+ EDIT: "Edit title",
+ SAVE: "Save title",
+ CANCEL: "Cancel",
+ FIELD: "Pull request title",
+} as const;
+
+/**
+ * The pull request's title and number, its pencil opening a field over the
+ * title where its words may be changed: kept on Enter or the check where
+ * they changed and are not empty, given up on Escape or the cross. The field
+ * stands until GitHub answers, and closes once it has.
+ */
+export function PullRequestTitle({
+ title,
+ number,
+ writes,
+}: {
+ title: string;
+ /** The pull request's number as the header says it. */
+ number: string;
+ writes: PullRequestWrites;
+}): React.JSX.Element {
+ const [editing, setEditing] = useState(undefined);
+ const field = useRef(null);
+ const busy = writes.pending !== undefined;
+ const begin = () => {
+ setEditing(title);
+ requestAnimationFrame(() => field.current?.select());
+ };
+ const keep = () => {
+ const words = (editing ?? "").trim();
+ if (words === "" || words === title) {
+ setEditing(undefined);
+ return;
+ }
+ void writes
+ .write({ action: PULL_REQUEST_ACTION.EDIT_TITLE, title: words })
+ .then(() => setEditing(undefined));
+ };
+ if (editing === undefined) {
+ return (
+