Public: anyone can read this; the crew can change it.

Conflicts, part 3: one tap has the crew propose the resolution as a commit on the pull's branch #2123

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1968-conflicts-part-3-one-tap-has-the-crew-pr → main 11 commits fork 7 threads · 7 unresolved

  • Merged. scout/1968-conflicts-part-3-one-tap-has-the-crew-pr was merged into main as 9d697cb.

Review

Sign in to review this change.
11 files changed +888 −8
README.md +1 −1

Diff not shown — a line 2,382 characters wide (limit 2,000). +1 −1

View file
src/core/runs.ts +4
@@ -282,11 +282,14 @@ export function finishRun(
282282 turns?: number | undefined;
283283 /** A failed check's own detail, when its output was recognised. */
284284 checkDetail?: CheckDetail | undefined;
285 /** For an agent run: the head it left the branch at, when that is worth keeping. */
286 commitOid?: string | undefined;
285287 },
286288 ): Run | undefined {
287289 const changes = db
288290 .query(
289291 `UPDATE run SET state = ?2, summary = ?3,
292 commit_oid = COALESCE(?8, commit_oid),
290293 session_id = COALESCE(?4, session_id), cost_usd = COALESCE(?5, cost_usd),
291294 turns = COALESCE(?6, turns), check_detail = ?7,
292295 finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
@@ -300,6 +303,7 @@ export function finishRun(
300303 outcome.costUsd ?? null,
301304 outcome.turns ?? null,
302305 outcome.checkDetail ? JSON.stringify(outcome.checkDetail) : "",
306 outcome.commitOid ?? null,
303307 ).changes;
304308 return changes > 0 ? getRun(db, runId) : undefined;
305309 }
src/crew/brief.ts +17
@@ -25,6 +25,7 @@ export type RunReason =
2525 | "feedback"
2626 | "check_failed"
2727 | "conflict"
28 | "resolve"
2829 | "converge"
2930 | "review";
3031
@@ -170,6 +171,8 @@ export interface TaskInput extends BriefInput {
170171 readonly checkKind?: "deploy" | "check" | undefined;
171172 /** For `conflict` and `converge`: the files both sides changed. */
172173 readonly conflictFiles?: readonly string[] | undefined;
174 /** For `resolve`: the conflict itself — both sides, the blocks, the tests. */
175 readonly resolution?: string | undefined;
173176 /** For `converge`: the parent this repository follows, and its branch. */
174177 readonly following?:
175178 | { readonly parent: string; readonly branch: string }
@@ -361,6 +364,20 @@ function taskBody(input: TaskInput): string {
361364 return out.join("\n");
362365 }
363366
367 if (input.reason === "resolve") {
368 const base = input.pull?.baseBranch ?? input.repo.defaultBranch;
369 out.push(
370 `A person asked the crew to propose how pull #${issue.number} should be resolved: \`${input.branch}\` no longer merges cleanly with \`${base}\`.`,
371 "",
372 input.resolution ?? "",
373 "",
374 `On the branch \`${input.branch}\`: merge \`${base}\` into it, resolve each conflict keeping what both sides meant, run the tests above until they pass, and push the merge commit. Where they cannot both be kept, say which you kept and why. Do not rewrite or force-push the branch.`,
375 "",
376 "Your report becomes the note on the pull: say, per file, what you chose and why, in a few sentences. A person reads it next to your commit and may edit the result.",
377 );
378 return out.join("\n");
379 }
380
364381 if (input.reason === "converge") {
365382 const files = input.conflictFiles ?? [];
366383 const parent = input.following?.parent ?? "its parent";
src/crew/dispatch.ts +1
@@ -1048,6 +1048,7 @@ export function taskOf(
10481048 run.reason === "feedback" ||
10491049 run.reason === "check_failed" ||
10501050 run.reason === "conflict" ||
1051 run.reason === "resolve" ||
10511052 run.reason === "converge" ||
10521053 run.reason === "review"
10531054 ? run.reason
src/crew/proposal.ts added +254
@@ -0,0 +1,254 @@
1 /**
2 * The crew proposes a resolution. One tap on the conflicts page queues one
3 * run for the pull's own agent, on the pull's own branch; its brief carries
4 * the conflict (`resolutionBrief`): both sides' commits since the base, the
5 * files and their blocks, and the tests that cover them. The agent commits
6 * the resolution to the branch and its report becomes the note on the pull.
7 */
8
9 import type { App } from "../app.ts";
10 import { getIssueById, listComments } from "../core/issues.ts";
11 import { pullDir, pullStatus, type Pull } from "../core/pulls.ts";
12 import { canWrite, type Repo } from "../core/repos.ts";
13 import { activeRunFor, listRuns, type Run } from "../core/runs.ts";
14 import type { User } from "../core/users.ts";
15 import { readConflictedFiles } from "../git/conflict-file.ts";
16 import { parseConflicts } from "../git/conflicts.ts";
17 import { runGitCapture } from "../git/env.ts";
18 import { isSafeOid, isSafeTreePath } from "../git/guard.ts";
19 import { err, ok, type Result } from "../kernel/result.ts";
20 import { agentFor, queueAgentRun } from "./dispatch.ts";
21
22 export type ProposeError =
23 | "forbidden"
24 | "not_open"
25 | "no_conflict"
26 | "no_agent"
27 | "busy"
28 | "not_queued";
29
30 /** What the brief carries at most: a hand's worth, never a whole history. */
31 const MAX_COMMITS = 20;
32 const MAX_BLOCKS = 12;
33 const MAX_BLOCK_CHARS = 1_500;
34 const MAX_TESTS = 10;
35
36 /** Who would do the work: the agent that holds the pull, if one does. */
37 export function proposerOf(app: App, pull: Pull): string | undefined {
38 const issue = getIssueById(app.db, pull.id);
39 return issue === undefined ? undefined : agentFor(app, issue)?.user.username;
40 }
41
42 export async function proposeResolution(
43 app: App,
44 actor: User | undefined,
45 repo: Repo,
46 pull: Pull,
47 ): Promise<Result<Run, ProposeError>> {
48 if (!canWrite(app.db, repo, actor)) return err("forbidden");
49 if (pull.state !== "open") return err("not_open");
50 const issue = getIssueById(app.db, pull.id);
51 const agent = issue === undefined ? undefined : agentFor(app, issue);
52 if (issue === undefined || agent === undefined) return err("no_agent");
53 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
54 const status = await pullStatus(dir, pull);
55 if (status.conflicts.length === 0) return err("no_conflict");
56 if (activeRunFor(app.db, "agent", issue.id) !== undefined) return err("busy");
57 const run = queueAgentRun(app, repo, issue, agent, "resolve", actor?.id);
58 return run === undefined ? err("not_queued") : ok(run);
59 }
60
61 /** The runs a person's tap started on this pull, newest first. */
62 export function proposalsFor(app: App, repo: Repo, pull: Pull): Run[] {
63 return listRuns(app.db, repo.id, { kind: "agent", issueId: pull.id }).filter(
64 (r) => r.reason === "resolve",
65 );
66 }
67
68 async function commitsBetween(
69 dir: string,
70 range: string,
71 ): Promise<string[]> {
72 const out = await runGitCapture(
73 [
74 "log",
75 "--no-merges",
76 `--max-count=${MAX_COMMITS}`,
77 "--format=%h %an: %s",
78 range,
79 "--",
80 ],
81 { cwd: dir },
82 );
83 return out.code === 0 ? out.stdout.split("\n").filter((l) => l !== "") : [];
84 }
85
86 /** Test files that name a conflicted file, by path or by what they say. */
87 async function testsFor(
88 dir: string,
89 oid: string,
90 files: readonly string[],
91 ): Promise<string[]> {
92 const found = new Set<string>();
93 for (const file of files) {
94 const stem = (file.split("/").pop() ?? "").replace(/\.[^.]*$/, "");
95 if (stem.length < 3 || /[^\w.-]/.test(stem)) continue;
96 const out = await runGitCapture(
97 ["grep", "-l", "-F", "-e", stem, oid, "--", "test", "tests", "*.test.*"],
98 { cwd: dir },
99 );
100 for (const line of out.stdout.split("\n")) {
101 // `git grep` over a commit prefixes each path with `<oid>:`.
102 const path = line.slice(oid.length + 1);
103 if (line.startsWith(`${oid}:`) && path !== "" && path !== file)
104 found.add(path);
105 }
106 }
107 return [...found].sort().slice(0, MAX_TESTS);
108 }
109
110 const clip = (s: string): string =>
111 s.length > MAX_BLOCK_CHARS ? `${s.slice(0, MAX_BLOCK_CHARS)}\n…` : s;
112
113 /** The conflict, in the words the agent reads. Plain text out. */
114 export async function resolutionBrief(
115 app: App,
116 repo: Repo,
117 pull: Pull,
118 ): Promise<string> {
119 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
120 const status = await pullStatus(dir, pull);
121 const { baseOid, headOid, conflicts } = status;
122 if (!isSafeOid(baseOid) || !isSafeOid(headOid)) return "";
123 const mb = await runGitCapture(["merge-base", baseOid, headOid], { cwd: dir });
124 const base = mb.code === 0 ? mb.stdout.trim() : "";
125 const there = isSafeOid(base);
126 const mainSide = there ? await commitsBetween(dir, `${base}..${baseOid}`) : [];
127 const branchSide = there ? await commitsBetween(dir, `${base}..${headOid}`) : [];
128 const texts = await readConflictedFiles(dir, baseOid, headOid, conflicts);
129 const tests = await testsFor(dir, headOid, conflicts);
130
131 const out: string[] = [
132 `The conflict. \`${pull.baseBranch}\` is at ${baseOid.slice(0, 7)}, \`${pull.headBranch}\` at ${headOid.slice(0, 7)}${there ? `, and they parted at ${base.slice(0, 7)}` : ""}.`,
133 "",
134 `Commits on \`${pull.baseBranch}\` since then (${mainSide.length === MAX_COMMITS ? `the latest ${MAX_COMMITS}` : mainSide.length}):`,
135 ...(mainSide.length > 0 ? mainSide.map((c) => `- ${c}`) : ["- (none read)"]),
136 "",
137 `Commits on \`${pull.headBranch}\` since then (${branchSide.length === MAX_COMMITS ? `the latest ${MAX_COMMITS}` : branchSide.length}):`,
138 ...(branchSide.length > 0 ? branchSide.map((c) => `- ${c}`) : ["- (none read)"]),
139 "",
140 `Conflicted files (${conflicts.length}):`,
141 ];
142 for (const file of conflicts) {
143 const parsed = parseConflicts(texts.get(file) ?? "");
144 out.push("", `### ${file} — ${parsed.blocks.length} block${parsed.blocks.length === 1 ? "" : "s"}`);
145 parsed.blocks.slice(0, MAX_BLOCKS).forEach((b, i) => {
146 out.push(
147 `Block ${i + 1}, on \`${pull.baseBranch}\`:`,
148 "```",
149 clip(b.ours),
150 "```",
151 `Block ${i + 1}, on \`${pull.headBranch}\`:`,
152 "```",
153 clip(b.theirs),
154 "```",
155 );
156 if (b.base !== "")
157 out.push("Before either changed it:", "```", clip(b.base), "```");
158 });
159 if (parsed.blocks.length > MAX_BLOCKS)
160 out.push(`(${parsed.blocks.length - MAX_BLOCKS} more blocks: read the file.)`);
161 }
162 out.push(
163 "",
164 tests.length > 0
165 ? `Tests that cover these files — run them before you push:\n${tests.map((t) => `- ${t}`).join("\n")}`
166 : "No test names these files; run the repository's whole test suite before you push.",
167 );
168 return out.join("\n");
169 }
170
171 /**
172 * The files a merge commit resolved by hand: the ones git's combined diff
173 * still shows, which is where its text is neither parent's. A commit that is
174 * not a merge names the files it changed.
175 */
176 export async function resolvedFiles(dir: string, oid: string): Promise<string[]> {
177 if (!isSafeOid(oid)) return [];
178 const out = await runGitCapture(
179 ["diff-tree", "--cc", "--no-commit-id", "--name-only", "-r", "-z", oid],
180 { cwd: dir },
181 );
182 if (out.code !== 0) return [];
183 return out.stdout
184 .split("\0")
185 .filter((p) => p !== "" && isSafeTreePath(p))
186 .slice(0, 20);
187 }
188
189 /** The agent's latest word on the pull since a run began: its note. */
190 export function noteSince(app: App, pull: Pull, agent: string, since: string): string | undefined {
191 const said = listComments(app.db, pull.id).filter(
192 (c) => c.authorName === agent && c.createdAt >= since,
193 );
194 return said.at(-1)?.body;
195 }
196
197 /** What the crew's latest proposal on a pull amounts to, as plain data for both doors. */
198 export type Proposal =
199 | { readonly kind: "none" }
200 | { readonly kind: "working"; readonly state: string }
201 | { readonly kind: "failed"; readonly state: string; readonly summary: string }
202 | {
203 readonly kind: "landed";
204 readonly oid: string;
205 readonly branch: string;
206 readonly note: string | undefined;
207 readonly files: string[];
208 };
209
210 /** The words for each refusal, the same on the page and in the terminal. */
211 export const PROPOSE_WHY: Record<ProposeError, string> = {
212 forbidden: "Only a person with write access can ask the crew to propose.",
213 not_open: "That pull is closed.",
214 no_conflict: "That pull has no conflicts to resolve.",
215 no_agent: "No agent holds this pull, so the crew has no hand to ask.",
216 busy: "The crew is already working on this pull.",

No test reaches busy (or no_agent / not_open above it). Add one per refusal: the error, and no new run.

217 not_queued: "The crew could not take it on right now (its ceiling or breaker); try again later.",
218 };
219
220 /**
221 * The latest proposal. A landed one is shown only while the pull's head is
222 * still the commit that run left: after another push, it is not the crew's.
223 * A failed one only while the pull still conflicts.
224 */
225 export async function proposalOf(
226 app: App,
227 repo: Repo,
228 pull: Pull,
229 dir: string,
230 ): Promise<Proposal> {
231 const last = proposalsFor(app, repo, pull)[0];
232 if (last === undefined) return { kind: "none" };
233 if (last.state === "queued" || last.state === "running")
234 return { kind: "working", state: last.state };
235 if (last.state !== "ok") {
236 // A failure is news only while there is still something to resolve.
237 const status = await pullStatus(dir, pull);
238 return status.conflicts.length === 0
239 ? { kind: "none" }
240 : { kind: "failed", state: last.state, summary: last.summary };
241 }
242 if (last.commitOid === "" || last.commitOid !== pull.headOid) return { kind: "none" };
243 const agent = proposerOf(app, pull);
244 return {
245 kind: "landed",
246 oid: last.commitOid,
247 branch: pull.headBranch,
248 note:
249 agent === undefined
250 ? undefined
251 : noteSince(app, pull, agent, last.startedAt ?? last.createdAt),
252 files: await resolvedFiles(dir, last.commitOid),
253 };
254 }
src/crew/runner.ts +31
@@ -16,6 +16,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
1616 import { join } from "node:path";
1717 import type { App } from "../app.ts";
1818 import { addComment } from "../actions/issues.ts";
19 import { resolutionBrief } from "./proposal.ts";
1920 import { openPull } from "../actions/pulls.ts";
2021 import { forkRepo, sealedForkFor } from "../actions/repos.ts";
2122 import { announceRun } from "../actions/runs.ts";
@@ -30,6 +31,7 @@ import {
3031 listComments,
3132 type Issue,
3233 } from "../core/issues.ts";
34 import { refreshPullHead } from "../core/following.ts";
3335 import { getPull, listPulls, type Pull } from "../core/pulls.ts";
3436 import {
3537 canWrite,
@@ -364,17 +366,42 @@ export async function executeAgentRun(
364366 ? interruptedSummary(outcome.summary, outcome.resumesAt)
365367 : outcome.summary;
366368 write(outcome.state === "ok" ? `done: ${summary}` : `failed: ${summary}`);
369 // A proposed resolution is the head it left the pull at: kept, so the page
370 // never calls a later push the crew's commit.
371 const left =
372 run.reason === "resolve" && outcome.state === "ok"
373 ? await headLeftBy(app, run)
374 : undefined;
367375 const done = finishRun(app.db, run.id, {
368376 state: outcome.state,
369377 summary,
370378 sessionId: outcome.sessionId,
371379 costUsd: outcome.costUsd,
372380 turns: outcome.turns,
381 commitOid: left,
373382 });
374383 if (done) announceRun(app, done);
375384 return undefined;
376385 }
377386
387 /** The head of the run's pull as the run left it. */
388 async function headLeftBy(app: App, run: Run): Promise<string | undefined> {
389 const repo = getRepoById(app.db, run.repoId);
390 const issue =
391 run.issueId === null ? undefined : getIssueById(app.db, run.issueId);
392 if (repo === undefined || issue === undefined || issue.kind !== "pull") return undefined;
393 const pull = getPull(app.db, repo.id, issue.number);
394 if (pull === undefined) return undefined;
395 // A fork's push reaches the pull's row by a fetch; take it now, not later.
396 // A push's own follow-up may have taken the head already, after this row
397 // was read: ask the row again rather than trust the copy in hand.
398 return (
399 (await refreshPullHead(app, repo, pull)) ??
400 getPull(app.db, repo.id, issue.number)?.headOid ??
401 pull.headOid
402 );
403 }
404
378405 /** Whether a queued follow-up would find nothing new (`news.ts`): recorded as a skip. */
379406 function skipsQueued(app: App, run: Run): boolean {
380407 const repo = getRepoById(app.db, run.repoId);
@@ -829,6 +856,10 @@ async function work(
829856 ),
830857 since,
831858 checkSummary: failedCheck?.run?.summary,
859 resolution:
860 reason === "resolve" && pull
861 ? await resolutionBrief(app, repo, pull)
862 : undefined,
832863 conflictFiles:
833864 reason === "conflict" || reason === "converge"
834865 ? run.summary
src/http/server.ts +2 −1
@@ -308,7 +308,7 @@ import {
308308 pullThreadFragment,
309309 threadResolveSubmit,
310310 } from "../web/pages/pull.ts";
311 import { conflictsFinish, conflictsPage } from "../web/pages/conflicts.ts";
311 import { conflictsFinish, conflictsPage, proposeSubmit } from "../web/pages/conflicts.ts";
312312 import {
313313 appDisable,
314314 appMembersUpdate,
@@ -1025,6 +1025,7 @@ export function buildRouter(): Router<Handler> {
10251025 router.add("GET", "/:owner/:name/pulls/:number/files", pullFilesPage);
10261026 router.add("GET", "/:owner/:name/pulls/:number/resolve", conflictsPage);
10271027 router.add("POST", "/:owner/:name/pulls/:number/resolve", conflictsFinish);
1028 router.add("POST", "/:owner/:name/pulls/:number/resolve/propose", proposeSubmit);
10281029 router.add("GET", "/:owner/:name/pulls/:number/thread", pullThreadFragment);
10291030 router.add("GET", "/:owner/:name/pulls/:number/state", pullStateFragment);
10301031 router.add("POST", "/:owner/:name/pulls/:number/comments", pullCommentSubmit);
src/tui/screens/conflicts.ts +54 −2
@@ -12,6 +12,7 @@ import {
1212 type Choice,
1313 } from "../../git/conflicts.ts";
1414 import type { Ctx, Screen } from "../app/app.ts";
15 import type { Proposal } from "../../crew/proposal.ts";
1516 import { binding } from "../app/keys.ts";
1617 import { style } from "../term/style.ts";
1718 import { fill, len, split } from "../ui/layout.ts";
@@ -28,11 +29,40 @@ export type Finish = (
2829 choices: readonly (Choice | undefined)[],
2930 ) => Promise<{ ok: boolean; message: string }>;
3031
32 /** The crew's door: what its latest proposal is, and the ask for another. */
33 export interface CrewDoor {
34 proposal: () => Promise<Proposal>;
35 propose: () => Promise<{ ok: boolean; message: string }>;
36 }
37
38 /** A proposal in the words the page uses, one row each. */
39 function proposalRows(seen: Proposal | undefined): string[] {
40 if (seen === undefined || seen.kind === "none") return [];
41 if (seen.kind === "working")
42 return [`The crew is working on a proposal (${seen.state}). Press r to look again.`];
outdated

"Press c to look again": c is propose, not look. While working it only gives a red toast; once the run has failed it queues a new paid run. Use a separate key that looks only (or refresh on a timer while working).

43 if (seen.kind === "failed")
44 return [`The last proposal did not land (${seen.state}): ${seen.summary}`];
45 return [
46 `The crew proposed a resolution, committed as ${seen.oid.slice(0, 7)} on ${seen.branch}.`,
47 ...(seen.note === undefined ? [] : seen.note.split("\n")),
48 ...(seen.files.length === 0 ? [] : [`Edit by hand: ${seen.files.join(", ")}`]),
49 ];
50 }
51
3152 export function conflictScreen(
3253 ctx: Ctx,
3354 read: PullConflicts,
3455 finish?: Finish,
56 crew?: CrewDoor,
3557 ): Screen {
58 /** The crew's latest proposal, read when the screen opens and after each ask. */
59 let seen: Proposal | undefined;
60 const look = (): Promise<void> =>
61 crew === undefined
62 ? Promise.resolve()
63 : crew.proposal().then((p) => {
64 seen = p;
65 });
3666 const scroll = new Scroll();
3767 const composer = new Composer();
3868 /** choices[file][block] */
@@ -58,7 +88,13 @@ export function conflictScreen(
5888 };
5989 const total = flat.length;
6090 say(`${plural(total, "conflict block")} in ${plural(read.files.length, "file")}`);
61 say("n/p block · o ours · t theirs · b both · e your own text · f finish (commit to the branch)", muted);
91 say(`n/p block · o ours · t theirs · b both · e your own text · f finish (commit to the branch)${crew ? " · c have the crew propose" : ""}`, muted);
92 const crewRows = proposalRows(seen);
93 if (crewRows.length > 0) {
94 say("");
95 say("The crew");
96 for (const row of crewRows) say(` ${row}`, muted);
97 }
6298 let n = 0;
6399 read.files.forEach((file, fi) => {
64100 const mine = choices[fi]!;
@@ -115,10 +151,11 @@ export function conflictScreen(
115151 binding(["o", "t", "b"], "ours / theirs / both"),
116152 binding(["e"], "your text"),
117153 binding(["f"], "finish"),
154 ...(crew ? [binding(["c"], "have the crew propose"), binding(["r"], "look at the crew's proposal")] : []),
118155 binding(["j", "k"], "scroll"),
119156 binding(["esc", "q"], "back"),
120157 ],
121 load: () => Promise.resolve(),
158 load: look,
122159 render(area, s) {
123160 const [body, comp] = split(area, "v", [fill(1), len(composer.height())]);
124161 const all = lines();
@@ -149,6 +186,21 @@ export function conflictScreen(
149186 });
150187 return true;
151188 }
189 // Looking costs nothing and never asks; `c` is the only key that spends.
outdated

Looking and asking share this key. Split them: c asks, and another key (or a timer) calls look() only.

190 if (!e.ctrl && !e.alt && e.key === "r" && crew) {
191 void look().catch(() => ctx.toast("the crew's proposal could not be read", "danger"));
192 return true;
193 }
194 if (!e.ctrl && !e.alt && e.key === "c" && crew) {
195 void crew
196 .propose()
197 .then(async (done) => {
198 ctx.toast(done.message, done.ok ? "success" : "danger");
199 await look();
200 })
201 .catch(() => ctx.toast("the crew could not be asked", "danger"));
202 return true;
203 }
152204 if (!e.ctrl && !e.alt && flat.length > 0) {
153205 if (e.key === "n") return move(1), true;
154206 if (e.key === "p") return move(-1), true;
src/tui/screens/pull.ts +15 −1
@@ -48,7 +48,7 @@ import {
4848 import { getAppByRepo } from "../../core/apps.ts";
4949 import { getIssue, type Issue } from "../../core/issues.ts";
5050 import { pullMayRun } from "../../core/pull_trust.ts";
51 import { getPull, variantSiblings, type Pull } from "../../core/pulls.ts";
51 import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts";
5252 import { voteVariant } from "../../actions/variants.ts";
5353 import { getRepoById, type Repo } from "../../core/repos.ts";
5454 import { checkSummary, type CheckSummary } from "../../core/runs.ts";
@@ -104,6 +104,7 @@ import { DiffView, type DiffAnchor, type DiffNote } from "./commits.ts";
104104 import { liveBindings, liveKey } from "./mirror.ts";
105105 import { pullConflicts } from "../../actions/conflicts.ts";
106106 import { FINISH_REFUSALS, finishConflicts } from "../../actions/finish-conflicts.ts";
107 import { PROPOSE_WHY, proposalOf, proposeResolution } from "../../crew/proposal.ts";
107108 import { conflictScreen } from "./conflicts.ts";
108109 import { reviewScreen } from "./review.ts";
109110 import { RunList, openRunLog, runTone } from "./runlist.ts";
@@ -663,6 +664,19 @@ export function pullScreen(
663664 return done.ok
664665 ? { ok: true, message: "Conflicts resolved: committed to the branch." }
665666 : { ok: false, message: FINISH_REFUSALS[done.error] };
667 }, {
668 proposal: async () => {
669 const fresh = getPull(f.db, target.id, number) ?? row;
670 const dir = await pullDir(f.app.db, f.app.reposRoot, target, fresh);
671 return proposalOf(f.app, target, fresh, dir);
672 },
673 propose: async () => {
674 const fresh = getPull(f.db, target.id, number) ?? row;
675 const made = await proposeResolution(f.app, f.user, target, fresh);
676 return made.ok
677 ? { ok: true, message: "Asked: the crew is resolving it on the pull's own branch." }
678 : { ok: false, message: PROPOSE_WHY[made.error] };
679 },
666680 }),
667681 );
668682 });
src/web/pages/conflicts.ts +75 −3
@@ -7,8 +7,16 @@
77 * Writers only.
88 */
99
10 import { canWrite } from "../../core/repos.ts";
11 import { pullDir, pullStatus } from "../../core/pulls.ts";
10 import { canWrite, getRepoById, type Repo } from "../../core/repos.ts";
11 import { pullDir, pullStatus, type Pull } from "../../core/pulls.ts";
12 import { activeRunFor } from "../../core/runs.ts";
13 import { getIssueById } from "../../core/issues.ts";
14 import {
15 PROPOSE_WHY,
16 proposalOf,
17 proposerOf,
18 proposeResolution,
19 } from "../../crew/proposal.ts";
1220 import { readConflictedFile } from "../../git/conflict-file.ts";
1321 import {
1422 applyChoices,
@@ -22,6 +30,7 @@ import { FINISH_REFUSALS, finishConflicts } from "../../actions/finish-conflicts
2230 import { field, readForm } from "../form.ts";
2331 import { html, type Html } from "../html.ts";
2432 import { flashTo, page } from "../layout.ts";
33 import { renderMarkdown } from "../markdown.ts";
2534 import { btn, card, icon } from "../ui.ts";
2635 import { changeHead, changeTabs, pullContext } from "../studio/change.ts";
2736
@@ -57,6 +66,67 @@ export function choicesFrom(
5766 return out;
5867 }
5968
69 /** POST /:owner/:name/pulls/:number/resolve/propose — have the crew propose a resolution. */
70 export async function proposeSubmit(ctx: Ctx): Promise<Response> {
71 const found = pullContext(ctx);
72 if (found instanceof Response) return found;
73 const { repo, pull } = found;
74 const form = await readForm(ctx, "rw");
75 if (form instanceof Response) return form;
76 const dest = `${repoBase(repo)}/pulls/${pull.number}/resolve`;
77 const made = await proposeResolution(ctx.app, ctx.auth.user, repo, pull);
78 if (!made.ok) return flashTo(ctx, dest, { tone: "danger", message: PROPOSE_WHY[made.error] });
79 return flashTo(ctx, dest, {
80 message: "Asked: the crew is resolving it on the pull's own branch.",
81 });
82 }
83
84 /**
85 * The crew's proposal: the tap to ask for one while the pull conflicts and
86 * the last run is not still working, and what the last run did — its note,
87 * its commit, and the files to edit by hand.
88 */
89 async function proposalCard(
90 ctx: Ctx,
91 repo: Repo,
92 pull: Pull,
93 dir: string,
94 conflicted: boolean,
95 ): Promise<Html> {
96 const { app } = ctx;
97 const here = `${repoBase(repo)}/pulls/${pull.number}/resolve`;
98 const agent = proposerOf(app, pull);
99 const issue = getIssueById(app.db, pull.id);
100 const working = issue !== undefined && activeRunFor(app.db, "agent", issue.id) !== undefined;
101 const seen = await proposalOf(app, repo, pull, dir);
102 const parts: Html[] = [];
103 if (seen.kind === "working")
104 parts.push(html`<p>The crew is working on a proposal (${seen.state}). This card updates by itself.</p>`);
105 else if (seen.kind === "landed") {
106 const head = getRepoById(app.db, pull.headRepoId) ?? repo;
107 parts.push(html`<p><strong>The crew proposed a resolution</strong>, committed as
108 <a class="mono" href="${repoBase(repo)}/commit/${seen.oid}">${seen.oid.slice(0, 7)}</a>
109 on <span class="mono">${seen.branch}</span>.</p>
110 ${seen.note === undefined ? "" : html`<div class="md">${renderMarkdown(seen.note)}</div>`}
111 ${seen.files.length === 0
112 ? ""
113 : html`<p class="small">Edit by hand: ${seen.files.map(
114 (f) => html`<a class="mono" href="/${head.ownerName}/${head.name}/edit/${encodeURIComponent(pull.headBranch)}/${f.split("/").map(encodeURIComponent).join("/")}">${f}</a> `,
115 )}</p>`}`);
116 } else if (seen.kind === "failed")
117 parts.push(html`<p>The last proposal did not land (${seen.state}): ${seen.summary}</p>`);

The failed card, "says why" in the README, is never rendered in a test. Add one with a failed resolve run on a pull that still conflicts, checking the page (and the terminal row) shows the summary.

118 if (conflicted && agent !== undefined && working && seen.kind !== "working")
119 parts.push(html`<p class="muted">${PROPOSE_WHY.busy}</p>`);
120 if (conflicted && agent !== undefined && !working)
121 parts.push(html`<form method="post" action="${here}/propose">
122 <p>${btn("Have the crew propose", { icon: "check" })}
123 <span class="muted small">${"@" + agent} merges the base into this branch and commits a resolution.</span></p>
124 </form>`);
125 if (parts.length === 0) return html``;
126 // A live region: the run's events re-read the card, so nobody reloads.
127 return html`<div id="crew-proposal" data-live="run.*,pull.*,comment.*">${card(html`${parts}`, { head: html`<h2>The crew</h2>` })}</div>`;
128 }
129
60130 const CSS = `
61131 .rs-list { display: grid; gap: 12px; margin-top: 12px; }
62132 .rs-card { content-visibility: auto; contain-intrinsic-size: auto 260px; }
@@ -99,7 +169,8 @@ export async function conflictsPage(ctx: Ctx): Promise<Response> {
99169 ? "Nothing to resolve there: that file is not one of the conflicted files."
100170 : "Nothing to resolve: this pull has no conflicts."}
101171 <a href="${back}">Back to the files</a>.</p>`,
102 )}`,
172 )}
173 ${await proposalCard(ctx, repo, pull, dir, false)}`,
103174 });
104175 }
105176
@@ -161,6 +232,7 @@ export async function conflictsPage(ctx: Ctx): Promise<Response> {
161232 )}
162233 </p>`
163234 : ""}
235 ${await proposalCard(ctx, repo, pull, dir, true)}
164236 ${total === 0
165237 ? card(
166238 html`<p>This file has no text blocks to choose between (it may be
test/crew-proposal.test.ts added +434
@@ -0,0 +1,434 @@
1 /**
2 * Conflicts, part 3: one tap has the crew propose the resolution. A stand-in
3 * Claude Code merges the base into the pull's own branch, resolves the file
4 * and pushes; the proposal lands as a commit on the head branch and its
5 * report is the note on the pull.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { chmodSync, readFileSync, writeFileSync } from "node:fs";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { addToCrew, createAgent } from "../src/actions/crew.ts";
15 import { assignIssue, openIssue } from "../src/actions/issues.ts";
16 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
17 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
18 import { proposalOf, proposeResolution, resolutionBrief } from "../src/crew/proposal.ts";
19 import { getIssueById, listComments } from "../src/core/issues.ts";
20 import { getPull, listPulls, pullDir, type Pull } from "../src/core/pulls.ts";
21 import type { Repo } from "../src/core/repos.ts";
22 import { createRun, listRuns, type Run } from "../src/core/runs.ts";
23 import { taskPrompt } from "../src/crew/brief.ts";
24 import { getAgentByName } from "../src/core/agents.ts";
25 import { createUser, type User } from "../src/core/users.ts";
26 import { serve, type Servers } from "../src/http/server.ts";
27 import { createForgeApp } from "../src/tui/forge.ts";
28 import { pullScreen } from "../src/tui/screens/pull.ts";
29 import { local } from "../src/tui/screens/where.ts";
30 import { headlessTerminal } from "../src/tui/term/terminal.ts";
31 import { settleApp } from "./helpers/tui.ts";
32
33 describe("the crew proposes a resolution", () => {
34 let dataDir: string;
35 let app: App;
36 let servers: Servers;
37 let crew: CrewHandle;
38 let alice: User;
39 let bob: User;
40 let repo: Repo;
41 let pull: Pull;
42 let token: string;
43 let promptFile: string;
44
45 async function settled(where: (run: Run) => boolean): Promise<Run> {
46 const deadline = Date.now() + 30_000;
47 while (Date.now() < deadline) {
48 const run = listRuns(app.db, repo.id, { kind: "agent" }).find(where);
49 if (run && ["ok", "failed", "cancelled"].includes(run.state)) return run;
50 await Bun.sleep(100);
51 }
52 throw new Error("agent run did not settle");
53 }
54
55 const tap = () =>
56 fetch(`${servers.url}/${repo.ownerName}/${repo.name}/pulls/${pull.number}/resolve/propose`, {
57 method: "POST",
58 headers: { authorization: `Bearer ${token}` },
59 redirect: "manual",
60 });
61
62 beforeAll(async () => {
63 dataDir = await mkdtemp(join(tmpdir(), "forge-proposal-"));
64 app = createApp({ dataDir, port: 0 });
65 servers = await serve(app);
66 promptFile = join(dataDir, "fake.prompt");
67 // Told to propose, it merges the base in, keeps both sides' lines and
68 // pushes; otherwise it changes the file the way a builder would.
69 const fake = join(dataDir, "fake-claude");
70 writeFileSync(
71 fake,
72 `#!/bin/sh
73 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
74 IFS= read -r first
75 echo '{"type":"system","subtype":"init","session_id":"fake-1","model":"fake"}'
76 if printf '%s' "$first" | grep -q "propose how pull"; then
77 printf '%s' "$first" > "${promptFile}"
78 git merge origin/main >/dev/null 2>&1
79 printf 'both lines\\n' > notes.txt
80 git add -A && git commit -qm "Resolve notes.txt" && git push -q -u origin HEAD
81 echo '{"type":"result","subtype":"success","is_error":false,"result":"Kept both lines in notes.txt: the crew wrote one, alice the other, and nothing needs one to win.","total_cost_usd":0.01,"num_turns":3,"session_id":"fake-1","terminal_reason":"completed"}'
82 exit 0
83 fi
84 echo "crew line" > notes.txt
85 git add -A && git commit -qm "crew work" && git push -q -u origin HEAD
86 echo '{"type":"result","subtype":"success","is_error":false,"result":"Wrote notes.txt.","total_cost_usd":0.01,"num_turns":3,"session_id":"fake-1","terminal_reason":"completed"}'
87 `,
88 );
89 chmodSync(fake, 0o755);
90 crew = await startCrew(app, {
91 forgeUrl: servers.url,
92 localUrl: servers.url,
93 binary: fake,
94 concurrency: 1,
95 budgetUsd: 2,
96 runTimeoutMs: 60_000,
97 });
98 const a = await createUser(app.db, {
99 trusted: true,
100 username: "alice",
101 email: "[email protected]",
102 password: "a-good-passphrase",
103 adminIfFirst: true,
104 });
105 if (!a.ok) throw new Error(a.error);
106 alice = a.value;
107 const b = await createUser(app.db, {
108 trusted: true,
109 username: "bob",
110 email: "[email protected]",
111 password: "a-good-passphrase",
112 });
113 if (!b.ok) throw new Error(b.error);
114 bob = b.value;
115 const made = await createAgent(app, alice, { username: "bot" });
116 if (!made.ok) throw new Error(String(made.error));
117 const created = await createRepo(app, alice, {
118 name: "garden",
119 visibility: "public",
120 });
121 if (!created.ok) throw new Error(String(created.error));
122 repo = created.value;
123 const seeded = await commitToBranch(app, alice, repo, {
124 message: "Start",
125 files: [{ path: "notes.txt", content: "base line\n" }],
126 });
127 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
128 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
129 const t = await fetch(`${servers.url}/api/v1/tokens`, {
130 method: "POST",
131 headers: { "content-type": "application/json" },
132 body: JSON.stringify({
133 adminConfirm: true,
134 username: "alice",
135 password: "a-good-passphrase",
136 name: "t",
137 scope: "rw",
138 }),
139 }).then((r) => r.json() as Promise<{ token?: string }>);
140 token = t.token ?? "";
141
142 // The builder's pull, then the base moves under it.
143 const issue = openIssue(app, alice, repo, { title: "Write notes", body: "Do it." });
144 if (!issue.ok) throw new Error(String(issue.error));
145 expect(assignIssue(app, alice, repo, issue.value, made.value.user).ok).toBe(true);
146 await settled((r) => r.issueId === issue.value.id);
147 pull = listPulls(app.db, repo.id, { state: "open" })[0]!;
148 const moved = await commitToBranch(app, alice, repo, {
149 message: "Alice rewrites the line",
150 files: [{ path: "notes.txt", content: "alice line\n" }],
151 });
152 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
153 }, 60_000);
154
155 afterAll(async () => {
156 await crew.stop();
157 servers.stop();
158 app.close();
159 await rm(dataDir, { recursive: true, force: true });
160 });
161
162 test("the brief carries both sides, the blocks and the tests", async () => {
163 const text = await resolutionBrief(app, repo, pull);
164 expect(text).toContain("Alice rewrites the line");
165 expect(text).toContain("crew work");
166 expect(text).toContain("### notes.txt — 1 block");
167 expect(text).toContain("alice line");
168 expect(text).toContain("crew line");
169 expect(text).toContain("No test names these files");
170 });
171
172 test("the page offers the tap to a writer, not to a reader", async () => {
173 const page = await fetch(
174 `${servers.url}/${repo.ownerName}/${repo.name}/pulls/${pull.number}/resolve`,
175 { headers: { authorization: `Bearer ${token}` } },
176 ).then((r) => r.text());
177 expect(page).toContain("Have the crew propose");
178 expect(page).toContain(`pulls/${pull.number}/resolve/propose`);
179 const stranger = await fetch(
180 `${servers.url}/${repo.ownerName}/${repo.name}/pulls/${pull.number}/resolve`,
181 );
182 expect(await stranger.text()).not.toContain("Have the crew propose");
183 });
184
185 test("a reader's tap, and a cross-origin one, start nothing", async () => {
186 const before = listRuns(app.db, repo.id, { kind: "agent" }).length;
187 expect((await proposeResolution(app, bob, repo, pull)).ok).toBe(false);
188 expect(
189 (
190 await fetch(
191 `${servers.url}/${repo.ownerName}/${repo.name}/pulls/${pull.number}/resolve/propose`,
192 {
193 method: "POST",
194 headers: { authorization: `Bearer ${token}`, origin: "https://evil.example" },
195 redirect: "manual",
196 },
197 )
198 ).status,
199 ).toBe(403);
200 expect(listRuns(app.db, repo.id, { kind: "agent" }).length).toBe(before);
201 });
202
203 test("one tap lands a commit on the head branch and a note on the pull", async () => {
204 const res = await tap();
205 expect(res.status).toBe(303);
206 const run = await settled((r) => r.reason === "resolve");
207 expect(run.state).toBe("ok");
208 expect(run.branch).toBe(pull.headBranch);
209
210 const prompt = readFileSync(promptFile, "utf8");
211 expect(prompt).toContain("propose how pull");
212 expect(prompt).toContain("alice line");
213
214 const now = getPull(app.db, repo.id, pull.number)!;
215 expect(now.headOid).not.toBe(pull.headOid);
216 const note = listComments(app.db, pull.id).find((c) => c.body.includes("Kept both lines"));
217 expect(note?.authorName).toBe("bot");
218
219 // The page now shows the proposal, its note and the file to edit.
220 const page = await fetch(
221 `${servers.url}/${repo.ownerName}/${repo.name}/pulls/${pull.number}/resolve`,
222 { headers: { authorization: `Bearer ${token}` } },
223 ).then((r) => r.text());
224 expect(page).toContain("The crew proposed a resolution");
225 expect(page).toContain("Kept both lines");
226 expect(page).toContain('id="crew-proposal" data-live="run.*');
227 expect(page).toContain(`/edit/`);
228 expect(page).toContain(now.headOid.slice(0, 7));
229 });
230
231 test("with nothing to resolve, the tap is refused", async () => {
232 const again = await proposeResolution(app, alice, repo, getPull(app.db, repo.id, pull.number)!);
233 expect(again.ok).toBe(false);
234 if (!again.ok) expect(again.error).toBe("no_conflict");
235 });
236
237 /** Open the pull in the terminal as `user`, press `v`, then each of `keys`. */
238 async function terminal(
239 user: User,
240 keys: string[],
241 until?: (text: string) => boolean,
242 ): Promise<string> {
243 const { app: tui, session } = createForgeApp(
244 app,
245 {
246 sshUsername: user.username,
247 remoteAddress: "127.0.0.1",
248 fingerprint: `SHA256:${user.username}`,
249 user,
250 },
251 { terminal: headlessTerminal(140, 44), exitHooks: false },
252 );
253 tui.start();
254 const press = async (key: string) => {
255 tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
256 await settleApp(tui);
257 };
258 try {
259 tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo), pull.number));
260 const end = Date.now() + 20_000;
261 while (!tui.screen.toText().includes("Conflicts in 1 file")) {
262 if (Date.now() > end) throw new Error(tui.screen.toText());
263 await settleApp(tui);
264 await Bun.sleep(20);
265 }
266 await press("v");
267 await Bun.sleep(1500);
268 await settleApp(tui);
269 for (const key of keys) await press(key);
270 while (until !== undefined && !until(tui.screen.toText())) {
271 if (Date.now() > end + 30_000) throw new Error(tui.screen.toText());
272 await Bun.sleep(100);
outdated

Pressing c in a loop to wait for the result relies on the spend key doubling as refresh. Once there is a separate look key, wait with that one.

273 await press("r");
274 }
275 return tui.screen.toText();
276 } finally {
277 tui.stop();
278 }
279 }
280
281 test("the terminal's c has the crew propose; a reader's c queues nothing", async () => {
282 // The base moves again, so the pull conflicts once more.
283 const moved = await commitToBranch(app, alice, repo, {
284 message: "Alice rewrites it again",
285 files: [{ path: "notes.txt", content: "alice line two\n" }],
286 });
287 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
288 const before = listRuns(app.db, repo.id, { kind: "agent" }).filter(
289 (r) => r.reason === "resolve",
290 ).length;
291
292 const reader = await terminal(bob, ["c"]);
293 expect(reader).not.toContain("have the crew propose");
294 expect(
295 listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.reason === "resolve").length,
296 ).toBe(before);
297 expect((await proposeResolution(app, bob, repo, getPull(app.db, repo.id, pull.number)!)).ok).toBe(false);
298
299 const screen = await terminal(
300 alice,
301 ["c"],
302 // The first proposal's card is still on show until the head moves, so
303 // wait for the second run to settle and the card to name its commit.
304 (t) => {
305 const run = listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.reason === "resolve");
306 const head = getPull(app.db, repo.id, pull.number)!.headOid.slice(0, 7);
307 return run.length === before + 1 && run[0]!.state === "ok" && t.includes(head);
308 },
309 );
310 const runs = listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.reason === "resolve");
311 expect(runs.length).toBe(before + 1);
312 expect(screen).toContain("Kept both lines");
313 }, 90_000);
314
outdated

