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

"How is my forge?" recommends "Ask the owner" for every item, so "make the recommended choices" does nothing #12378

Merged opened this in answer to

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

Merged

mason/forge:mason/12377-how-is-my-forge-recommends-ask-the-owner → main 5 commits fork

  • Merged. mason/12377-how-is-my-forge-recommends-ask-the-owner was merged into main as bbd5a0b.

Review

Sign in to review this change.
6 files changed +337 −19
docs/features/forge-needs.md +16 −1
@@ -4,6 +4,21 @@ One stack of what needs the owner: `needsStack` (`src/actions/needs.ts`) is the
44
55 The terminal has it too: palette command "How is my forge?" (`src/tui/screens/needs.ts`, offered to anyone signed in). `j`/`k` move, `a` takes the recommended choice of the need under the cursor. It reads and writes through the same actions as the tool, so a person's gates are the page's, and an agent's session applies nothing. Any bus event redraws it, so a change made on the page shows there.
66
7 Each need: `sentence`, `why` it ranks, `waits` (what happens if nobody acts), `choices`, and `recommended`: `choice`, `reason`, `undo` (`receipt` or `none`), `safe`, and for an unsafe one the `ask` to put to the owner. A recommendation comes from state (`recommend`). Only a close the crew advised on a pull is safe: it has a receipt. Everything else is "Ask the owner" with the question, including a green pull: a merge has no undo but a revert pull, and green is not a person's approval.
7 Each need: `sentence`, `why` it ranks, `waits` (what happens if nobody acts), `choices`, and `recommended`.
8 `recommended` has `choice`, `reason`, `undo` (`receipt` or `none`), `safe`, and for an unsafe one the `ask` to put to the owner.
9 A recommendation comes from state (`recommend`).
10
11 Safe defaults, taken by apply (`done` says what each did):
12 - A close the crew advised on a pull: it has a receipt.
13 - A restore drill (an admin's): it restores into a scratch place and changes nothing live.
14 - A stopped pull: handed back to its builder with a comment and an assignment; the pull is re-checked as still stopped first.
15 - An issue assigned to the owner: handed to the crew's builder.
16
17 Decisions, left out of apply by name, each with its `options` and the question:
18 - Everything else says "Needs your decision".
19 - A green pull is one: a merge has no undo but a revert pull, and green is not a person's approval.
20 - "Ask the owner" is only for someone who cannot make the choice.
21
22 In the terminal, `a` reports the same `done` rows, or the `refused` reason.
823
924 `forge {apply: "recommended"}` plans (dry by default); `dryRun: false` does the safe ones through the asks' bulk actions (`asks_bulk.ts`), so the per-row access check and the receipt are theirs. Undo with `asks_act {action: "undo", operation}`. The rest is returned untouched under `left`.
src/actions/needs.ts +118 −14
@@ -6,9 +6,14 @@
66 * Applying "recommended" goes through the asks' bulk actions (`asks_bulk.ts`),
77 * so the preview, the per-row access check, the receipt and the undo are theirs.
88 */
9 import type { App } from "../app.ts";
910 import { applyAsksBulk, receiptOf } from "./asks_bulk.ts";
1011 import { askStackFor, type askLinesFor } from "../web/studio/asks.ts";
1112 import { needLine } from "./health.ts";
13 import { drillNow } from "./backups_now.ts";
14 import { builderOf, giveBack, handBack } from "./give_back.ts";
15 import { getIssueById } from "../core/issues.ts";
16 import { canWrite, getRepoById } from "../core/repos.ts";
1217 import { forgeHealth } from "../kernel/guard.ts";
1318 import type { Chips } from "../core/feed_chips.ts";
1419 import type { User } from "../core/users.ts";
@@ -17,7 +22,11 @@ import type { Ctx } from "../http/context.ts";
1722 type Home = Pick<Ctx, "app">;
1823
1924 export interface Recommended {
20 /** The choice, in the words `choices` use; "Ask the owner" when the forge cannot tell. */
25 /**
26 * The choice, in the words `choices` use. "Needs your decision" when no default is safe:
27 * `options` then names the real ones. "Ask the owner" only for someone who cannot make the
28 * choice.
29 */
2130 readonly choice: string;
2231 readonly reason: string;
2332 /** What undoing it takes: "undo" (a receipt), "revert pull", or "none". */
@@ -26,8 +35,13 @@ export interface Recommended {
2635 readonly safe: boolean;
2736 /** For an item not safe: the question to put to the owner. */
2837 readonly ask?: string;
29 /** The asks' bulk action that carries it out, for a safe one: only a close (it has a receipt). */
30 readonly action?: "close";
38 /**
39 * What carries it out, for a safe one: a close (it has a receipt), a drill, or a hand back to
40 * the crew.
41 */
42 readonly action?: "close" | "drill" | "give_back";
43 /** For a need with no safe default: the real options the owner picks between. */
44 readonly options?: readonly string[];
3145 }
3246
3347 export interface Need {
@@ -44,31 +58,38 @@ export interface Need {
4458
4559 type Row = ReturnType<typeof askLinesFor>[number];
4660
47 const askOwner = (question: string, reason: string): Recommended => ({
48 choice: "Ask the owner",
61 /**
62 * No safe default: the owner's call, with the options it is between. Left out of "recommended"
63 * by name.
64 */
65 const needsDecision = (question: string, reason: string, options: readonly string[]): Recommended => ({
66 choice: "Needs your decision",
4967 reason,
5068 undo: "none",
5169 safe: false,
5270 ask: question,
71 options,
5372 });
5473
5574 /** What to do about one row, from its state: its kind, what it waits for, the crew's advice. */
5675 export function recommend(row: Row): Recommended {
5776 const about = row.line;
77 const options = row.words.options;
5878 if (row.items.length !== 1)
59 return askOwner(`Which of these should be taken first: ${about}?`, "several changes share this row; each is its own call");
79 return needsDecision(`Which of these should be taken first: ${about}?`, "several changes share this row; each is its own call", options);
6080 const advice = row.recommendation;
6181 const conflicts = row.waitingFor.word === "conflicts with main";
6282 if (row.numberOf === "pull" && !conflicts) {
6383 // A merge has no undo but a revert pull, so no change is shipped on a recommendation:
6484 // the owner's word is the approval.
6585 if (row.kind === "look")
66 return askOwner(
86 return needsDecision(
6787 `${about}: ship it? Green, no conflicts, nothing asks for changes.`,
6888 "a merge is undone only by a revert pull, and green is not a person's approval",
89 options,
6990 );
7091 if (row.kind === "waiting" && advice?.verdict === "merge")
71 return askOwner(`${about}: ship it? The crew advises merging: ${advice.reason || "no reason given"}.`, "a merge is undone only by a revert pull, and the crew's advice is not a person's approval");
92 return needsDecision(`${about}: ship it? The crew advises merging: ${advice.reason || "no reason given"}.`, "a merge is undone only by a revert pull, and the crew's advice is not a person's approval", options);
7293 if (advice?.verdict === "close" && (row.kind === "waiting" || row.kind === "handover" || row.kind === "stopped"))
7394 return {
7495 choice: "Close it",
@@ -78,14 +99,49 @@ export function recommend(row: Row): Recommended {
7899 action: "close",
79100 };
80101 }
81 if (conflicts) return askOwner(`${about}: it conflicts with main; should its author rebase it?`, "it cannot land until its conflicts are gone");
102 if (row.kind === "stopped" && !conflicts && row.numberOf === "pull")
103 return {
104 choice: "Start the crew on it again",
105 reason: "the check still fails and the crew stopped; handing it back to its builder is a comment and an assignment, nothing is shipped",
106 undo: "none",
107 safe: true,
108 action: "give_back",
109 };
110 if (row.kind === "assigned" && row.numberOf === "issue")
111 return {
112 choice: "Give it to the crew",
113 reason: "it is assigned to you and open; the crew's builder takes it, and you can take it back by assigning it to yourself",
114 undo: "none",
115 safe: true,
116 action: "give_back",
117 };
118 if (conflicts) return needsDecision(`${about}: it conflicts with main; should its author rebase it?`, "it cannot land until its conflicts are gone", options);
82119 if (row.kind === "proposal" || row.kind === "declined")
83 return askOwner(`${about}: what direction should the crew take?`, "a product decision: only the owner can make it");
120 return needsDecision(`${about}: what direction should the crew take?`, "a product decision: only the owner can make it", options);
84121 if (row.kind === "paused")
85 return askOwner(`${about}: raise today's ceiling?`, "it spends money above what is set");
122 return needsDecision(`${about}: raise today's ceiling?`, "it spends money above what is set", options);
86123 if (row.kind === "review")
87 return askOwner(`${about}: ship it, or say what is off?`, "an agent's change is landed after a person's review");
88 return askOwner(`${about}: what should happen?`, "the state does not say which way to go");
124 return needsDecision(`${about}: ship it, or say what is off?`, "an agent's change is landed after a person's review", options);
125 return needsDecision(`${about}: what should happen?`, "the state does not say which way to go", options);
126 }
127
128 /** What to do about one of the forge's own needs, from its cause. */
129 export function forgeRecommended(cause: string, sentence: string): Recommended {
130 if (cause === "drill")
131 return {
132 choice: "Run the drill now",
133 reason: "no restore has ever been proven; a drill restores the newest snapshot into a scratch place and changes nothing live",
134 undo: "none",
135 safe: true,
136 action: "drill",
137 };
138 if (cause.startsWith("drill:"))
139 return needsDecision(sentence, "a standby drill causes a real fault and needs an operator at the keyboard; the forge cannot run it for you", ["Run it with the command", "Leave it"]);
140 if (cause.startsWith("db-"))
141 return needsDecision(`${sentence} Make room, or leave it?`, "the database's size has no safe default: clearing it loses data, leaving it keeps growing", ["Make room", "Leave it"]);
142 if (cause.startsWith("queue:"))
143 return needsDecision(`${sentence} Raise the check slots, or leave them?`, "more slots use more of the machine", ["Raise the check slots", "Leave them"]);
144 return needsDecision(`${sentence} Look into it?`, "the forge's own health is the owner's to act on", ["Open it", "Leave it"]);
89145 }
90146
91147 /** The stack: the forge's own needs first (an admin's), then the asks, most consequential first. */
@@ -98,7 +154,7 @@ export function needsStack(ctx: Home, user: User, chips?: Chips): Need[] {
98154 why: "the forge itself is degraded",
99155 waits: "it stays that way until someone looks",
100156 choices: ["Open it"],
101 recommended: askOwner(`${d.sentence} Look into it?`, "the forge's own health is the owner's to act on"),
157 recommended: forgeRecommended(d.cause, d.sentence),
102158 link: d.href,
103159 items: [],
104160 }),
@@ -134,12 +190,44 @@ export interface Applied {
134190 readonly dryRun: boolean;
135191 /** What was (or would be) done, one row each. */
136192 readonly plan: { id: string; choice: string; reason: string; undo: Recommended["undo"] }[];
193 /** What was done, one row each, with what it did. */
194 readonly done: { id: string; choice: string; result: string }[];
137195 /** Left for the owner, each with the question to ask. */
138196 readonly left: { id: string; sentence: string; ask: string }[];
139197 readonly receipts: { operation: string; receipt: string; undo?: string }[];
140198 readonly refused: { item: string; reason: string }[];
141199 }
142200
201 /** Hand a stopped pull, or an assigned issue, to the crew's builder: the card's own give-back. */
202 export async function handedBack(
203 app: App,
204 user: User,
205 item: string,
206 ): Promise<{ ok: true; says: string } | { ok: false; reason: string }> {
207 if (item.startsWith("assigned:")) {
208 const back = giveBack(app, user, item);
209 return back.ok ? { ok: true, says: `handed to @${back.value.builder}` } : { ok: false, reason: back.error.message };
210 }
211 // The `stopped:` prefix is the item's own word: what is acted on is a pull that is open,
212 // writable by this person and still on the stack as stopped.
213 const pull = getIssueById(app.db, item.slice(item.indexOf(":") + 1));
214 const repo = pull === undefined ? undefined : getRepoById(app.db, pull.repoId);
215 const stillStopped = askStackFor({ app }, user).rows.some((r) => r.kind === "stopped" && r.items.includes(item));
216 if (
217 pull === undefined ||
218 repo === undefined ||
219 pull.kind !== "pull" ||
220 pull.state !== "open" ||
221 !stillStopped ||
222 !canWrite(app.db, repo, user)
223 )
224 return { ok: false, reason: "it no longer asks" };
225 const builder = builderOf(app.db, repo, pull);
226 if (builder === undefined) return { ok: false, reason: "no builder on the crew opened it, so there is no one to give it to" };
227 const given = handBack(app, user, repo, pull, builder, `Given back to the crew by @${user.username}: the recommended choice for a stopped run.`);
228 return given.ok ? { ok: true, says: `handed back to @${builder.username}` } : { ok: false, reason: given.error.message };
229 }
230
143231 /** Apply the recommended choice of every safe item; dry unless `dryRun` is false. */
144232 export async function applyRecommended(ctx: Home, user: User, dryRun: boolean, only?: string): Promise<Applied> {
145233 const stack = needsStack(ctx, user).filter((n) => only === undefined || n.id === only);
@@ -150,10 +238,22 @@ export async function applyRecommended(ctx: Home, user: User, dryRun: boolean, o
150238 left: stack
151239 .filter((n) => !safe.includes(n))
152240 .map((n) => ({ id: n.id, sentence: n.sentence, ask: n.recommended.ask ?? n.sentence })),
241 done: [],
153242 receipts: [],
154243 refused: [],
155244 };
156245 if (dryRun) return out;
246 for (const n of safe.filter((n) => n.recommended.action === "drill")) {
247 const drilled = await drillNow(ctx.app, user);
248 out.done.push({ id: n.id, choice: n.recommended.choice, result: drilled.ok ? drilled.value.says : drilled.error.says });
249 }
250 for (const n of safe.filter((n) => n.recommended.action === "give_back")) {
251 for (const item of n.items) {
252 const back = await handedBack(ctx.app, user, item);
253 if (back.ok) out.done.push({ id: n.id, choice: n.recommended.choice, result: back.says });
254 else out.refused.push({ item, reason: back.reason });
255 }
256 }
157257 for (const action of ["close"] as const) {
158258 const items = safe.filter((n) => n.recommended.action === action).flatMap((n) => [...n.items]);
159259 if (items.length === 0) continue;
@@ -163,6 +263,10 @@ export async function applyRecommended(ctx: Home, user: User, dryRun: boolean, o
163263 out.refused.push(...items.map((item) => ({ item, reason: done.error.message })));
164264 continue;
165265 }
266 const skipped = new Set(done.value.skipped.map((s) => s.item));
267 for (const n of safe.filter((n) => n.recommended.action === action))
268 for (const item of n.items)
269 if (!skipped.has(item)) out.done.push({ id: n.id, choice: n.recommended.choice, result: "closed, with a receipt" });
166270 out.refused.push(...done.value.skipped);
167271 out.receipts.push({
168272 operation: done.value.id,
src/http/mcp/tools/forge.ts +1 −1
@@ -13,7 +13,7 @@ export const tool: Tool = {
1313 },
1414 },
1515 doc:
16 "`{ apply?, dryRun? }`: `{ says, needs, recommended }`: one or two sentences, then what needs you (the home page's own stack, most consequential first: `sentence`, `why`, `choices`, `recommended` with `reason`, `undo`, `safe`, and the `ask` to put to the owner when not), and the call that applies them. `apply: \"recommended\"` (or one need's `id`) takes the recommended choice of each safe need (a close the crew advised, which has a receipt); a ship is never one: a merge has no undo, so it is a question for the owner and lists the rest, untouched, with their questions; planned first (`dryRun` is true unless `false`); each receipt names its undo (`asks_act` `undo`)",
16 "`{ apply?, dryRun? }`: `{ says, needs, recommended }`: one or two sentences, then what needs you (the home page's own stack, most consequential first: `sentence`, `why`, `choices`, `recommended` with `reason`, `undo`, `safe`, and the `ask` to put to the owner when not), and the call that applies them. `apply: \"recommended\"` (or one need's `id`) takes the recommended choice of each safe need (a close the crew advised, a restore drill, a stopped run or assigned issue handed to the crew) and answers `done` with what each did; a ship is never one (a merge has no undo), nor a need with no safe default: those say `Needs your decision` with their real `options`, and are listed under `left` by name; planned first (`dryRun` is true unless `false`); each receipt names its undo (`asks_act` `undo`)",
1717 writes: true,
1818 dryByDefault: true,
1919 readsWithout: "apply",
src/tui/screens/needs.ts +2 −1
@@ -78,7 +78,8 @@ export function needsScreen(f: Session, ctx: Ctx): Screen {
7878 runAsync(ctx, async () => {
7979 const done = await applyRecommended({ app: f.app }, f.user!, false, at.id);
8080 const left = done.refused[0];
81 ctx.toast(left ? `Refused: ${left.reason}` : done.receipts[0]?.receipt ?? "Nothing to do.", left ? "danger" : undefined);
81 const said = done.done.map((d) => d.result).join("; ");
82 ctx.toast(left ? `Refused: ${left.reason}` : said || (done.receipts[0]?.receipt ?? "Nothing to do."), left ? "danger" : undefined);
8283 reload();
8384 });
8485 return true;
test/forge-needs-defaults.test.ts added +198
@@ -0,0 +1,198 @@
1 /**
2 * "Make the recommended choices" (#12377): a need the owner can decide has a real
3 * recommendation, apply carries it out and says what it did; one with no safe default
4 * says "Needs your decision" with its options and is left out by name.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { assignIssue, openIssue } from "../src/actions/issues.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { applyRecommended, forgeRecommended, handedBack, needsStack } from "../src/actions/needs.ts";
15 import { removeCrew } from "../src/core/agents.ts";
16 import { getIssue, setAssignee } from "../src/core/issues.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { createUser, getUserByName, type User } from "../src/core/users.ts";
19 import { openPull } from "../src/actions/pulls.ts";
20 import { recordDecision } from "../src/core/decisions.ts";
21 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
22 import { recordVerdict } from "../src/snapshot/verdict.ts";
23 import type { DrillResult } from "../src/snapshot/drill.ts";
24 import { startSnapshots } from "../src/snapshot/service.ts";
25 import { dirStore } from "../src/snapshot/store.ts";
26 import { needsScreen } from "../src/tui/screens/needs.ts";
27 import type { Ctx as TuiCtx } from "../src/tui/app/app.ts";
28 import type { Session } from "../src/tui/session.ts";
29 import type { ForgeEvent } from "../src/core/events.ts";
30 import { quietLogs } from "./helpers/globals.ts";
31
32 quietLogs();
33
34 let dataDir = "";
35 let app: App;
36 let alice: User;
37 let repo: Repo;
38 let number = 0;
39
40 beforeAll(async () => {
41 dataDir = await mkdtemp(join(tmpdir(), "forge-needs-defaults-"));
42 app = createApp({ dataDir, port: 0 });
43 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-needs-passphrase", adminIfFirst: true });
44 if (!a.ok) throw new Error(a.error);
45 alice = a.value;
46 await createAgent(app, alice, { username: "bot" });
47 const made = await createRepo(app, alice, { name: "open", visibility: "public" });
48 if (!made.ok) throw new Error(String(made.error));
49 repo = made.value;
50 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# open\n" }] });
51 addToCrew(app, alice, repo, "bot");
52 const issue = openIssue(app, alice, repo, { title: "Mine" });
53 if (!issue.ok) throw new Error(String(issue.error));
54 number = issue.value.number;
55 const assigned = assignIssue(app, alice, repo, issue.value, alice);
56 if (!assigned.ok) throw new Error(String(assigned.error));
57 }, 30_000);
58
59 afterAll(() => rm(dataDir, { recursive: true, force: true }));
60
61 test("an issue assigned to the owner is handed to the crew by apply, and the reply says so", async () => {
62 const need = needsStack({ app }, alice).find((n) => n.link.endsWith(`/issues/${number}`))!;
63 expect(need.recommended).toMatchObject({ choice: "Give it to the crew", safe: true, action: "give_back" });
64 const dry = await applyRecommended({ app }, alice, true);
65 expect(dry.plan.map((p) => p.id)).toContain(need.id);
66 expect(getIssue(app.db, repo.id, number)!.assigneeId).toBe(alice.id);
67 const done = await applyRecommended({ app }, alice, false);
68 expect(done.done).toEqual([expect.objectContaining({ id: need.id, result: expect.stringContaining("@bot") })]);
69 expect(getIssue(app.db, repo.id, number)!.assigneeId).toBe(getUserByName(app.db, "bot")!.id);
70 });
71
72 test("forge needs: the restore drill is a default; a database, a queue and a standby drill are decisions with options", () => {
73 expect(forgeRecommended("drill", "x")).toMatchObject({ safe: true, action: "drill" });
74 for (const cause of ["db-size", "queue:agent", "drill:never"]) {
75 const r = forgeRecommended(cause, "x");
76 expect(r).toMatchObject({ choice: "Needs your decision", safe: false });
77 expect(r.options!.length).toBe(2);
78 }
79 });
80
81 let branchN = 0;
82 /**
83 * An open pull by `author`, its check failed; `stopped` adds the crew's stop, so it is a stopped
84 * card.
85 */
86 const redPull = async (author: string, stopped: boolean) => {
87 const n = ++branchN;
88 const who = getUserByName(app.db, author)!;
89 await commitToBranch(app, alice, repo, { branch: `${author}/r${n}`, base: "main", message: `Work ${n}`, files: [{ path: `R${n}.md`, content: `${n}\n` }] });
90 const opened = await openPull(app, who, repo, { title: `Red ${n}`, body: "", baseBranch: "main", headBranch: `${author}/r${n}` });
91 if (!opened.ok) throw new Error(String(opened.error));
92 const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: opened.value.id, env: `pull-${opened.value.number}`, commitOid: opened.value.headOid, reason: "pull" });
93 claimRun(app.db, run.id);
94 finishRun(app.db, run.id, { state: "failed", summary: "red" });
95 if (stopped) recordDecision(app.db, { kind: "retry", repoId: repo.id, issueId: opened.value.id, state: {}, answer: "stop", p: 1, by: "rule", ms: 1 });
96 return opened.value;
97 };
98 const needOf = (user: User, number: number) => needsStack({ app }, user).find((n) => n.link.endsWith(`/pulls/${number}`));
99 const carolOf = async (): Promise<User> => {
100 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-long-carol-passphrase" });
101 if (!made.ok) throw new Error(made.error);
102 return made.value;
103 };
104
105 test("a stopped pull recommends starting the crew again, and apply hands it back with a comment and the assignment", async () => {
106 const pull = await redPull("bot", true);
107 // A pull its builder opened starts assigned to it; the person took it, so it is theirs now.
108 setAssignee(app.db, pull.id, alice.id);
109 const need = needOf(alice, pull.number)!;
110 expect(need.recommended).toMatchObject({ choice: "Start the crew on it again", safe: true, action: "give_back" });
111 const done = await applyRecommended({ app }, alice, false, need.id);
112 expect(done.refused).toEqual([]);
113 expect(done.done).toEqual([expect.objectContaining({ id: need.id, result: "handed back to @bot" })]);
114 expect(getIssue(app.db, repo.id, pull.number)!.assigneeId).toBe(getUserByName(app.db, "bot")!.id);
115 const comments = app.db.query<{ body: string }, [string]>("SELECT body FROM comment WHERE issue_id = ?1").all(pull.id);
116 expect(comments.map((c) => c.body).join("\n")).toContain("Given back to the crew by @alice");
117 });
118
119 test("handedBack refuses a pull with no builder, a closed one, a non-writer, an issue, and one no longer stopped", async () => {
120 await createAgent(app, alice, { username: "ghost" });
121 addToCrew(app, alice, repo, "ghost");
122 const orphan = await redPull("ghost", true);
123 removeCrew(app.db, repo.id, getUserByName(app.db, "ghost")!.id);
124 expect(await handedBack(app, alice, `stopped:${orphan.id}`)).toEqual({ ok: false, reason: expect.stringContaining("no builder") });
125 expect(getIssue(app.db, repo.id, orphan.number)!.assigneeId).not.toBe(getUserByName(app.db, "bot")!.id);
126
127 const closed = await redPull("bot", true);
128 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(closed.id);
129 expect(await handedBack(app, alice, `stopped:${closed.id}`)).toEqual({ ok: false, reason: "it no longer asks" });
130
131 const stopped = await redPull("bot", true);
132 const carol = await carolOf();
133 expect(await handedBack(app, carol, `stopped:${stopped.id}`)).toEqual({ ok: false, reason: "it no longer asks" });
134
135 const issue = openIssue(app, alice, repo, { title: "An issue, not a pull" });
136 if (!issue.ok) throw new Error(String(issue.error));
137 expect(await handedBack(app, alice, `stopped:${issue.value.id}`)).toEqual({ ok: false, reason: "it no longer asks" });
138
139 // Failed, but the crew has not stopped: working, not stopped.
140 const working = await redPull("bot", false);
141 expect(await handedBack(app, alice, `stopped:${working.id}`)).toEqual({ ok: false, reason: "it no longer asks" });
142 expect(app.db.query("SELECT 1 FROM comment WHERE issue_id = ?1").all(working.id)).toEqual([]);
143 });
144
145 const redDrill = (): DrillResult =>
146 ({ at: new Date().toISOString(), snapshotId: "s1", ms: 5, counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, health: "unreached", verdict: "red", failedStep: "fetch", phases: [], budgetMs: 1000, reason: null }) as DrillResult;
147
148 test("the drill need runs the drill for an admin and says so; a non-admin never sees it and runs nothing", async () => {
149 let runs = 0;
150 const handle = startSnapshots(app, dirStore(join(dataDir, "store")), {
151 version: "0",
152 nightly: false,
153 drill: async () => {
154 runs++;
155 return { ...redDrill(), verdict: "green", failedStep: null, health: "ok" } as DrillResult;
156 },
157 });
158 try {
159 recordVerdict(dataDir, redDrill());
160 const need = needsStack({ app }, alice).find((n) => n.id === "forge:drill")!;
161 expect(need.recommended).toMatchObject({ safe: true, action: "drill" });
162 const dry = await applyRecommended({ app }, alice, true, need.id);
163 expect(dry.plan.map((p) => p.id)).toEqual(["forge:drill"]);
164 expect(runs).toBe(0);
165 const carol = getUserByName(app.db, "carol") ?? (await carolOf());
166 expect(needsStack({ app }, carol).some((n) => n.id === "forge:drill")).toBe(false);
167 const theirs = await applyRecommended({ app }, carol, false, "forge:drill");
168 expect(theirs.done).toEqual([]);
169 expect(runs).toBe(0);
170 const done = await applyRecommended({ app }, alice, false, need.id);
171 expect(runs).toBe(1);
172 expect(done.done).toEqual([{ id: "forge:drill", choice: "Run the drill now", result: "The drill restored green." }]);
173 } finally {
174 await handle.stop();
175 }
176 });
177
178 test("a in the terminal hands a stopped pull back, and the web sees the comment on the bus", async () => {
179 // Stopped pulls of one repository share a row; this one stands alone.
180 app.db.query("UPDATE issue SET state = 'closed' WHERE kind = 'pull' AND title LIKE 'Red %'").run();
181 const pull = await redPull("bot", true);
182 const toasts: string[] = [];
183 const tracked: Promise<unknown>[] = [];
184 const tui = { toast: (t: string) => toasts.push(t), invalidate() {}, push() {}, track: (p: Promise<unknown>) => (tracked.push(p), p) } as unknown as TuiCtx;
185 const screen = needsScreen({ user: alice, db: app.db, app } as unknown as Session, tui);
186 const at = needsStack({ app }, alice).findIndex((n) => n.link.endsWith(`/pulls/${pull.number}`));
187 expect(at).toBeGreaterThanOrEqual(0);
188 const key = (k: string) => ({ type: "key" as const, key: k, ctrl: false, alt: false, shift: false });
189 for (let i = 0; i < at; i++) screen.key?.(key("j"));
190 const events: ForgeEvent[] = [];
191 const off = app.bus.subscribe((e) => void events.push(e));
192 expect(screen.key?.(key("a"))).toBe(true);
193 await Promise.all(tracked);
194 off();
195 expect(getIssue(app.db, repo.id, pull.number)!.assigneeId).toBe(getUserByName(app.db, "bot")!.id);
196 expect(toasts).toEqual(["handed back to @bot"]);
197 expect(events.some((e) => e.kind === "pull.commented" && e.repoId === repo.id)).toBe(true);
198 });
test/forge-needs-one-stack.test.ts +2 −2
@@ -155,9 +155,9 @@ test("a green agent pull is a question for the owner, never safe to ship", async
155155 const { needs } = await forge();
156156 const pull = needs.find((n) => n.link.endsWith(`/pulls/${pullNumber}`))!;
157157 const parked = needs.find((n) => n.link.endsWith(`/issues/${issueNumber}`))!;
158 expect(pull.recommended).toMatchObject({ choice: "Ask the owner", safe: false });
158 expect(pull.recommended).toMatchObject({ choice: "Needs your decision", safe: false });
159159 expect(pull.recommended.ask).toContain("ship it?");
160 expect(parked.recommended).toMatchObject({ choice: "Ask the owner", safe: false });
160 expect(parked.recommended).toMatchObject({ choice: "Needs your decision", safe: false });
161161 expect(parked.recommended.ask).toContain(`#${issueNumber}`);
162162 });
163163

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs