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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6347-a-pull-a-pass-handed-to-a-person-shows-i → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 29 | 29 | Decider) opens the rows, and `1`, `2`, `3` switch pile. Both are for the |
| 30 | 30 | repositories you own only: a reader sees nothing. |
| 31 | 31 | |
| 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 | ||
| 32 | 41 | **Let the decider** answers a hand-over card (an issue the crew parked) when |
| 33 | 42 | you would rather not: the third tap on the card, `POST /api/v1/asks/decide` |
| 34 | 43 | (`{"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. | ||
| 42 | 42 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 43 | 43 | | `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 | |
| 44 | 44 | | `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 | | |
| 45 | 46 | | `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 | |
| 46 | 47 | | `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` | | |
| 47 | 48 | | `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"; | ||
| 14 | 14 | import { closesFromBody } from "../core/pulls.ts"; |
| 15 | 15 | import { canWrite, getRepoById, type Repo } from "../core/repos.ts"; |
| 16 | 16 | import { getUserById, type User } from "../core/users.ts"; |
| 17 | import { handoverOf } from "../crew/guard.ts"; | |
| 17 | import { handoverOf, passReasonOf } from "../crew/guard.ts"; | |
| 18 | 18 | import { transact } from "../kernel/db.ts"; |
| 19 | 19 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 20 | 20 | import type { FleetError } from "./fleet.ts"; |
| 21 | 21 | import { NO_LONGER_ASKS, SIGN_IN_TO_ACT } from "./asks_words.ts"; |
| 22 | 22 | |
| 23 | const ITEM = /^handover:[A-Za-z0-9_-]{1,64}$/; | |
| 23 | const ITEM = /^(?:handover|assigned):[A-Za-z0-9_-]{1,64}$/; | |
| 24 | 24 | |
| 25 | 25 | const fail = (code: FleetError["code"], message: string) => |
| 26 | 26 | err<FleetError>({ code, message }); |
| @@ -37,6 +37,23 @@ export function builderOf(db: Database, repo: Repo, pull: Issue): User | undefin | ||
| 37 | 37 | : undefined; |
| 38 | 38 | } |
| 39 | 39 | |
| 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 | ||
| 40 | 57 | export interface GivenBack { |
| 41 | 58 | readonly pull: number; |
| 42 | 59 | readonly builder: string; |
| @@ -56,9 +73,14 @@ export function giveBack( | ||
| 56 | 73 | // A reader, or someone who cannot see it, is told nothing exists. |
| 57 | 74 | if (pull === undefined || repo === undefined || !canWrite(app.db, repo, actor)) |
| 58 | 75 | 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); | |
| 62 | 84 | if (builder === undefined) |
| 63 | 85 | return fail("bad_action", "no builder on the crew opened this pull, so there is no one to give it back to"); |
| 64 | 86 | // 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"; | ||
| 175 | 175 | export { route as postApiV1AsksBulkIdUndo } from "./postApiV1AsksBulkIdUndo.ts"; |
| 176 | 176 | export { route as postApiV1AsksDecide } from "./postApiV1AsksDecide.ts"; |
| 177 | 177 | export { route as postApiV1AsksDecideIdUndo } from "./postApiV1AsksDecideIdUndo.ts"; |
| 178 | export { route as postApiV1AsksGiveBack } from "./postApiV1AsksGiveBack.ts"; | |
| 178 | 179 | export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdVerb.ts"; |
| 179 | 180 | export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts"; |
| 180 | 181 | 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; | ||
| 259 | 259 | /** What the hand-over card starts with: the thread's own record of it, and what `handover` ignores as a person's word. */ |
| 260 | 260 | export const HANDOVER_NOTE = "Handed to a person by the crew:"; |
| 261 | 261 | |
| 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]>( | |
| 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 | ||
| 262 | 283 | export interface Handover { |
| 263 | 284 | /** The crew reviewer's verdicts that asked for changes, oldest first. */ |
| 264 | 285 | readonly reviews: readonly { id: string; author: string; body: string; at: string }[]; |
src/http/feed.ts +11
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | parseCursor, |
| 32 | 32 | readFeed, |
| 33 | 33 | } from "../feed/feed.ts"; |
| 34 | import { giveBack } from "../actions/give_back.ts"; | |
| 34 | 35 | import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts"; |
| 35 | 36 | import { actOnProposal, isProposalVerb } from "../actions/proposals.ts"; |
| 36 | 37 | import { asksAnswer } from "../web/studio/asks.ts"; |
| @@ -192,6 +193,16 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 192 | 193 | return json(done.value, 201); |
| 193 | 194 | }); |
| 194 | 195 | |
| 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 | ||
| 195 | 206 | router.add("POST", "/api/v1/asks/decide/:id/undo", async (ctx) => { |
| 196 | 207 | const denied = requireScope(ctx, "rw"); |
| 197 | 208 | if (denied) return denied; |
src/http/mcp/tools/asks_decide.ts +8 −2
| @@ -1,17 +1,23 @@ | ||
| 1 | 1 | import { decideAsk } from "../../../actions/asks_decide.ts"; |
| 2 | import { giveBack } from "../../../actions/give_back.ts"; | |
| 2 | 3 | import { good, bad, type Tool } from "../shared.ts"; |
| 3 | 4 | |
| 4 | 5 | export const tool: Tool = { |
| 5 | 6 | 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`", | |
| 6 | 8 | 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>`)", | |
| 8 | 10 | inputSchema: { |
| 9 | 11 | type: "object", |
| 10 | properties: { item: { type: "string", maxLength: 80 } }, | |
| 12 | properties: { item: { type: "string", maxLength: 80 }, give_back: { type: "boolean" } }, | |
| 11 | 13 | required: ["item"], |
| 12 | 14 | }, |
| 13 | 15 | writes: true, |
| 14 | 16 | 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 | } | |
| 15 | 21 | const done = await decideAsk(ctx.app, user, args["item"]); |
| 16 | 22 | return done.ok ? good(done.value) : bad(done.error.code, done.error.message); |
| 17 | 23 | }, |
src/tui/screens/dashboard.ts +16 −6
| @@ -33,6 +33,7 @@ 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 | 35 | import { giveBack } from "../../actions/give_back.ts"; |
| 36 | import { passReasonOf } from "../../crew/guard.ts"; | |
| 36 | 37 | import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts"; |
| 37 | 38 | import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts"; |
| 38 | 39 | import { putAsideWaves } from "../../web/studio/asks.ts"; |
| @@ -140,6 +141,8 @@ interface OnYouItem { | ||
| 140 | 141 | readonly hand?: true; |
| 141 | 142 | /** A pull the crew handed to a person: closed, marked and opened like any row. */ |
| 142 | 143 | readonly over?: true; |
| 144 | /** A pull a pass assigned to this person: why, and who it goes back to (`b`). */ | |
| 145 | readonly passed?: string; | |
| 143 | 146 | /** For a handed-over row: the crew's advice, when it wrote one. */ |
| 144 | 147 | readonly overAdvice?: string; |
| 145 | 148 | /** 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 { | ||
| 334 | 337 | ) |
| 335 | 338 | return []; |
| 336 | 339 | 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 }) }]; | |
| 338 | 345 | }); |
| 339 | 346 | return [...assigned, ...handOvers(assigned, rows, waitingOf)]; |
| 340 | 347 | }; |
| @@ -653,11 +660,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 653 | 660 | const giveBackHere = (): void => { |
| 654 | 661 | const here = onYou[at("onyou").index]; |
| 655 | 662 | if (here === undefined) return; |
| 656 | if (here.over !== true) { | |
| 663 | if (here.over !== true && here.passed === undefined) { | |
| 657 | 664 | ctx.toast("only a handed-over pull goes back to the crew", "attn"); |
| 658 | 665 | return; |
| 659 | 666 | } |
| 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}`); | |
| 661 | 668 | reload(); |
| 662 | 669 | ctx.toast( |
| 663 | 670 | 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 { | ||
| 824 | 831 | ...(focus === "onyou" && onYou[at("onyou").index]?.hand === true |
| 825 | 832 | ? [binding(["D"], "let the decider")] |
| 826 | 833 | : []), |
| 827 | ...(focus === "onyou" && onYou[at("onyou").index]?.over === true | |
| 834 | ...(focus === "onyou" && (onYou[at("onyou").index]?.over === true || onYou[at("onyou").index]?.passed !== undefined) | |
| 828 | 835 | ? [binding(["b"], "give back to the crew")] |
| 829 | 836 | : []), |
| 830 | 837 | ...(focus === "onyou" && waveHere() !== undefined |
| @@ -1122,7 +1129,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1122 | 1129 | : ISSUE_MARK; |
| 1123 | 1130 | return { |
| 1124 | 1131 | 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 ?? ""}`, | |
| 1126 | 1133 | }; |
| 1127 | 1134 | }), |
| 1128 | 1135 | ...waves.map( |
| @@ -1152,9 +1159,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1152 | 1159 | if (waitingSet !== undefined && waitingSet.count > 0) parts.push(`${waitingSet.count} waiting on you`); |
| 1153 | 1160 | if (waitingSet !== undefined && waitingSet.stale.length > 0) parts.push(`${staleWords(waitingSet.stale.length)} Z previews`); |
| 1154 | 1161 | // 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}`); | |
| 1155 | 1165 | if (lastHand !== undefined) parts.push("U undo decider"); |
| 1156 | 1166 | 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"); | |
| 1158 | 1168 | if (f.user !== undefined) { |
| 1159 | 1169 | const chips = chipsOf(f.db, f.user.id); |
| 1160 | 1170 | const off: string[] = KIND_CHIPS.filter((k) => chips.kinds.has(k)); |
src/web/studio/asks.ts +11 −5
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | import { lastBuilderOf } from "../../actions/give_back.ts"; | |
| 11 | 12 | import { forgeVitals, needLine } from "../../actions/health.ts"; |
| 12 | 13 | import { reviewSet } from "./review_set.ts"; |
| 13 | 14 | import type { Degraded } from "../../kernel/vitals.ts"; |
| @@ -55,7 +56,7 @@ import { | ||
| 55 | 56 | } from "../../crew/autonomy.ts"; |
| 56 | 57 | import { breakerParked, WAITING_NOTES } from "../../crew/breaker.ts"; |
| 57 | 58 | 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"; | |
| 59 | 60 | import { conflictsOf, isStalePull, mergesAgainSince, staleGreen } from "../../crew/stale_asks.ts"; |
| 60 | 61 | import { reviewWaitsFor, type ReviewWait } from "../../crew/review.ts"; |
| 61 | 62 | import type { Ctx } from "../../http/context.ts"; |
| @@ -130,7 +131,7 @@ export interface Decision { | ||
| 130 | 131 | readonly waiting: WaitingFor; |
| 131 | 132 | readonly why?: string | undefined; |
| 132 | 133 | 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. */ | |
| 134 | 135 | readonly giveBack?: string | undefined; |
| 135 | 136 | } |
| 136 | 137 | |
| @@ -280,9 +281,9 @@ interface AskCandidate { | ||
| 280 | 281 | readonly decline?: { readonly runId: string; readonly words: string }; |
| 281 | 282 | /** For "handover": the reviews that asked for changes. */ |
| 282 | 283 | 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. */ | |
| 284 | 285 | 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. */ | |
| 286 | 287 | readonly why?: string; |
| 287 | 288 | /** For "handover" and "waiting": what the crew advised, when it wrote one. */ |
| 288 | 289 | readonly recommendation?: Recommendation; |
| @@ -597,6 +598,9 @@ function askCandidatesFor( | ||
| 597 | 598 | if (!mayView(db, repo, issue, user)) continue; |
| 598 | 599 | const pull = joinedMine.get(issue.id); |
| 599 | 600 | 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; | |
| 600 | 604 | out.push({ |
| 601 | 605 | kind: "assigned", |
| 602 | 606 | id: `assigned:${issue.id}`, |
| @@ -605,6 +609,8 @@ function askCandidatesFor( | ||
| 605 | 609 | ...(pull === undefined ? { issue } : { pull }), |
| 606 | 610 | who: issue.authorName, |
| 607 | 611 | authorIsAgent: issue.authorIsAgent, |
| 612 | ...(reason === undefined ? {} : { why: reason }), | |
| 613 | ...(back === undefined ? {} : { builder: back }), | |
| 608 | 614 | }); |
| 609 | 615 | } |
| 610 | 616 | return out; |
| @@ -948,7 +954,7 @@ export function recordAskLedger(db: Database, now = new Date()): void { | ||
| 948 | 954 | function lineOf(c: AskCandidate): string { |
| 949 | 955 | if (c.kind === "paused") return "is paused at its ceiling"; |
| 950 | 956 | 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}`}`; | |
| 952 | 958 | if (c.kind === "parked") |
| 953 | 959 | return `the crew parked #${c.issue!.number} after three failed runs`; |
| 954 | 960 | if (c.kind === "proposal") |
src/web/studio/decisions.ts +9 −3
| @@ -69,8 +69,10 @@ export function decisionsFor( | ||
| 69 | 69 | repo: c.repo, |
| 70 | 70 | ...(c.pull === undefined ? { issue: c.issue } : { pull: c.pull }), |
| 71 | 71 | 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 }), | |
| 74 | 76 | }; |
| 75 | 77 | } |
| 76 | 78 | if (c.kind === "proposal" && c.proposal) |
| @@ -216,6 +218,10 @@ export function decisionForm(d: Decision, back?: string): Html | null { | ||
| 216 | 218 | if (pull === undefined) return null; |
| 217 | 219 | const base = repoBase(d.repo); |
| 218 | 220 | 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>`; | |
| 219 | 225 | if (d.kind === "handover" && d.giveBack !== undefined) |
| 220 | 226 | return html`<form ${own} action="${base}/pulls/${pull.number}/state"> |
| 221 | 227 | <input type="hidden" name="state" value="closed" />${where} |
| @@ -301,7 +307,7 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others: | ||
| 301 | 307 | size: "sm", |
| 302 | 308 | variant: "primary", |
| 303 | 309 | }), |
| 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: |
||
| 305 | 311 | }; |
| 306 | 312 | if (d.kind === "proposal" && d.proposal) |
| 307 | 313 | return { |
test/mcp-docs.test.ts −1
| @@ -20,7 +20,6 @@ const undocumented = new Set<string>([ | ||
| 20 | 20 | "here", |
| 21 | 21 | "asks", |
| 22 | 22 | "asks_act", |
| 23 | "asks_decide", | |
| 24 | 23 | "asks_proposal", |
| 25 | 24 | "asks_undecide", |
| 26 | 25 | "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 | }); |
new above
offline
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.