now is never used: remove it.

315 test("the card names only the commit the run left: a later push hides it", async () => {
316 const landed = await fetch(
317 `${servers.url}/${repo.ownerName}/${repo.name}/pulls/${pull.number}/resolve`,
318 { headers: { authorization: `Bearer ${token}` } },
319 ).then((r) => r.text());
320 expect(landed).toContain("The crew proposed a resolution");
321 // Someone pushes to the pull's branch (a fork's, so the row follows by a fetch).
322 app.db.query("UPDATE pull SET head_oid = ?2 WHERE issue_id = ?1").run(pull.id, "a".repeat(40));
323 const page = await fetch(
324 `${servers.url}/${repo.ownerName}/${repo.name}/pulls/${pull.number}/resolve`,
325 { headers: { authorization: `Bearer ${token}` } },
326 ).then((r) => r.text());
327 expect(page).not.toContain("The crew proposed a resolution");
328 });
329
330 test("a failed proposal is not left on the page once nothing conflicts", async () => {

This covers only the hidden case. Add the shown case next to it: still conflicted, failed run, so the summary is visible.

331 const now = getPull(app.db, repo.id, pull.number)!;
332 const run = createRun(app.db, {
333 repoId: repo.id,
334 kind: "agent",
335 issueId: pull.id,
336 reason: "resolve",
337 });
338 app.db
339 .query("UPDATE run SET state = 'failed', summary = 'it gave up' WHERE id = ?1")
340 .run(run.id);
341 app.db.query("UPDATE pull SET head_oid = ?2 WHERE issue_id = ?1").run(pull.id, now.headOid);
342 const dir = await pullDir(app.db, app.reposRoot, repo, getPull(app.db, repo.id, pull.number)!);
343 const seen = await proposalOf(app, repo, getPull(app.db, repo.id, pull.number)!, dir);
344 expect(seen.kind).toBe("none");
345 });
346
347 const resolvePage = () =>
348 fetch(`${servers.url}/${repo.ownerName}/${repo.name}/pulls/${pull.number}/resolve`, {
349 headers: { authorization: `Bearer ${token}` },
350 }).then((r) => r.text());
351 const current = () => getPull(app.db, repo.id, pull.number)!;
352 const agentRuns = () => listRuns(app.db, repo.id, { kind: "agent" }).length;
353
354 test("a failed proposal on a pull that still conflicts says why, on the page and in the terminal", async () => {
355 // The base moves once more, so the pull conflicts again; the last
356 // resolve run is the failed one from the test above.
357 const moved = await commitToBranch(app, alice, repo, {
358 message: "Alice rewrites it a third time",
359 files: [{ path: "notes.txt", content: "alice line three\n" }],
360 });
361 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
362 const page = await resolvePage();
363 expect(page).toContain("The last proposal did not land (failed): it gave up");
364 expect(page).toContain("Have the crew propose");
365
366 const screen = await terminal(alice, [], (t) => t.includes("did not land"));
367 expect(screen).toContain("The last proposal did not land (failed): it gave up");
368 }, 60_000);
369
370 test("while an agent run is under way, the tap is busy: no second run, no button", async () => {
371 const run = createRun(app.db, {
372 repoId: repo.id,
373 kind: "agent",
374 issueId: pull.id,
375 reason: "comment",
376 });
377 app.db.query("UPDATE run SET state = 'running' WHERE id = ?1").run(run.id);
378 const before = agentRuns();
379 const refused = await proposeResolution(app, alice, repo, current());
380 expect(refused.ok).toBe(false);
381 if (!refused.ok) expect(refused.error).toBe("busy");
382 expect(agentRuns()).toBe(before);
383 const page = await resolvePage();
384 expect(page).toContain("The crew is already working on this pull.");
385 expect(page).not.toContain("Have the crew propose");
386 app.db.query("UPDATE run SET state = 'cancelled' WHERE id = ?1").run(run.id);
387 });
388
389 test("a pull no agent holds is refused with no_agent and shows no button", async () => {
390 app.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(pull.id, bob.id);
391 try {
392 const before = agentRuns();
393 const refused = await proposeResolution(app, alice, repo, current());
394 expect(refused.ok).toBe(false);
395 if (!refused.ok) expect(refused.error).toBe("no_agent");
396 expect(agentRuns()).toBe(before);
397 expect(await resolvePage()).not.toContain("Have the crew propose");
398 } finally {
399 app.db.query("UPDATE issue SET assignee_id = NULL WHERE id = ?1").run(pull.id);
400 }
401 });
402
403 test("a closed pull is refused with not_open", async () => {
404 const before = agentRuns();
405 const refused = await proposeResolution(app, alice, repo, { ...current(), state: "closed" });
406 expect(refused.ok).toBe(false);
407 if (!refused.ok) expect(refused.error).toBe("not_open");
408 expect(agentRuns()).toBe(before);
409 });
410
411 test("the brief names the pull's real base, not main", async () => {
412 const real = getPull(app.db, repo.id, pull.number)!;
413 const agent = getAgentByName(app.db, "bot")!;
414 const text = taskPrompt({
415 agent,
416 repo,
417 issue: getIssueById(app.db, pull.id)!,
418 pull: { ...real, baseBranch: "trunk" },
419 branch: real.headBranch,
420 forgeUrl: servers.url,
421 isApp: false,
422 resuming: false,
423 reason: "resolve",
424 comments: [],
425 reviews: [],
426 reviewComments: [],
427 since: real.createdAt,
428 resolution: "x",
429 });
430 expect(text).toContain("merge `trunk` into it");
431 expect(text).toContain("with `trunk`");
432 expect(text).not.toContain("`main`");
433 });
434 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs