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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6419-a-hand-over-card-has-give-back-to-the-cr → main 2 commits fork 3 threads · 3 unresolved
README.md +3
| @@ -39,6 +39,9 @@ parked, why) and a "Restore drill" card (the last drill's pass or fail and | ||
| 39 | 39 | when). The terminal's palette has the same as "Crew hand-overs", a read-only |
| 40 | 40 | list of the same rows (repo#number, title, reasons; it does not open the |
| 41 | 41 | 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 | ||
| 42 | 45 | Under the agents, each team that has a pull has one pooled row (its members' |
| 43 | 46 | counts added, the ratios taken from the sums; an agent on two teams counts in |
| 44 | 47 | 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, () => { | |
| 69 | const assigned = assignIssue(app, actor, repo, pull, builder); | |
| 70 | if (!assigned.ok) throw refusal(`assign @${builder.username}`, assigned.error); | |
outdated
The |
||
| 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 |
||
| 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 { | ||
| 266 | 266 | asksBulkSubmit, |
| 267 | 267 | asksBulkUndo, |
| 268 | 268 | asksDecide, |
| 269 | asksGiveBack, | |
| 269 | 270 | asksProposal, |
| 270 | 271 | asksDecideUndo, |
| 271 | 272 | asksListen, |
| @@ -751,6 +752,7 @@ export function buildRouter(): Router<Handler> { | ||
| 751 | 752 | router.add("POST", "/asks/proposal/:id/later", asksProposal("later")); |
| 752 | 753 | router.add("POST", "/asks/proposal/:id/again", asksProposal("again")); |
| 753 | 754 | router.add("POST", "/asks/decide", asksDecide); |
| 755 | router.add("POST", "/asks/give-back", asksGiveBack); | |
| 754 | 756 | router.add("POST", "/asks/decide/:id/undo", asksDecideUndo); |
| 755 | 757 | // What people asked of the crew, and what became of it (core/asked.ts). |
| 756 | 758 | router.add("GET", "/asked", askedPage); |
src/tui/screens/dashboard.ts +22
| @@ -32,6 +32,7 @@ import { getProposal } from "../../core/proposals.ts"; | ||
| 32 | 32 | import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts"; |
| 33 | 33 | import { askLinesFor } from "../../web/studio/asks.ts"; |
| 34 | 34 | import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; |
| 35 | import { giveBack } from "../../actions/give_back.ts"; | |
| 35 | 36 | import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts"; |
| 36 | 37 | import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts"; |
| 37 | 38 | import { putAsideWaves } from "../../web/studio/asks.ts"; |
| @@ -597,6 +598,22 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 597 | 598 | }); |
| 598 | 599 | }; |
| 599 | 600 | |
| 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 | ||
| 600 | 617 | const undoHand = (): void => { |
| 601 | 618 | const id = lastHand; |
| 602 | 619 | if (id === undefined) { |
| @@ -756,6 +773,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 756 | 773 | ...(focus === "onyou" && onYou[at("onyou").index]?.hand === true |
| 757 | 774 | ? [binding(["D"], "let the decider")] |
| 758 | 775 | : []), |
| 776 | ...(focus === "onyou" && onYou[at("onyou").index]?.over === true | |
| 777 | ? [binding(["b"], "give back to the crew")] | |
| 778 | : []), | |
| 759 | 779 | ...(focus === "onyou" && waveHere() !== undefined |
| 760 | 780 | ? [binding(["y"], "approve wave"), binding(["R"], "redirect"), binding(["l"], "later")] |
| 761 | 781 | : []), |
| @@ -934,6 +954,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 934 | 954 | if (e.key === "m") return clear("mute"), true; |
| 935 | 955 | if (e.key === "S") return shipSet(), true; |
| 936 | 956 | if (e.key === "D" && item !== undefined) return handToDecider(), true; |
| 957 | if (e.key === "b" && item !== undefined) return giveBackHere(), true; | |
| 937 | 958 | if (e.key === "h") return hideAuthor(), true; |
| 938 | 959 | if (e.key === "y") return actOnWave("approve"), true; |
| 939 | 960 | if (e.key === "R") return redirectWave(), true; |
| @@ -1092,6 +1113,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1092 | 1113 | if (lastSet !== undefined) parts.push(`u undo ${lastSet.verb}`); |
| 1093 | 1114 | if (lastHand !== undefined) parts.push("U undo decider"); |
| 1094 | 1115 | 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"); | |
| 1095 | 1117 | if (asideWaves.length > 0) parts.push(`${asideWaves.length} ${asideWaves.length === 1 ? "wave" : "waves"} put aside · B bring back`); |
| 1096 | 1118 | return parts.length > 0 ? parts.join(" · ") : undefined; |
| 1097 | 1119 | } |
src/web/studio/asks.ts +21
| @@ -141,6 +141,8 @@ export interface Decision { | ||
| 141 | 141 | readonly waiting: WaitingFor; |
| 142 | 142 | readonly why?: string | undefined; |
| 143 | 143 | readonly recommendation?: Recommendation | undefined; |
| 144 | /** For "handover": the builder the pull can be given back to, if any. */ | |
| 145 | readonly giveBack?: string | undefined; | |
| 144 | 146 | } |
| 145 | 147 | |
| 146 | 148 | /** What an ask asks, the answers it takes, and what happens if nobody gives one. */ |
| @@ -289,6 +291,8 @@ interface AskCandidate { | ||
| 289 | 291 | readonly decline?: { readonly runId: string; readonly words: string }; |
| 290 | 292 | /** For "handover": the reviews that asked for changes. */ |
| 291 | 293 | readonly handover?: Handover; |
| 294 | /** For "handover": the crew member that opened the pull, when it can be given back. */ | |
| 295 | readonly builder?: string; | |
| 292 | 296 | /** For "handover" and "waiting": one sentence on why a person must decide. */ |
| 293 | 297 | readonly why?: string; |
| 294 | 298 | /** For "handover" and "waiting": what the crew advised, when it wrote one. */ |
| @@ -371,6 +375,7 @@ function askCandidatesFor( | ||
| 371 | 375 | handedOver, |
| 372 | 376 | ); |
| 373 | 377 | const first = out.length; |
| 378 | let crews: Map<string, string[]> | undefined; | |
| 374 | 379 | for (const issue of pulls) { |
| 375 | 380 | const repo = byId.get(issue.repoId)!; |
| 376 | 381 | const pull = joined.get(issue.id); |
| @@ -391,6 +396,10 @@ function askCandidatesFor( | ||
| 391 | 396 | if (over !== undefined && repo.autonomy === "autonomous" && issue.authorIsAgent) |
| 392 | 397 | continue; |
| 393 | 398 | 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; | |
| 394 | 403 | out.push({ |
| 395 | 404 | kind: "handover", |
| 396 | 405 | id: `handover:${pull.id}`, |
| @@ -400,6 +409,7 @@ function askCandidatesFor( | ||
| 400 | 409 | who, |
| 401 | 410 | authorIsAgent: issue.authorIsAgent, |
| 402 | 411 | handover: over, |
| 412 | ...(builder === undefined ? {} : { builder }), | |
| 403 | 413 | why: HANDOVER_WHY, |
| 404 | 414 | }); |
| 405 | 415 | continue; |
| @@ -1179,6 +1189,7 @@ export function decisionsFor( | ||
| 1179 | 1189 | short: `"${c.pull.title}" had three reviews asking for changes`, |
| 1180 | 1190 | why: c.why, |
| 1181 | 1191 | recommendation: c.recommendation, |
| 1192 | giveBack: c.builder, | |
| 1182 | 1193 | }; |
| 1183 | 1194 | if (c.kind === "waiting" && c.pull) |
| 1184 | 1195 | return { |
| @@ -1279,6 +1290,13 @@ export function decisionForm(d: Decision, back?: string): Html | null { | ||
| 1279 | 1290 | if (pull === undefined) return null; |
| 1280 | 1291 | const base = repoBase(d.repo); |
| 1281 | 1292 | 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>`; | |
| 1282 | 1300 | if (d.kind === "stopped" || d.kind === "handover") |
| 1283 | 1301 | return html`<form ${own} action="${base}/pulls/${pull.number}/state"> |
| 1284 | 1302 | <input type="hidden" name="state" value="closed" />${where} |
| @@ -1321,6 +1339,9 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others: | ||
| 1321 | 1339 | variant: "primary", |
| 1322 | 1340 | }), |
| 1323 | 1341 | others: [ |
| 1342 | ...(d.kind === "handover" && d.giveBack !== undefined | |
| 1343 | ? [btn("Give back to the crew", { size: "sm", form: `${askFormId(d)}-back` })] | |
| 1344 | : []), | |
| 1324 | 1345 | btn("Close", { size: "sm", form: askFormId(d) }), |
| 1325 | 1346 | btn("See", { href: changeHref(base, pull.number), size: "sm" }), |
| 1326 | 1347 | ], |
src/web/studio/asks_page.ts +14
| @@ -19,6 +19,7 @@ import { | ||
| 19 | 19 | type Skipped, |
| 20 | 20 | } from "../../actions/asks_bulk.ts"; |
| 21 | 21 | import { getDecision } from "../../core/decisions.ts"; |
| 22 | import { giveBack } from "../../actions/give_back.ts"; | |
| 22 | 23 | import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts"; |
| 23 | 24 | import type { FleetValue } from "../../core/fleet_ops.ts"; |
| 24 | 25 | import { muteRepo, removeMutes } from "../../core/interactions.ts"; |
| @@ -687,6 +688,19 @@ export async function asksDecide(ctx: Ctx): Promise<Response> { | ||
| 687 | 688 | return redirect(`/asks?decided=${done.value.id}#asks-bulk-result`); |
| 688 | 689 | } |
| 689 | 690 | |
| 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 | ||
| 690 | 704 | /** POST /asks/proposal/:id/(approve|redirect|later|again) — one wave proposal, then back to the page the person came from. */ |
| 691 | 705 | export function asksProposal(verb: ProposalVerb) { |
| 692 | 706 | 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 | }); |
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.assignedthat was rolled back. The durable effects (event row, dispatch queue) roll back correctly, so this is cosmetic and fine as it stands.