Skip to content
Merged
8 changes: 6 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,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
Expand All @@ -450,7 +453,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
Expand Down
57 changes: 57 additions & 0 deletions apps/desktop/src/renderer/styles/tasks.css
Original file line number Diff line number Diff line change
Expand Up @@ -2036,6 +2036,63 @@
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(--luke-space-8);
margin-inline-start: auto;
}

.merge-box-note {
color: var(--luke-color-text-muted);
}

.merge-box-split {
display: inline-flex;
align-items: stretch;
}

.merge-box-press {
height: 26px;
padding: 0 var(--luke-space-8);
gap: var(--luke-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(--luke-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(--luke-color-complete);
}
Expand Down
194 changes: 194 additions & 0 deletions apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@ 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 { LINK_PLACE, type ReadLink } from "@luke/features/luke-link";
import {
MERGE_METHOD,
PULL_REQUEST_ACTION,
PULL_REQUEST_WRITE_OUTCOME,
type PullRequestActionRequest,
Expand Down Expand Up @@ -1576,6 +1580,196 @@ test("a change GitHub refused says so in GitHub's own words until dismissed, and
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<PullRequestMerging> = {}): 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<void> {
const press =
[...tab.querySelectorAll<HTMLButtonElement>(".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<HTMLButtonElement>('[aria-label="Choose how to merge"]') ??
assert.fail("the box offers the repository's other methods")
).click(),
);
const rebase =
[
...document.querySelectorAll<HTMLButtonElement>(
'[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<HTMLElement>('[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), []);
});

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<HTMLElement>('[role="dialog"], [role="alertdialog"]') ??
assert.fail("the merge is asked about");
assert.match(dialog.textContent ?? "", /at commit c0ffee1/u);

act(() =>
(
tab.querySelector<HTMLButtonElement>('[aria-label="Pull request actions"]') ??
assert.fail("the bar has a ⋯")
).click(),
);
const refresh =
[...document.querySelectorAll<HTMLButtonElement>('[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<HTMLButtonElement>(
'[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" },
],
]);
});

/** The bar's Review press. */
function reviewPress(tab: HTMLElement): HTMLButtonElement {
return (
Expand Down
37 changes: 31 additions & 6 deletions apps/desktop/src/renderer/tasks/agent-pull-request-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
} from "./conversation-filters";
import { PierreDiff, useDiffLook } from "./file-diff";
import { useFileViewSettings } from "./file-view-settings";
import { blockedWords, MergeActions } from "./merge-box";
import { PullRequestLabels } from "./pull-request-labels";
import { HeaderReviewers, OpenedBy, PersonRef, ReviewStanding } from "./pull-request-people";
import { PullRequestReview } from "./pull-request-review";
Expand Down Expand Up @@ -705,17 +706,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 (
<p className="pull-request-merge" data-merge-tone={tone}>
<div className="pull-request-merge" data-merge-tone={tone}>
{tone === MERGE_TONE.READY ? <CheckIcon aria-hidden="true" /> : null}
{tone === MERGE_TONE.BLOCKED ? <XIcon aria-hidden="true" /> : null}
{tone === MERGE_TONE.WAITING ? <CircleDotIcon aria-hidden="true" /> : null}
{line}
</p>
<span className="pull-request-merge-words">{blocked ?? line}</span>
{actions}
</div>
);
}

Expand Down Expand Up @@ -1002,7 +1009,18 @@ interface OverviewParts {
}

/** Under the title: its labels, the branches and the stack it stands in, 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 (
Expand Down Expand Up @@ -1047,7 +1065,14 @@ function PullRequestHead({ parts, now }: { parts: OverviewParts; now: number }):
</span>
{reviewers.length === 0 ? null : <HeaderReviewers reviewers={reviewers} />}
</div>
{open ? <MergeLine pullRequest={pullRequest} /> : null}
{open ? (
<MergeLine
pullRequest={pullRequest}
actions={
<MergeActions pullRequest={pullRequest} repository={repository} writes={writes} />
}
/>
) : null}
</>
);
}
Expand Down Expand Up @@ -1621,7 +1646,7 @@ export function AgentPullRequestTab({
<>
<header className="pull-request-header">
<HeaderTitle title={title} />
<PullRequestHead parts={parts} now={now} />
<PullRequestHead parts={parts} now={now} repository={repository} writes={writes} />
</header>
<Tabs<PullRequestView>
label="Pull request"
Expand Down
Loading
Loading