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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1968-conflicts-part-3-one-tap-has-the-crew-pr → main 11 commits fork 7 threads · 7 unresolved
README.md +1 −1
Diff not shown — a line 2,382 characters wide (limit 2,000). +1 −1
View filesrc/core/runs.ts +4
| @@ -282,11 +282,14 @@ export function finishRun( | ||
| 282 | 282 | turns?: number | undefined; |
| 283 | 283 | /** A failed check's own detail, when its output was recognised. */ |
| 284 | 284 | checkDetail?: CheckDetail | undefined; |
| 285 | /** For an agent run: the head it left the branch at, when that is worth keeping. */ | |
| 286 | commitOid?: string | undefined; | |
| 285 | 287 | }, |
| 286 | 288 | ): Run | undefined { |
| 287 | 289 | const changes = db |
| 288 | 290 | .query( |
| 289 | 291 | `UPDATE run SET state = ?2, summary = ?3, |
| 292 | commit_oid = COALESCE(?8, commit_oid), | |
| 290 | 293 | session_id = COALESCE(?4, session_id), cost_usd = COALESCE(?5, cost_usd), |
| 291 | 294 | turns = COALESCE(?6, turns), check_detail = ?7, |
| 292 | 295 | finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') |
| @@ -300,6 +303,7 @@ export function finishRun( | ||
| 300 | 303 | outcome.costUsd ?? null, |
| 301 | 304 | outcome.turns ?? null, |
| 302 | 305 | outcome.checkDetail ? JSON.stringify(outcome.checkDetail) : "", |
| 306 | outcome.commitOid ?? null, | |
| 303 | 307 | ).changes; |
| 304 | 308 | return changes > 0 ? getRun(db, runId) : undefined; |
| 305 | 309 | } |
src/crew/brief.ts +17
| @@ -25,6 +25,7 @@ export type RunReason = | ||
| 25 | 25 | | "feedback" |
| 26 | 26 | | "check_failed" |
| 27 | 27 | | "conflict" |
| 28 | | "resolve" | |
| 28 | 29 | | "converge" |
| 29 | 30 | | "review"; |
| 30 | 31 | |
| @@ -170,6 +171,8 @@ export interface TaskInput extends BriefInput { | ||
| 170 | 171 | readonly checkKind?: "deploy" | "check" | undefined; |
| 171 | 172 | /** For `conflict` and `converge`: the files both sides changed. */ |
| 172 | 173 | readonly conflictFiles?: readonly string[] | undefined; |
| 174 | /** For `resolve`: the conflict itself — both sides, the blocks, the tests. */ | |
| 175 | readonly resolution?: string | undefined; | |
| 173 | 176 | /** For `converge`: the parent this repository follows, and its branch. */ |
| 174 | 177 | readonly following?: |
| 175 | 178 | | { readonly parent: string; readonly branch: string } |
| @@ -361,6 +364,20 @@ function taskBody(input: TaskInput): string { | ||
| 361 | 364 | return out.join("\n"); |
| 362 | 365 | } |
| 363 | 366 | |
| 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 | ||
| 364 | 381 | if (input.reason === "converge") { |
| 365 | 382 | const files = input.conflictFiles ?? []; |
| 366 | 383 | const parent = input.following?.parent ?? "its parent"; |
src/crew/dispatch.ts +1
| @@ -1048,6 +1048,7 @@ export function taskOf( | ||
| 1048 | 1048 | run.reason === "feedback" || |
| 1049 | 1049 | run.reason === "check_failed" || |
| 1050 | 1050 | run.reason === "conflict" || |
| 1051 | run.reason === "resolve" || | |
| 1051 | 1052 | run.reason === "converge" || |
| 1052 | 1053 | run.reason === "review" |
| 1053 | 1054 | ? 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.", | |
| 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"; | ||
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | import type { App } from "../app.ts"; |
| 18 | 18 | import { addComment } from "../actions/issues.ts"; |
| 19 | import { resolutionBrief } from "./proposal.ts"; | |
| 19 | 20 | import { openPull } from "../actions/pulls.ts"; |
| 20 | 21 | import { forkRepo, sealedForkFor } from "../actions/repos.ts"; |
| 21 | 22 | import { announceRun } from "../actions/runs.ts"; |
| @@ -30,6 +31,7 @@ import { | ||
| 30 | 31 | listComments, |
| 31 | 32 | type Issue, |
| 32 | 33 | } from "../core/issues.ts"; |
| 34 | import { refreshPullHead } from "../core/following.ts"; | |
| 33 | 35 | import { getPull, listPulls, type Pull } from "../core/pulls.ts"; |
| 34 | 36 | import { |
| 35 | 37 | canWrite, |
| @@ -364,17 +366,42 @@ export async function executeAgentRun( | ||
| 364 | 366 | ? interruptedSummary(outcome.summary, outcome.resumesAt) |
| 365 | 367 | : outcome.summary; |
| 366 | 368 | 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; | |
| 367 | 375 | const done = finishRun(app.db, run.id, { |
| 368 | 376 | state: outcome.state, |
| 369 | 377 | summary, |
| 370 | 378 | sessionId: outcome.sessionId, |
| 371 | 379 | costUsd: outcome.costUsd, |
| 372 | 380 | turns: outcome.turns, |
| 381 | commitOid: left, | |
| 373 | 382 | }); |
| 374 | 383 | if (done) announceRun(app, done); |
| 375 | 384 | return undefined; |
| 376 | 385 | } |
| 377 | 386 | |
| 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 | ||
| 378 | 405 | /** Whether a queued follow-up would find nothing new (`news.ts`): recorded as a skip. */ |
| 379 | 406 | function skipsQueued(app: App, run: Run): boolean { |
| 380 | 407 | const repo = getRepoById(app.db, run.repoId); |
| @@ -829,6 +856,10 @@ async function work( | ||
| 829 | 856 | ), |
| 830 | 857 | since, |
| 831 | 858 | checkSummary: failedCheck?.run?.summary, |
| 859 | resolution: | |
| 860 | reason === "resolve" && pull | |
| 861 | ? await resolutionBrief(app, repo, pull) | |
| 862 | : undefined, | |
| 832 | 863 | conflictFiles: |
| 833 | 864 | reason === "conflict" || reason === "converge" |
| 834 | 865 | ? run.summary |
src/http/server.ts +2 −1
| @@ -308,7 +308,7 @@ import { | ||
| 308 | 308 | pullThreadFragment, |
| 309 | 309 | threadResolveSubmit, |
| 310 | 310 | } from "../web/pages/pull.ts"; |
| 311 | import { conflictsFinish, conflictsPage } from "../web/pages/conflicts.ts"; | |
| 311 | import { conflictsFinish, conflictsPage, proposeSubmit } from "../web/pages/conflicts.ts"; | |
| 312 | 312 | import { |
| 313 | 313 | appDisable, |
| 314 | 314 | appMembersUpdate, |
| @@ -1025,6 +1025,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1025 | 1025 | router.add("GET", "/:owner/:name/pulls/:number/files", pullFilesPage); |
| 1026 | 1026 | router.add("GET", "/:owner/:name/pulls/:number/resolve", conflictsPage); |
| 1027 | 1027 | router.add("POST", "/:owner/:name/pulls/:number/resolve", conflictsFinish); |
| 1028 | router.add("POST", "/:owner/:name/pulls/:number/resolve/propose", proposeSubmit); | |
| 1028 | 1029 | router.add("GET", "/:owner/:name/pulls/:number/thread", pullThreadFragment); |
| 1029 | 1030 | router.add("GET", "/:owner/:name/pulls/:number/state", pullStateFragment); |
| 1030 | 1031 | router.add("POST", "/:owner/:name/pulls/:number/comments", pullCommentSubmit); |
src/tui/screens/conflicts.ts +54 −2
| @@ -12,6 +12,7 @@ import { | ||
| 12 | 12 | type Choice, |
| 13 | 13 | } from "../../git/conflicts.ts"; |
| 14 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | import type { Proposal } from "../../crew/proposal.ts"; | |
| 15 | 16 | import { binding } from "../app/keys.ts"; |
| 16 | 17 | import { style } from "../term/style.ts"; |
| 17 | 18 | import { fill, len, split } from "../ui/layout.ts"; |
| @@ -28,11 +29,40 @@ export type Finish = ( | ||
| 28 | 29 | choices: readonly (Choice | undefined)[], |
| 29 | 30 | ) => Promise<{ ok: boolean; message: string }>; |
| 30 | 31 | |
| 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": |
||
| 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 | ||
| 31 | 52 | export function conflictScreen( |
| 32 | 53 | ctx: Ctx, |
| 33 | 54 | read: PullConflicts, |
| 34 | 55 | finish?: Finish, |
| 56 | crew?: CrewDoor, | |
| 35 | 57 | ): 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 | }); | |
| 36 | 66 | const scroll = new Scroll(); |
| 37 | 67 | const composer = new Composer(); |
| 38 | 68 | /** choices[file][block] */ |
| @@ -58,7 +88,13 @@ export function conflictScreen( | ||
| 58 | 88 | }; |
| 59 | 89 | const total = flat.length; |
| 60 | 90 | 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 | } | |
| 62 | 98 | let n = 0; |
| 63 | 99 | read.files.forEach((file, fi) => { |
| 64 | 100 | const mine = choices[fi]!; |
| @@ -115,10 +151,11 @@ export function conflictScreen( | ||
| 115 | 151 | binding(["o", "t", "b"], "ours / theirs / both"), |
| 116 | 152 | binding(["e"], "your text"), |
| 117 | 153 | binding(["f"], "finish"), |
| 154 | ...(crew ? [binding(["c"], "have the crew propose"), binding(["r"], "look at the crew's proposal")] : []), | |
| 118 | 155 | binding(["j", "k"], "scroll"), |
| 119 | 156 | binding(["esc", "q"], "back"), |
| 120 | 157 | ], |
| 121 | load: () => Promise.resolve(), | |
| 158 | load: look, | |
| 122 | 159 | render(area, s) { |
| 123 | 160 | const [body, comp] = split(area, "v", [fill(1), len(composer.height())]); |
| 124 | 161 | const all = lines(); |
| @@ -149,6 +186,21 @@ export function conflictScreen( | ||
| 149 | 186 | }); |
| 150 | 187 | return true; |
| 151 | 188 | } |
| 189 | // Looking costs nothing and never asks; `c` is the only key that spends. | |
outdated
Looking and asking share this key. Split them: |
||
| 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 | } | |
| 152 | 204 | if (!e.ctrl && !e.alt && flat.length > 0) { |
| 153 | 205 | if (e.key === "n") return move(1), true; |
| 154 | 206 | if (e.key === "p") return move(-1), true; |
src/tui/screens/pull.ts +15 −1
| @@ -48,7 +48,7 @@ import { | ||
| 48 | 48 | import { getAppByRepo } from "../../core/apps.ts"; |
| 49 | 49 | import { getIssue, type Issue } from "../../core/issues.ts"; |
| 50 | 50 | 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"; | |
| 52 | 52 | import { voteVariant } from "../../actions/variants.ts"; |
| 53 | 53 | import { getRepoById, type Repo } from "../../core/repos.ts"; |
| 54 | 54 | import { checkSummary, type CheckSummary } from "../../core/runs.ts"; |
| @@ -104,6 +104,7 @@ import { DiffView, type DiffAnchor, type DiffNote } from "./commits.ts"; | ||
| 104 | 104 | import { liveBindings, liveKey } from "./mirror.ts"; |
| 105 | 105 | import { pullConflicts } from "../../actions/conflicts.ts"; |
| 106 | 106 | import { FINISH_REFUSALS, finishConflicts } from "../../actions/finish-conflicts.ts"; |
| 107 | import { PROPOSE_WHY, proposalOf, proposeResolution } from "../../crew/proposal.ts"; | |
| 107 | 108 | import { conflictScreen } from "./conflicts.ts"; |
| 108 | 109 | import { reviewScreen } from "./review.ts"; |
| 109 | 110 | import { RunList, openRunLog, runTone } from "./runlist.ts"; |
| @@ -663,6 +664,19 @@ export function pullScreen( | ||
| 663 | 664 | return done.ok |
| 664 | 665 | ? { ok: true, message: "Conflicts resolved: committed to the branch." } |
| 665 | 666 | : { 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 | }, | |
| 666 | 680 | }), |
| 667 | 681 | ); |
| 668 | 682 | }); |
src/web/pages/conflicts.ts +75 −3
| @@ -7,8 +7,16 @@ | ||
| 7 | 7 | * Writers only. |
| 8 | 8 | */ |
| 9 | 9 | |
| 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"; | |
| 12 | 20 | import { readConflictedFile } from "../../git/conflict-file.ts"; |
| 13 | 21 | import { |
| 14 | 22 | applyChoices, |
| @@ -22,6 +30,7 @@ import { FINISH_REFUSALS, finishConflicts } from "../../actions/finish-conflicts | ||
| 22 | 30 | import { field, readForm } from "../form.ts"; |
| 23 | 31 | import { html, type Html } from "../html.ts"; |
| 24 | 32 | import { flashTo, page } from "../layout.ts"; |
| 33 | import { renderMarkdown } from "../markdown.ts"; | |
| 25 | 34 | import { btn, card, icon } from "../ui.ts"; |
| 26 | 35 | import { changeHead, changeTabs, pullContext } from "../studio/change.ts"; |
| 27 | 36 | |
| @@ -57,6 +66,67 @@ export function choicesFrom( | ||
| 57 | 66 | return out; |
| 58 | 67 | } |
| 59 | 68 | |
| 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 |
||
| 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 | ||
| 60 | 130 | const CSS = ` |
| 61 | 131 | .rs-list { display: grid; gap: 12px; margin-top: 12px; } |
| 62 | 132 | .rs-card { content-visibility: auto; contain-intrinsic-size: auto 260px; } |
| @@ -99,7 +169,8 @@ export async function conflictsPage(ctx: Ctx): Promise<Response> { | ||
| 99 | 169 | ? "Nothing to resolve there: that file is not one of the conflicted files." |
| 100 | 170 | : "Nothing to resolve: this pull has no conflicts."} |
| 101 | 171 | <a href="${back}">Back to the files</a>.</p>`, |
| 102 | )}`, | |
| 172 | )} | |
| 173 | ${await proposalCard(ctx, repo, pull, dir, false)}`, | |
| 103 | 174 | }); |
| 104 | 175 | } |
| 105 | 176 | |
| @@ -161,6 +232,7 @@ export async function conflictsPage(ctx: Ctx): Promise<Response> { | ||
| 161 | 232 | )} |
| 162 | 233 | </p>` |
| 163 | 234 | : ""} |
| 235 | ${await proposalCard(ctx, repo, pull, dir, true)} | |
| 164 | 236 | ${total === 0 |
| 165 | 237 | ? card( |
| 166 | 238 | 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 |
||
| 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
|
||
| 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 | }); |
No test reaches
busy(orno_agent/not_openabove it). Add one per refusal: the error, and no new run.