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

A hand-over card has "Give back to the crew": one press assigns the pull back to the builder that opened it, and its run starts #6430

Merged opened this

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

Merged

scout/forge:scout/6419-a-hand-over-card-has-give-back-to-the-cr → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6419-a-hand-over-card-has-give-back-to-the-cr was merged into main as ada3939.

Review

Sign in to review this change.
7 files changed +392 −0
README.md +3
@@ -39,6 +39,9 @@ parked, why) and a "Restore drill" card (the last drill's pass or fail and
3939 when). The terminal's palette has the same as "Crew hand-overs", a read-only
4040 list of the same rows (repo#number, title, reasons; it does not open the
4141 issue — use the link on the page). Both are a forge admin's only.
42
43 A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`).
44
4245 Under the agents, each team that has a pull has one pooled row (its members'
4346 counts added, the ratios taken from the sums; an agent on two teams counts in
4447 both): `data-team` rows on the page, "team <name>" rows in that terminal screen.
src/actions/give_back.ts added +89
@@ -0,0 +1,89 @@
1 /**
2 * "Give back to the crew" on a hand-over card: one press assigns the pull
3 * (and the open issue it closes, when a person holds that too) to the agent
4 * that opened it, and says so in a comment. The comment is a director's
5 * word newer than the third review, which is what takes the card off the
6 * board (`crew/guard.ts`); the assignment is what the next tick dispatches.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import type { App } from "../app.ts";
11 import { assignIssue, addComment } from "./issues.ts";
12 import { isCrew } from "../core/agents.ts";
13 import { getIssue, getIssueById, type Issue } from "../core/issues.ts";
14 import { closesFromBody } from "../core/pulls.ts";
15 import { canWrite, getRepoById, type Repo } from "../core/repos.ts";
16 import { getUserById, type User } from "../core/users.ts";
17 import { handoverOf } from "../crew/guard.ts";
18 import { transact } from "../kernel/db.ts";
19 import { err, ok, type Result } from "../kernel/result.ts";
20 import type { FleetError } from "./fleet.ts";
21 import { NO_LONGER_ASKS, SIGN_IN_TO_ACT } from "./asks_words.ts";
22
23 const ITEM = /^handover:[A-Za-z0-9_-]{1,64}$/;
24
25 const fail = (code: FleetError["code"], message: string) =>
26 err<FleetError>({ code, message });
27
28 /**
29 * The crew member that opened this pull, when there is one to give it back
30 * to: a pull a person opened, or by an agent gone from the crew, has none.
31 */
32 export function builderOf(db: Database, repo: Repo, pull: Issue): User | undefined {
33 if (!pull.authorIsAgent) return undefined;
34 const agent = getUserById(db, pull.authorId);
35 return agent !== undefined && agent.kind === "agent" && isCrew(db, repo.id, agent.id)
36 ? agent
37 : undefined;
38 }
39
40 export interface GivenBack {
41 readonly pull: number;
42 readonly builder: string;
43 }
44
45 /** Give one handed-over pull (`item`: `handover:<pull id>`) back to its builder. */
46 export function giveBack(
47 app: App,
48 actor: User | undefined,
49 item: unknown,
50 ): Result<GivenBack, FleetError> {
51 if (!actor) return fail("unauthorized", SIGN_IN_TO_ACT);
52 if (typeof item !== "string" || !ITEM.test(item))
53 return fail("bad_action", "only a hand-over can be given back to the crew");
54 const pull = getIssueById(app.db, item.slice(item.indexOf(":") + 1));
55 const repo = pull === undefined ? undefined : getRepoById(app.db, pull.repoId);
56 // A reader, or someone who cannot see it, is told nothing exists.
57 if (pull === undefined || repo === undefined || !canWrite(app.db, repo, actor))
58 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);
62 if (builder === undefined)
63 return fail("bad_action", "no builder on the crew opened this pull, so there is no one to give it back to");
64 // All or nothing: the pull, the issue a person holds and the comment that
65 // ends the hand-over land together, or none of them does.
66 const refusal = (what: string, why: string) => new Refused(`could not ${what}: ${why}`);
67 try {
68 transact(app.db, () => {

Non-blocking: bus listeners run synchronously inside this transaction. If a later step is refused, a terminal or SSE viewer may already have been told about a pull.assigned that was rolled back. The durable effects (event row, dispatch queue) roll back correctly, so this is cosmetic and fine as it stands.

69 const assigned = assignIssue(app, actor, repo, pull, builder);
70 if (!assigned.ok) throw refusal(`assign @${builder.username}`, assigned.error);
outdated

The assignIssue result is thrown away here. If it fails (for example agent_not_on_crew on another path, or archived), the person is told the pull was given back while its issue stays with them, and the next tick still treats the issue as held by a person. At least collect the failures and name them in the comment or the result. A test for this loop is point 2 of the review.

71 // The issue it closes, when a person was left holding it.
72 for (const n of closesFromBody(pull.body)) {
outdated

The assignment and the comment are not done together. If addComment fails after assignIssue succeeded, the pull is already the builder's, but the card stays on the board and the page says nothing was done. Write the comment first, since it is the cheap step that can be refused, or run both in one app.db.transaction.

73 const issue = getIssue(app.db, repo.id, n);
74 if (issue?.kind !== "issue" || issue.state !== "open" || issue.assigneeId === null || issue.assigneeIsAgent)
75 continue;
76 const moved = assignIssue(app, actor, repo, issue, builder);
77 if (!moved.ok) throw refusal(`assign #${n} to @${builder.username}`, moved.error);
78 }
79 const said = addComment(app, actor, repo, pull, `Given back to the crew by @${actor.username}`);
80 if (!said.ok) throw refusal("comment", said.error);
81 });
82 } catch (e) {
83 if (e instanceof Refused) return fail("bad_action", e.message);
84 throw e;
85 }
86 return ok({ pull: pull.number, builder: builder.username });
87 }
88
89 class Refused extends Error {}
src/http/server.ts +2
@@ -266,6 +266,7 @@ import {
266266 asksBulkSubmit,
267267 asksBulkUndo,
268268 asksDecide,
269 asksGiveBack,
269270 asksProposal,
270271 asksDecideUndo,
271272 asksListen,
@@ -751,6 +752,7 @@ export function buildRouter(): Router<Handler> {
751752 router.add("POST", "/asks/proposal/:id/later", asksProposal("later"));
752753 router.add("POST", "/asks/proposal/:id/again", asksProposal("again"));
753754 router.add("POST", "/asks/decide", asksDecide);
755 router.add("POST", "/asks/give-back", asksGiveBack);
754756 router.add("POST", "/asks/decide/:id/undo", asksDecideUndo);
755757 // What people asked of the crew, and what became of it (core/asked.ts).
756758 router.add("GET", "/asked", askedPage);
src/tui/screens/dashboard.ts +22
@@ -32,6 +32,7 @@ import { getProposal } from "../../core/proposals.ts";
3232 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";
35 import { giveBack } from "../../actions/give_back.ts";
3536 import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts";
3637 import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts";
3738 import { putAsideWaves } from "../../web/studio/asks.ts";
@@ -597,6 +598,22 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
597598 });
598599 };
599600
601 /** Give the handed-over pull under the cursor back to its builder, through the action the web uses. */
602 const giveBackHere = (): void => {
603 const here = onYou[at("onyou").index];
604 if (here === undefined) return;
605 if (here.over !== true) {
606 ctx.toast("only a handed-over pull goes back to the crew", "attn");
607 return;
608 }
609 const done = giveBack(f.app, f.user, `handover:${here.issue.id}`);
610 reload();
611 ctx.toast(
612 done.ok ? `#${done.value.pull} given back to @${done.value.builder}` : done.error.message,
613 done.ok ? "success" : "danger",
614 );
615 };
616
600617 const undoHand = (): void => {
601618 const id = lastHand;
602619 if (id === undefined) {
@@ -756,6 +773,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
756773 ...(focus === "onyou" && onYou[at("onyou").index]?.hand === true
757774 ? [binding(["D"], "let the decider")]
758775 : []),
776 ...(focus === "onyou" && onYou[at("onyou").index]?.over === true
777 ? [binding(["b"], "give back to the crew")]
778 : []),
759779 ...(focus === "onyou" && waveHere() !== undefined
760780 ? [binding(["y"], "approve wave"), binding(["R"], "redirect"), binding(["l"], "later")]
761781 : []),
@@ -934,6 +954,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
934954 if (e.key === "m") return clear("mute"), true;
935955 if (e.key === "S") return shipSet(), true;
936956 if (e.key === "D" && item !== undefined) return handToDecider(), true;
957 if (e.key === "b" && item !== undefined) return giveBackHere(), true;
937958 if (e.key === "h") return hideAuthor(), true;
938959 if (e.key === "y") return actOnWave("approve"), true;
939960 if (e.key === "R") return redirectWave(), true;
@@ -1092,6 +1113,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10921113 if (lastSet !== undefined) parts.push(`u undo ${lastSet.verb}`);
10931114 if (lastHand !== undefined) parts.push("U undo decider");
10941115 if (onYou[at("onyou").index]?.hand === true) parts.push("D let the decider");
1116 if (onYou[at("onyou").index]?.over === true) parts.push("b give back to the crew");
10951117 if (asideWaves.length > 0) parts.push(`${asideWaves.length} ${asideWaves.length === 1 ? "wave" : "waves"} put aside · B bring back`);
10961118 return parts.length > 0 ? parts.join(" · ") : undefined;
10971119 }
src/web/studio/asks.ts +21
@@ -141,6 +141,8 @@ export interface Decision {
141141 readonly waiting: WaitingFor;
142142 readonly why?: string | undefined;
143143 readonly recommendation?: Recommendation | undefined;
144 /** For "handover": the builder the pull can be given back to, if any. */
145 readonly giveBack?: string | undefined;
144146 }
145147
146148 /** What an ask asks, the answers it takes, and what happens if nobody gives one. */
@@ -289,6 +291,8 @@ interface AskCandidate {
289291 readonly decline?: { readonly runId: string; readonly words: string };
290292 /** For "handover": the reviews that asked for changes. */
291293 readonly handover?: Handover;
294 /** For "handover": the crew member that opened the pull, when it can be given back. */
295 readonly builder?: string;
292296 /** For "handover" and "waiting": one sentence on why a person must decide. */
293297 readonly why?: string;
294298 /** For "handover" and "waiting": what the crew advised, when it wrote one. */
@@ -371,6 +375,7 @@ function askCandidatesFor(
371375 handedOver,
372376 );
373377 const first = out.length;
378 let crews: Map<string, string[]> | undefined;
374379 for (const issue of pulls) {
375380 const repo = byId.get(issue.repoId)!;
376381 const pull = joined.get(issue.id);
@@ -391,6 +396,10 @@ function askCandidatesFor(
391396 if (over !== undefined && repo.autonomy === "autonomous" && issue.authorIsAgent)
392397 continue;
393398 if (over !== undefined) {
399 // Whoever opened it, while still on the crew, is who it can go back to.
400 crews ??= crewForRepos(db, [...byId.keys()]);
401 const builder =
402 issue.authorIsAgent && crews.get(repo.id)?.includes(issue.authorName) ? issue.authorName : undefined;
394403 out.push({
395404 kind: "handover",
396405 id: `handover:${pull.id}`,
@@ -400,6 +409,7 @@ function askCandidatesFor(
400409 who,
401410 authorIsAgent: issue.authorIsAgent,
402411 handover: over,
412 ...(builder === undefined ? {} : { builder }),
403413 why: HANDOVER_WHY,
404414 });
405415 continue;
@@ -1179,6 +1189,7 @@ export function decisionsFor(
11791189 short: `"${c.pull.title}" had three reviews asking for changes`,
11801190 why: c.why,
11811191 recommendation: c.recommendation,
1192 giveBack: c.builder,
11821193 };
11831194 if (c.kind === "waiting" && c.pull)
11841195 return {
@@ -1279,6 +1290,13 @@ export function decisionForm(d: Decision, back?: string): Html | null {
12791290 if (pull === undefined) return null;
12801291 const base = repoBase(d.repo);
12811292 const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`;
1293 if (d.kind === "handover" && d.giveBack !== undefined)
1294 return html`<form ${own} action="${base}/pulls/${pull.number}/state">
1295 <input type="hidden" name="state" value="closed" />${where}
1296 </form>
1297 <form id="${askFormId(d)}-back" method="post" hidden action="/asks/give-back">
1298 <input type="hidden" name="ask" value="${d.id}" />${where}
1299 </form>`;
12821300 if (d.kind === "stopped" || d.kind === "handover")
12831301 return html`<form ${own} action="${base}/pulls/${pull.number}/state">
12841302 <input type="hidden" name="state" value="closed" />${where}
@@ -1321,6 +1339,9 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others:
13211339 variant: "primary",
13221340 }),
13231341 others: [
1342 ...(d.kind === "handover" && d.giveBack !== undefined
1343 ? [btn("Give back to the crew", { size: "sm", form: `${askFormId(d)}-back` })]
1344 : []),
13241345 btn("Close", { size: "sm", form: askFormId(d) }),
13251346 btn("See", { href: changeHref(base, pull.number), size: "sm" }),
13261347 ],
src/web/studio/asks_page.ts +14
@@ -19,6 +19,7 @@ import {
1919 type Skipped,
2020 } from "../../actions/asks_bulk.ts";
2121 import { getDecision } from "../../core/decisions.ts";
22 import { giveBack } from "../../actions/give_back.ts";
2223 import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts";
2324 import type { FleetValue } from "../../core/fleet_ops.ts";
2425 import { muteRepo, removeMutes } from "../../core/interactions.ts";
@@ -687,6 +688,19 @@ export async function asksDecide(ctx: Ctx): Promise<Response> {
687688 return redirect(`/asks?decided=${done.value.id}#asks-bulk-result`);
688689 }
689690
691 /** POST /asks/give-back — hand a handed-over pull back to the builder that opened it; its card leaves. */
692 export async function asksGiveBack(ctx: Ctx): Promise<Response> {
693 if (!ctx.auth.user) return signInFirst(ctx);
694 const form = await readForm(ctx, "rw");
695 if (form instanceof Response) return form;
696 const done = giveBack(ctx.app, ctx.auth.user, field(form, "ask"));
697 if (!done.ok)
698 return done.error.code === "not_found"
699 ? notFoundPage(ctx)
700 : await asksScreen(ctx, nothingDone(done.error.message));
701 return redirect(backTo(form, "/asks"));
702 }
703
690704 /** POST /asks/proposal/:id/(approve|redirect|later|again) — one wave proposal, then back to the page the person came from. */
691705 export function asksProposal(verb: ProposalVerb) {
692706 return async (ctx: Ctx): Promise<Response> => {
test/handover-give-back.test.ts added +241
@@ -0,0 +1,241 @@
1 /**
2 * "Give back to the crew" on a hand-over card: drawn on a crew pull's card
3 * and not a person's; one POST assigns the pull to its builder, comments,
4 * and the card leaves; a foreign Origin is refused; a reader gets a 404.
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 { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
13 import { getIssueById, listComments } from "../src/core/issues.ts";
14 import { createRepo, type Repo } from "../src/core/repos.ts";
15 import { submitReview } from "../src/core/reviews.ts";
16 import { createUser, getUserByName, type User } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { id } from "../src/kernel/id.ts";
19 import { askLinesFor } from "../src/web/studio/asks.ts";
20 import { createForgeApp } from "../src/tui/forge.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { settleApp } from "./helpers/tui.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { promote } from "./helpers/members.ts";
25
26 quietLogs();
27
28 const ZERO = "0".repeat(40);
29
30 describe("give a handed-over pull back to the crew", () => {
31 let dataDir = "";
32 let app: App;
33 let servers: Servers;
34 let owner: User;
35 let builder: User;
36 let reviewer: User;
37 let repo: Repo;
38 let cookie = "";
39 let readerCookie = "";
40 let seq = 0;
41
42 const signIn = async (username: string, password: string) => {
43 const res = await fetch(`${servers.url}/signin`, {
44 method: "POST",
45 body: new URLSearchParams({ username, password }),
46 redirect: "manual",
47 });
48 return `${(res.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=studio`;
49 };
50
51 const nextNumber = (): number =>
52 app.db
53 .query<{ issue_seq: number }, [string]>(
54 "UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq",
55 )
56 .get(repo.id)!.issue_seq;
57
58 /** An open issue held by `holder`; returns its id and number. */
59 const heldIssue = (holder: User): { id: string; number: number } => {
60 const issueId = id();
61 const number = nextNumber();
62 app.db
63 .query(
64 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, assignee_id)
65 VALUES (?1, ?2, ?3, 'issue', 'Held', '', ?4, 'open', ?4)`,
66 )
67 .run(issueId, repo.id, number, owner.id);
68 app.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(issueId, holder.id);
69 return { id: issueId, number };
70 };
71
72 const handedOver = (author: User, title: string, body = ""): string => {
73 const issueId = id();
74 const number = nextNumber();
75 app.db
76 .query(
77 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state)
78 VALUES (?1, ?2, ?3, 'pull', ?4, ?6, ?5, 'open')`,
79 )
80 .run(issueId, repo.id, number, title, author.id, body);
81 app.db
82 .query(
83 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id)
84 VALUES (?1, 'main', ?2, ?3, ?3, ?4)`,
85 )
86 .run(issueId, `b/${++seq}`, ZERO, repo.id);
87 for (let r = 0; r < 3; r++)
88 submitReview(app.db, {
89 issueId,
90 authorId: reviewer.id,
91 state: "changes_requested",
92 body: `Round ${r}.`,
93 commitOid: ZERO,
94 });
95 return issueId;
96 };
97
98 const asking = (pullId: string): boolean =>
99 askLinesFor(app.db, owner).some((r) => r.items.includes(`handover:${pullId}`));
100
101 const page = async (): Promise<string> =>
102 (await (await fetch(`${servers.url}/asks`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
103
104 const post = (ask: string, headers: Record<string, string> = {}, as = cookie) =>
105 fetch(`${servers.url}/asks/give-back`, {
106 method: "POST",
107 headers: { cookie: as, origin: servers.url, ...headers },
108 body: new URLSearchParams({ ask }),
109 redirect: "manual",
110 });
111
112 beforeAll(async () => {
113 dataDir = await mkdtemp(join(tmpdir(), "forge-give-back-"));
114 app = createApp({ dataDir, port: 0 });
115 servers = await serve(app);
116 const make = async (username: string, agent: boolean) => {
117 const made = await createUser(app.db, {
118 username,
119 email: `${username}@forge.test`,
120 ...(agent
121 ? { agent: true, origin: "agent" as const }
122 : { password: "a-good-passphrase", origin: "seed" as const, trusted: true }),
123 });
124 if (!made.ok) throw new Error(`seed ${username}: ${made.error}`);
125 if (agent) createAgentRow(app.db, { userId: made.value.id, brief: `${username}.` });
126 return made.value;
127 };
128 owner = await make("alice", false);
129 await make("reader", false);
130 builder = await make("builder", true);
131 reviewer = await make("reviewer", true);
132 promote(app.db, getUserByName(app.db, "alice")!);
133 const made = await createRepo(app.db, app.reposRoot, {
134 ownerId: owner.id,
135 name: "back",
136 visibility: "public",
137 description: "back",
138 });
139 if (!made.ok) throw new Error(`seed repo: ${made.error}`);
140 repo = made.value;
141 for (const agent of [builder, reviewer]) addCrew(app.db, repo.id, agent.id, owner.id);
142 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
143 cookie = await signIn("alice", "a-good-passphrase");
144 readerCookie = await signIn("reader", "a-good-passphrase");
145 }, 30_000);
146
147 afterAll(async () => {
148 await servers.stop();
149 app.close();
150 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
151 });
152
153 test("the button is on a crew pull's card and not on a person's", async () => {
154 const crew = handedOver(builder, "Crew pull");
155 const mine = handedOver(owner, "Person pull");
156 expect(asking(crew)).toBe(true);
157 expect(asking(mine)).toBe(true);
158 const html = await page();
159 expect(html.split("Give back to the crew").length - 1).toBe(1);
160 expect(html).toContain(`id="ask-handover:${crew}-back"`);
161 expect(html).not.toContain(`id="ask-handover:${mine}-back"`);
162 expect((await post(`handover:${mine}`)).status).not.toBe(303);
163 expect(getIssueById(app.db, mine)!.assigneeId).toBeNull();
164 });
165
166 test("a foreign Origin is refused and a reader gets 404", async () => {
167 const crew = handedOver(builder, "Guarded");
168 expect((await post(`handover:${crew}`, { origin: "https://evil.example" })).status).toBe(403);
169 expect((await post(`handover:${crew}`, {}, readerCookie)).status).toBe(404);
170 expect(getIssueById(app.db, crew)!.assigneeId).toBeNull();
171 expect(asking(crew)).toBe(true);
172 });
173
174 test("the post assigns the builder, comments, and the card leaves", async () => {
175 const crew = handedOver(builder, "Given back");
176 const res = await post(`handover:${crew}`);
177 expect(res.status).toBe(303);
178 expect(getIssueById(app.db, crew)!.assigneeId).toBe(builder.id);
179 expect(listComments(app.db, crew).map((c) => c.body)).toContain("Given back to the crew by @alice");
180 expect(asking(crew)).toBe(false);
181 });
182
183 test("the issue a person holds goes to the builder too; one an agent holds is left alone", async () => {
184 const mine = heldIssue(owner);
185 const theirs = heldIssue(builder);
186 const crew = handedOver(builder, "Closes two", `Closes #${mine.number}\nCloses #${theirs.number}`);
187 expect((await post(`handover:${crew}`)).status).toBe(303);
188 expect(getIssueById(app.db, crew)!.assigneeId).toBe(builder.id);
189 expect(getIssueById(app.db, mine.id)!.assigneeId).toBe(builder.id);
190 // Still the builder's, untouched: no second assignment event.
191 expect(getIssueById(app.db, theirs.id)!.assigneeId).toBe(builder.id);
192 const other = heldIssue(reviewer);
193 const second = handedOver(builder, "Closes the reviewer's", `Closes #${other.number}`);
194 expect((await post(`handover:${second}`)).status).toBe(303);
195 expect(getIssueById(app.db, other.id)!.assigneeId).toBe(reviewer.id);
196 });
197
198 test("when the issue cannot be assigned nothing is done and the card stays", async () => {
199 const mine = heldIssue(owner);
200 const crew = handedOver(builder, "Half done", `Closes #${mine.number}`);
201 // Archived: assigning an agent is refused, so the whole give-back is.
202 app.db.query("UPDATE repo SET archived_at = ?2 WHERE id = ?1").run(repo.id, new Date().toISOString());
203 const res = await post(`handover:${crew}`);
204 app.db.query("UPDATE repo SET archived_at = NULL WHERE id = ?1").run(repo.id);
205 expect(res.status).not.toBe(303);
206 expect(getIssueById(app.db, crew)!.assigneeId).toBeNull();
207 expect(getIssueById(app.db, mine.id)!.assigneeId).toBe(owner.id);
208 expect(listComments(app.db, crew).map((c) => c.body)).not.toContain("Given back to the crew by @alice");
209 expect(asking(crew)).toBe(true);
210 });
211
212 test("the terminal: b gives the row back, and a reader's b does nothing", async () => {
213 const crew = handedOver(builder, "Terminal pull");
214 const drive = async (user: User, keys: string[]) => {
215 const terminal = headlessTerminal(140, 40);
216 const { app: ui } = createForgeApp(
217 app,
218 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user },
219 { terminal, exitHooks: false },
220 );
221 ui.start();
222 await settleApp(ui);
223 for (const key of keys) {
224 ui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
225 await settleApp(ui);
226 }
227 const text = ui.screen.toText();
228 ui.stop();
229 return text;
230 };
231 const reader = getUserByName(app.db, "reader")!;
232 await drive(reader, ["tab", "b"]);
233 expect(getIssueById(app.db, crew)!.assigneeId).toBeNull();
234 // Earlier tests left other handed-over rows: step down until this one is given back.
235 const keys = ["tab"];
236 for (let n = 0; n < 8; n++) keys.push("b", "j");
237 await drive(owner, keys);
238 expect(getIssueById(app.db, crew)!.assigneeId).toBe(builder.id);
239 expect(asking(crew)).toBe(false);
240 });
241 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs