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

A pull a pass handed to a person shows in "needs you" with the reason and a Give back to the crew button #6609

Merged opened this

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

Merged

scout/forge:scout/6347-a-pull-a-pass-handed-to-a-person-shows-i → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/6347-a-pull-a-pass-handed-to-a-person-shows-i was merged into main as f748c80.

Review

Sign in to review this change.
14 files changed +369 −22
docs/ASKS.md +9
@@ -29,6 +29,15 @@ In the terminal the same line sits above the dashboard, `C` (or the palette's
2929 Decider) opens the rows, and `1`, `2`, `3` switch pile. Both are for the
3030 repositories you own only: a reader sees nothing.
3131
32 **Give back to the crew** is on a pull a pass gave up on and assigned to you
33 (finishing, requested, sweep, picker): its row says the pass's reason in one
34 line, and one press assigns the pull to the builder that last worked on it.
35 `POST /api/v1/asks/give-back` (`{"item": "assigned:<id>"}`), or the MCP tool
36 `asks_decide` with `give_back: true`. In the terminal the On you row says
37 `handed to you: <reason>` and `b` gives it back (the same `b` as on a
38 handed-over card). Only a note posted under your own name counts as the
39 pass's: a copy of its first words by anyone else shows nothing.
40
3241 **Let the decider** answers a hand-over card (an issue the crew parked) when
3342 you would rather not: the third tap on the card, `POST /api/v1/asks/decide`
3443 (`{"item": "parked:<id>"}`), the MCP tool `asks_decide`, or `D` on the On you
docs/MCP.md +1
@@ -42,6 +42,7 @@ for a tunnel and a forge that sleeps.
4242 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
4343 | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes |
4444 | `asked` | `{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write | |
45 | `asks_decide` | `{ item, give_back? }`: let the decider answer a parked hand-over (`parked:<id>`); with `give_back`, give a hand-over back to the crew instead (`handover:<id>`, or `assigned:<id>` for a pull a pass gave up on and assigned to you, whose ask carries the pass's reason): it is assigned to the builder that last worked on it and a comment says so; the same as the card's **Give back to the crew** and `POST /api/v1/asks/give-back` | yes |
4546 | `assign` | `{ repo, number, kind, assignee }`: assign an issue or pull (`kind` is `issue` or `pull`) to a user, or clear it with `null`, as `PUT …/assignee` and with its refusals | yes |
4647 | `assigned` | `{ limit? }`: the open issues and pulls assigned to you in every repository you can still read (repository, number, title, kind, state), as `GET /api/v1/user/assigned` | |
4748 | `attach_look` | `{ app, pull, width, says, image, dryRun? }`: attach a look to an open pull: a PNG or JPEG (at most 5 MB, base64 in `image`), `width` phone or desktop, one line in `says`; planned first (`dryRun` is true unless `false`) | yes |
src/actions/give_back.ts +27 −5
@@ -14,13 +14,13 @@ import { getIssue, getIssueById, type Issue } from "../core/issues.ts";
1414 import { closesFromBody } from "../core/pulls.ts";
1515 import { canWrite, getRepoById, type Repo } from "../core/repos.ts";
1616 import { getUserById, type User } from "../core/users.ts";
17 import { handoverOf } from "../crew/guard.ts";
17 import { handoverOf, passReasonOf } from "../crew/guard.ts";
1818 import { transact } from "../kernel/db.ts";
1919 import { err, ok, type Result } from "../kernel/result.ts";
2020 import type { FleetError } from "./fleet.ts";
2121 import { NO_LONGER_ASKS, SIGN_IN_TO_ACT } from "./asks_words.ts";
2222
23 const ITEM = /^handover:[A-Za-z0-9_-]{1,64}$/;
23 const ITEM = /^(?:handover|assigned):[A-Za-z0-9_-]{1,64}$/;
2424
2525 const fail = (code: FleetError["code"], message: string) =>
2626 err<FleetError>({ code, message });
@@ -37,6 +37,23 @@ export function builderOf(db: Database, repo: Repo, pull: Issue): User | undefin
3737 : undefined;
3838 }
3939
40 /**
41 * Who a pass handed a pull over from: the crew member whose agent run on it
42 * is the latest and who is still on the crew, else whoever opened it.
43 */
44 export function lastBuilderOf(db: Database, repo: Repo, pull: Issue): User | undefined {
45 const rows = db
46 .query<{ actor_id: string }, [string]>(
47 "SELECT actor_id FROM run WHERE kind = 'agent' AND issue_id = ?1 AND actor_id IS NOT NULL ORDER BY created_at DESC, id DESC LIMIT 5",
48 )
49 .all(pull.id);
50 for (const { actor_id } of rows) {
51 const agent = getUserById(db, actor_id);
52 if (agent !== undefined && agent.kind === "agent" && isCrew(db, repo.id, agent.id)) return agent;
53 }
54 return builderOf(db, repo, pull);
55 }
56
4057 export interface GivenBack {
4158 readonly pull: number;
4259 readonly builder: string;
@@ -56,9 +73,14 @@ export function giveBack(
5673 // A reader, or someone who cannot see it, is told nothing exists.
5774 if (pull === undefined || repo === undefined || !canWrite(app.db, repo, actor))
5875 return fail("not_found", NO_LONGER_ASKS);
59 if (pull.kind !== "pull" || pull.state !== "open" || handoverOf(app.db, repo, pull.id) === undefined)
60 return fail("not_found", NO_LONGER_ASKS);
61 const builder = builderOf(app.db, repo, pull);
76 if (pull.kind !== "pull" || pull.state !== "open") return fail("not_found", NO_LONGER_ASKS);
77 // Three reviews without an approval, or a pass that gave up and assigned it to a person.
78 const byPass = item.startsWith("assigned:");
79 const stillAsks = byPass
80 ? pull.assigneeId === actor.id && !pull.assigneeIsAgent && passReasonOf(app.db, pull.id, actor.id) !== undefined
81 : handoverOf(app.db, repo, pull.id) !== undefined;
82 if (!stillAsks) return fail("not_found", NO_LONGER_ASKS);
83 const builder = byPass ? lastBuilderOf(app.db, repo, pull) : builderOf(app.db, repo, pull);
6284 if (builder === undefined)
6385 return fail("bad_action", "no builder on the crew opened this pull, so there is no one to give it back to");
6486 // All or nothing: the pull, the issue a person holds and the comment that
src/contract/openapi/routes/index.ts +1
@@ -175,6 +175,7 @@ export { route as postApiV1AsksBulk } from "./postApiV1AsksBulk.ts";
175175 export { route as postApiV1AsksBulkIdUndo } from "./postApiV1AsksBulkIdUndo.ts";
176176 export { route as postApiV1AsksDecide } from "./postApiV1AsksDecide.ts";
177177 export { route as postApiV1AsksDecideIdUndo } from "./postApiV1AsksDecideIdUndo.ts";
178 export { route as postApiV1AsksGiveBack } from "./postApiV1AsksGiveBack.ts";
178179 export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdVerb.ts";
179180 export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts";
180181 export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts";
src/contract/openapi/routes/postApiV1AsksGiveBack.ts added +14
@@ -0,0 +1,14 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/asks/give-back",
6 summary: "Give a hand-over back to the crew (rw scope): `item` is `handover:<id>` or `assigned:<id>`; assigns the pull to the builder that last worked on it; answers 201 with the pull and the builder",
7 auth: true,
8 status: 201,
9 body: { item: "string" },
10 refusals: {
11 bad_action: "the ask is not a hand-over, or no crew builder worked on the pull",
12 not_found: "the ask no longer asks the caller",
13 },
14 };
src/crew/guard.ts +21
@@ -259,6 +259,27 @@ export const HANDOVER_ROUNDS = 3;
259259 /** What the hand-over card starts with: the thread's own record of it, and what `handover` ignores as a person's word. */
260260 export const HANDOVER_NOTE = "Handed to a person by the crew:";
261261
262 /**
263 * Why a pass handed this pull to a person, in one line: the newest hand-over
264 * note the assignee's own name carries (the finishing pass posts as them;
265 * a reader's copy of the prefix is not the crew's word), without its prefix or the forge's "posted under your name"
266 * aside. Undefined when no pass has handed it over.
267 */
268 export function passReasonOf(db: Database, pullId: string, assigneeId: string): string | undefined {
269 const row = db
270 .query<{ body: string }, [string, string, string]>(
outdated

High: matches the prefix from any commenter, so a reader can plant a "crew reason" in the owner's asks and enable Give back. Restrict to the author the pass posts as (author_id = <assignee>), or use a marker only the forge writes, and add a test for a forged note.

271 "SELECT body FROM comment WHERE issue_id = ?1 AND body LIKE ?2 AND author_id = ?3 ORDER BY created_at DESC, id DESC LIMIT 1",
272 )
273 .get(pullId, `${HANDOVER_NOTE}%`, assigneeId);
274 if (row === null) return undefined;
275 const words = row.body
276 .slice(HANDOVER_NOTE.length)
277 .split(" (The forge posts")[0]!
278 .replace(/\s+/g, " ")
279 .trim();
280 return words.length > 200 ? `${words.slice(0, 199)}…` : words;
281 }
282
262283 export interface Handover {
263284 /** The crew reviewer's verdicts that asked for changes, oldest first. */
264285 readonly reviews: readonly { id: string; author: string; body: string; at: string }[];
src/http/feed.ts +11
@@ -31,6 +31,7 @@ import {
3131 parseCursor,
3232 readFeed,
3333 } from "../feed/feed.ts";
34 import { giveBack } from "../actions/give_back.ts";
3435 import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts";
3536 import { actOnProposal, isProposalVerb } from "../actions/proposals.ts";
3637 import { asksAnswer } from "../web/studio/asks.ts";
@@ -192,6 +193,16 @@ export function feedRoutes(router: Router<Handler>): void {
192193 return json(done.value, 201);
193194 });
194195
196 // Give a hand-over back to the crew's builder (`actions/give_back.ts`).
197 router.add("POST", "/api/v1/asks/give-back", async (ctx) => {
198 const denied = requireScope(ctx, "rw");
199 if (denied) return denied;
200 const input = await body(ctx.request);
201 const done = giveBack(ctx.app, ctx.auth.user, input.item);
202 if (!done.ok) return bulkError(done.error);
203 return json(done.value, 201);
204 });
205
195206 router.add("POST", "/api/v1/asks/decide/:id/undo", async (ctx) => {
196207 const denied = requireScope(ctx, "rw");
197208 if (denied) return denied;
src/http/mcp/tools/asks_decide.ts +8 −2
@@ -1,17 +1,23 @@
11 import { decideAsk } from "../../../actions/asks_decide.ts";
2 import { giveBack } from "../../../actions/give_back.ts";
23 import { good, bad, type Tool } from "../shared.ts";
34
45 export const tool: Tool = {
56 name: "asks_decide",
7 doc: "`{ item, give_back? }`: let the decider answer a parked hand-over (`parked:<id>`); with `give_back`, give a hand-over back to the crew instead (`handover:<id>`, or `assigned:<id>` for a pull a pass gave up on and assigned to you, whose ask carries the pass's reason): it is assigned to the builder that last worked on it and a comment says so; the same as the card's **Give back to the crew** and `POST /api/v1/asks/give-back`",
68 description:
7 "Let the decider answer a hand-over (`item`: `parked:<id>`); only a sure set_aside clears.",
9 "Answer a hand-over: the decider (`parked:<id>`), or `give_back` to its builder (`handover:`/`assigned:<id>`)",
810 inputSchema: {
911 type: "object",
10 properties: { item: { type: "string", maxLength: 80 } },
12 properties: { item: { type: "string", maxLength: 80 }, give_back: { type: "boolean" } },
1113 required: ["item"],
1214 },
1315 writes: true,
1416 async run(ctx, user, args) {
17 if (args["give_back"] === true) {
18 const back = giveBack(ctx.app, user, args["item"]);
19 return back.ok ? good(back.value) : bad(back.error.code, back.error.message);
20 }
1521 const done = await decideAsk(ctx.app, user, args["item"]);
1622 return done.ok ? good(done.value) : bad(done.error.code, done.error.message);
1723 },
src/tui/screens/dashboard.ts +16 −6
@@ -33,6 +33,7 @@ import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts";
3333 import { askLinesFor } from "../../web/studio/asks.ts";
3434 import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
3535 import { giveBack } from "../../actions/give_back.ts";
36 import { passReasonOf } from "../../crew/guard.ts";
3637 import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts";
3738 import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts";
3839 import { putAsideWaves } from "../../web/studio/asks.ts";
@@ -140,6 +141,8 @@ interface OnYouItem {
140141 readonly hand?: true;
141142 /** A pull the crew handed to a person: closed, marked and opened like any row. */
142143 readonly over?: true;
144 /** A pull a pass assigned to this person: why, and who it goes back to (`b`). */
145 readonly passed?: string;
143146 /** For a handed-over row: the crew's advice, when it wrote one. */
144147 readonly overAdvice?: string;
145148 /** A pull no person has touched for a day, with the crew's advice when it wrote one. */
@@ -334,7 +337,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
334337 )
335338 return [];
336339 if (muted.has(repo.id)) return [];
337 return [{ issue, repo, waiting: waitingOf(issue.id) }];
340 const passed =
341 issue.kind === "pull" && !issue.assigneeIsAgent && issue.assigneeId === f.user?.id
342 ? passReasonOf(f.db, issue.id, issue.assigneeId)
343 : undefined;
344 return [{ issue, repo, waiting: waitingOf(issue.id), ...(passed === undefined ? {} : { passed }) }];
338345 });
339346 return [...assigned, ...handOvers(assigned, rows, waitingOf)];
340347 };
@@ -653,11 +660,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
653660 const giveBackHere = (): void => {
654661 const here = onYou[at("onyou").index];
655662 if (here === undefined) return;
656 if (here.over !== true) {
663 if (here.over !== true && here.passed === undefined) {
657664 ctx.toast("only a handed-over pull goes back to the crew", "attn");
658665 return;
659666 }
660 const done = giveBack(f.app, f.user, `handover:${here.issue.id}`);
667 const done = giveBack(f.app, f.user, `${here.over === true ? "handover" : "assigned"}:${here.issue.id}`);
661668 reload();
662669 ctx.toast(
663670 done.ok ? `#${done.value.pull} given back to @${done.value.builder}` : done.error.message,
@@ -824,7 +831,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
824831 ...(focus === "onyou" && onYou[at("onyou").index]?.hand === true
825832 ? [binding(["D"], "let the decider")]
826833 : []),
827 ...(focus === "onyou" && onYou[at("onyou").index]?.over === true
834 ...(focus === "onyou" && (onYou[at("onyou").index]?.over === true || onYou[at("onyou").index]?.passed !== undefined)
828835 ? [binding(["b"], "give back to the crew")]
829836 : []),
830837 ...(focus === "onyou" && waveHere() !== undefined
@@ -1122,7 +1129,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
11221129 : ISSUE_MARK;
11231130 return {
11241131 title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : item.late !== undefined ? "waiting " : ""}${mark} #${item.issue.number} ${item.issue.title} · ${item.repo.ownerName}/${item.repo.name}`,
1125 meta: `${item.waiting.word} · on ${item.waiting.on}${item.late?.advice ?? item.overAdvice ?? ""}`,
1132 meta: `${item.waiting.word} · on ${item.waiting.on}${item.passed === undefined ? "" : ` · handed to you: ${item.passed}`}${item.late?.advice ?? item.overAdvice ?? ""}`,
11261133 };
11271134 }),
11281135 ...waves.map(
@@ -1152,9 +1159,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
11521159 if (waitingSet !== undefined && waitingSet.count > 0) parts.push(`${waitingSet.count} waiting on you`);
11531160 if (waitingSet !== undefined && waitingSet.stale.length > 0) parts.push(`${staleWords(waitingSet.stale.length)} Z previews`);
11541161 // The decider keys come first: the card clips its footer from the right.
1162 // A pass's reason leads the line: a row's meta is dropped where the pane is narrow.
1163 const passed = onYou[at("onyou").index]?.passed;
1164 if (passed !== undefined) parts.push(`handed to you: ${passed.length > 60 ? `${passed.slice(0, 59)}…` : passed}`);
11551165 if (lastHand !== undefined) parts.push("U undo decider");
11561166 if (onYou[at("onyou").index]?.hand === true) parts.push("D let the decider");
1157 if (onYou[at("onyou").index]?.over === true) parts.push("b give back to the crew");
1167 if (onYou[at("onyou").index]?.over === true || onYou[at("onyou").index]?.passed !== undefined) parts.push("b give back to the crew");
11581168 if (f.user !== undefined) {
11591169 const chips = chipsOf(f.db, f.user.id);
11601170 const off: string[] = KIND_CHIPS.filter((k) => chips.kinds.has(k));
src/web/studio/asks.ts +11 −5
@@ -8,6 +8,7 @@
88 */
99
1010 import type { Database } from "bun:sqlite";
11 import { lastBuilderOf } from "../../actions/give_back.ts";
1112 import { forgeVitals, needLine } from "../../actions/health.ts";
1213 import { reviewSet } from "./review_set.ts";
1314 import type { Degraded } from "../../kernel/vitals.ts";
@@ -55,7 +56,7 @@ import {
5556 } from "../../crew/autonomy.ts";
5657 import { breakerParked, WAITING_NOTES } from "../../crew/breaker.ts";
5758 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
58 import { handoversOf, type Handover } from "../../crew/guard.ts";
59 import { handoversOf, passReasonOf, type Handover } from "../../crew/guard.ts";
5960 import { conflictsOf, isStalePull, mergesAgainSince, staleGreen } from "../../crew/stale_asks.ts";
6061 import { reviewWaitsFor, type ReviewWait } from "../../crew/review.ts";
6162 import type { Ctx } from "../../http/context.ts";
@@ -130,7 +131,7 @@ export interface Decision {
130131 readonly waiting: WaitingFor;
131132 readonly why?: string | undefined;
132133 readonly recommendation?: Recommendation | undefined;
133 /** For "handover": the builder the pull can be given back to, if any. */
134 /** For "handover" and a pass-assigned "assigned": the builder the pull can be given back to, if any. */
134135 readonly giveBack?: string | undefined;
135136 }
136137
@@ -280,9 +281,9 @@ interface AskCandidate {
280281 readonly decline?: { readonly runId: string; readonly words: string };
281282 /** For "handover": the reviews that asked for changes. */
282283 readonly handover?: Handover;
283 /** For "handover": the crew member that opened the pull, when it can be given back. */
284 /** For "handover" and a pass-assigned "assigned": the crew member that opened or last worked on the pull, when it can be given back. */
284285 readonly builder?: string;
285 /** For "handover" and "waiting": one sentence on why a person must decide. */
286 /** For "handover", "waiting" and a pass-assigned "assigned": one sentence on why a person must decide. */
286287 readonly why?: string;
287288 /** For "handover" and "waiting": what the crew advised, when it wrote one. */
288289 readonly recommendation?: Recommendation;
@@ -597,6 +598,9 @@ function askCandidatesFor(
597598 if (!mayView(db, repo, issue, user)) continue;
598599 const pull = joinedMine.get(issue.id);
599600 if (issue.kind === "pull" && pull === undefined) continue;
601 // A pull a pass gave up on and assigned to this person says why, and can go back.
602 const reason = pull === undefined || issue.assigneeIsAgent ? undefined : passReasonOf(db, issue.id, issue.assigneeId!);
603 const back = reason === undefined ? undefined : lastBuilderOf(db, repo, issue)?.username;
600604 out.push({
601605 kind: "assigned",
602606 id: `assigned:${issue.id}`,
@@ -605,6 +609,8 @@ function askCandidatesFor(
605609 ...(pull === undefined ? { issue } : { pull }),
606610 who: issue.authorName,
607611 authorIsAgent: issue.authorIsAgent,
612 ...(reason === undefined ? {} : { why: reason }),
613 ...(back === undefined ? {} : { builder: back }),
608614 });
609615 }
610616 return out;
@@ -948,7 +954,7 @@ export function recordAskLedger(db: Database, now = new Date()): void {
948954 function lineOf(c: AskCandidate): string {
949955 if (c.kind === "paused") return "is paused at its ceiling";
950956 if (c.kind === "assigned")
951 return `"${c.pull?.title ?? c.issue?.title ?? ""}" is assigned to you`;
957 return `"${c.pull?.title ?? c.issue?.title ?? ""}" is assigned to you${c.why === undefined ? "" : `: ${c.why}`}`;
952958 if (c.kind === "parked")
953959 return `the crew parked #${c.issue!.number} after three failed runs`;
954960 if (c.kind === "proposal")
src/web/studio/decisions.ts +9 −3
@@ -69,8 +69,10 @@ export function decisionsFor(
6969 repo: c.repo,
7070 ...(c.pull === undefined ? { issue: c.issue } : { pull: c.pull }),
7171 who: c.who,
72 says: `"${thing.title}" is assigned to you.`,
73 short: `assigned to you · updated ${thing.updatedAt.slice(0, 10)}`,
72 says: c.why === undefined ? `"${thing.title}" is assigned to you.` : `"${thing.title}" was handed to you by the crew: ${c.why}`,
73 short: c.why ?? `assigned to you · updated ${thing.updatedAt.slice(0, 10)}`,
74 ...(c.why === undefined ? {} : { why: c.why }),
75 ...(c.builder === undefined ? {} : { giveBack: c.builder }),
7476 };
7577 }
7678 if (c.kind === "proposal" && c.proposal)
@@ -216,6 +218,10 @@ export function decisionForm(d: Decision, back?: string): Html | null {
216218 if (pull === undefined) return null;
217219 const base = repoBase(d.repo);
218220 const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`;
221 if (d.kind === "assigned" && d.giveBack !== undefined)
222 return html`<form id="${askFormId(d)}-back" method="post" hidden action="/asks/give-back">
223 <input type="hidden" name="ask" value="${d.id}" />${where}
224 </form>`;
219225 if (d.kind === "handover" && d.giveBack !== undefined)
220226 return html`<form ${own} action="${base}/pulls/${pull.number}/state">
221227 <input type="hidden" name="state" value="closed" />${where}
@@ -301,7 +307,7 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others:
301307 size: "sm",
302308 variant: "primary",
303309 }),
304 others: [],
310 others: d.giveBack === undefined ? [] : [btn("Give back to the crew", { size: "sm", form: `${askFormId(d)}-back` })],

The page gets the button, but the terminal does not: giveBackHere in src/tui/screens/dashboard.ts only accepts handover:. Both doors need it.

305311 };
306312 if (d.kind === "proposal" && d.proposal)
307313 return {
test/mcp-docs.test.ts −1
@@ -20,7 +20,6 @@ const undocumented = new Set<string>([
2020 "here",
2121 "asks",
2222 "asks_act",
23 "asks_decide",
2423 "asks_proposal",
2524 "asks_undecide",
2625 "issue_teams",
test/pass-handover-ask.test.ts added +132
@@ -0,0 +1,132 @@
1 /**
2 * A pull a pass handed to a person (the finishing pass's hand-over note) is
3 * in that person's asks with the pass's reason, and "Give back to the crew"
4 * re-assigns the builder that last worked on it.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { giveBack } from "../src/actions/give_back.ts";
13 import { addCrew, createAgentRow } from "../src/core/agents.ts";
14 import { addComment } from "../src/actions/issues.ts";
15 import { getIssueById } from "../src/core/issues.ts";
16 import { createRepo, type Repo } from "../src/core/repos.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { HANDOVER_NOTE } from "../src/crew/guard.ts";
19 import { id } from "../src/kernel/id.ts";
20 import { askLinesFor } from "../src/web/studio/asks.ts";
21 import { decisionsFor } from "../src/web/studio/decisions.ts";
22 import type { Ctx } from "../src/http/context.ts";
23 import { promote } from "./helpers/members.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25
26 quietLogs();
27
28 const ZERO = "0".repeat(40);
29
30 describe("a pull a pass handed to a person", () => {
31 let dir = "";
32 let app: App;
33 let owner: User;
34 let other: User;
35 let first: User;
36 let last: User;
37 let repo: Repo;
38 let n = 0;
39
40 const person = async (username: string) => {
41 const made = await createUser(app.db, { username, email: `${username}@forge.test`, password: "a-good-passphrase", origin: "seed", trusted: true });
42 if (!made.ok) throw new Error(made.error);
43 return made.value;
44 };
45 const agent = async (username: string) => {
46 const made = await createUser(app.db, { username, email: `${username}@forge.test`, agent: true, origin: "agent" });
47 if (!made.ok) throw new Error(made.error);
48 createAgentRow(app.db, { userId: made.value.id, brief: "b" });
49 addCrew(app.db, repo.id, made.value.id, owner.id);
50 return made.value;
51 };
52
53 /** An open pull `first` opened, with agent runs by both builders, assigned to `to`. */
54 const handed = (to: User | undefined): string => {
55 const pullId = id();
56 const number = ++n;
57 app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, assignee_id) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`)
58 .run(pullId, repo.id, number, `Pull ${number}`, first.id, to?.id ?? null);
59 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, ?3, ?3, ?4)`)
60 .run(pullId, `b/${number}`, ZERO, repo.id);
61 for (const [who, at] of [[first, "2026-10-01T00:00:00.000Z"], [last, "2026-10-02T00:00:00.000Z"]] as const)
62 app.db.query(`INSERT INTO run (id, repo_id, kind, issue_id, env, commit_oid, branch, actor_id, reason, state, created_at) VALUES (?1, ?2, 'agent', ?3, 'preview', ?4, 'b', ?5, 'feedback', 'ok', ?6)`)
63 .run(id(), repo.id, pullId, ZERO, who.id, at);
64 return pullId;
65 };
66 const note = (pullId: string) =>
67 addComment(app, owner, repo, getIssueById(app.db, pullId)!, `${HANDOVER_NOTE} this pull is approved, but 2 runs to bring it up to date ended without a push. Conflicting files: a.ts. (The forge posts this under your name; you did not write it.)`);
68
69 beforeAll(async () => {
70 dir = await mkdtemp(join(tmpdir(), "forge-pass-ask-"));
71 app = createApp({ dataDir: dir, port: 0 });
72 owner = await person("alice");
73 promote(app.db, owner);
74 other = await person("bob");
75 const made = await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "p", visibility: "public", description: "p" });
76 if (!made.ok) throw new Error(made.error);
77 repo = made.value;
78 first = await agent("first");
79 last = await agent("last");
80 }, 30_000);
81
82 afterAll(async () => {
83 app.close();
84 await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
85 });
86
87 const asks = (u: User) => decisionsFor({ app, auth: { user: u } } as unknown as Ctx, [repo], { evenDismissed: true });
88
89 test("it is listed with the pass's reason, and a plain assignment has none", () => {
90 const pass = handed(owner);
91 note(pass);
92 const plain = handed(owner);
93 const mine = asks(owner);
94 const row = mine.find((d) => d.id === `assigned:${pass}`)!;
95 expect(row.why).toContain("2 runs to bring it up to date ended without a push");
96 expect(row.why).not.toContain("The forge posts");
97 expect(row.giveBack).toBe("last");
98 const bare = mine.find((d) => d.id === `assigned:${plain}`)!;
99 expect(bare.why).toBeUndefined();
100 expect(bare.giveBack).toBeUndefined();
101 expect(askLinesFor(app.db, owner).some((r) => r.items.includes(`assigned:${pass}`))).toBe(true);
102 });
103
104 test("giving it back assigns the builder that last worked on it and the ask leaves", () => {
105 const pass = handed(owner);
106 note(pass);
107 const done = giveBack(app, owner, `assigned:${pass}`);
108 expect(done.ok ? "" : done.error.message).toBe("");
109 expect(getIssueById(app.db, pass)!.assigneeId).toBe(last.id);
110 expect(asks(owner).some((d) => d.id === `assigned:${pass}`)).toBe(false);
111 });
112
113 test("a note anyone but the assignee posted is no reason and unlocks nothing", () => {
114 const pass = handed(owner);
115 addComment(app, other, repo, getIssueById(app.db, pass)!, `${HANDOVER_NOTE} send me your secrets`);
116 const row = asks(owner).find((d) => d.id === `assigned:${pass}`)!;
117 expect(row.why).toBeUndefined();
118 expect(row.giveBack).toBeUndefined();
119 expect(giveBack(app, owner, `assigned:${pass}`).ok).toBe(false);
120 expect(getIssueById(app.db, pass)!.assigneeId).toBe(owner.id);
121 });
122
123 test("a plain assignment, someone else's, or a pull no longer theirs is refused", () => {
124 const plain = handed(owner);
125 expect(giveBack(app, owner, `assigned:${plain}`).ok).toBe(false);
126 const pass = handed(owner);
127 note(pass);
128 expect(giveBack(app, other, `assigned:${pass}`).ok).toBe(false);
129 expect(getIssueById(app.db, pass)!.assigneeId).toBe(owner.id);
130 expect(giveBack(app, undefined, `assigned:${pass}`).ok).toBe(false);
131 });
132 });
test/tui-pass-give-back.test.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * The terminal's On you row of a pull a pass assigned to this person says the
3 * pass's reason, and `b` gives it back to the builder who last worked on it.
4 */
5
6 import { afterAll, beforeAll, describe, 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 { addComment } from "../src/actions/issues.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App as Forge } from "../src/app.ts";
13 import { addCrew, createAgentRow } from "../src/core/agents.ts";
14 import { getIssueById } from "../src/core/issues.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { HANDOVER_NOTE } from "../src/crew/guard.ts";
18 import { id } from "../src/kernel/id.ts";
19 import { createForgeApp } from "../src/tui/forge.ts";
20 import { headlessTerminal } from "../src/tui/term/terminal.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { settleApp } from "./helpers/tui.ts";
23 import { seedAdmin } from "./helpers/admin.ts";
24
25 quietLogs();
26
27 const ZERO = "0".repeat(40);
28
29 describe("terminal: give back a pull a pass handed over", () => {
30 let dir = "";
31 let forge: Forge;
32 let alice: User;
33 let builder: User;
34 let repo: Repo;
35
36 const boot = async () => {
37 const terminal = headlessTerminal(200, 32);
38 const { app } = createForgeApp(
39 forge,
40 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: alice },
41 { terminal, exitHooks: false },
42 );
43 app.start();
44 await settleApp(app);
45 const press = async (key: string) => {
46 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
47 await settleApp(app);
48 };
49 await press("tab");
50 return { press, text: () => app.screen.toText(), stop: () => app.stop() };
51 };
52
53 /** An open pull the builder worked on, assigned to alice. */
54 const pull = (n: number): string => {
55 const pullId = id();
56 forge.db
57 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, assignee_id) VALUES (?1, ?2, ?3, 'pull', 'Rebase me', '', ?4, 'open', ?5)`)
58 .run(pullId, repo.id, n, builder.id, alice.id);
59 forge.db
60 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', 'b/x', ?2, ?2, ?3)`)
61 .run(pullId, ZERO, repo.id);
62 forge.db
63 .query(`INSERT INTO run (id, repo_id, kind, issue_id, env, commit_oid, branch, actor_id, reason, state, created_at) VALUES (?1, ?2, 'agent', ?3, 'preview', ?4, 'b', ?5, 'conflict', 'ok', '2026-10-01T00:00:00.000Z')`)
64 .run(id(), repo.id, pullId, ZERO, builder.id);
65 return pullId;
66 };
67
68 beforeAll(async () => {
69 dir = await mkdtemp(join(tmpdir(), "forge-tui-back-"));
70 forge = createApp({ dataDir: dir, port: 0 });
71 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
72 if (!a.ok) throw new Error("signup failed");
73 alice = a.value;
74 const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" });
75 if (!r.ok) throw new Error("repo failed");
76 repo = r.value;
77 const made = await createUser(forge.db, { username: "builder", email: "[email protected]", agent: true, origin: "agent" });
78 if (!made.ok) throw new Error(made.error);
79 builder = made.value;
80 createAgentRow(forge.db, { userId: builder.id, brief: "b" });
81 addCrew(forge.db, repo.id, builder.id, alice.id);
82 }, 30_000);
83
84 afterAll(async () => {
85 forge.close();
86 await rm(dir, { recursive: true, force: true });
87 });
88
89 test("the row says why, and b assigns the builder", async () => {
90 const pullId = pull(1);
91 addComment(forge, alice, repo, getIssueById(forge.db, pullId)!, `${HANDOVER_NOTE} two runs ended without a push. (The forge posts this under your name; you did not write it.)`);
92 const h = await boot();
93 expect(h.text()).toContain("handed to you: two runs ended without a push.");
94 expect(h.text()).toContain("b give back to the crew");
95 await h.press("b");
96 expect(getIssueById(forge.db, pullId)!.assigneeId).toBe(builder.id);
97 expect(h.text()).not.toContain("Rebase me");
98 h.stop();
99 });
100
101 test("a plain assignment has no reason and b refuses", async () => {
102 const pullId = pull(2);
103 const h = await boot();
104 expect(h.text()).not.toContain("handed to you");
105 await h.press("b");
106 expect(getIssueById(forge.db, pullId)!.assigneeId).toBe(alice.id);
107 h.stop();
108 });
109 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs