Public: anyone can read this; the crew can change it.
Terminal: open the private repository a writer fixes a sealed report in #6492
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6464-terminal-open-the-private-repository-a-w → main 1 commit fork 2 threads · 2 unresolved
3 files
changed +98
−1
README.md +2
| @@ -42,6 +42,8 @@ issue — use the link on the page). Both are a forge admin's only. | ||
| 42 | 42 | |
| 43 | 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 | 44 | |
| 45 | On a sealed issue, a writer gets the private repository to fix it in: `POST /api/v1/repos/:owner/:name/issues/:number/private`, the issue page's button, and `p` on the issue's terminal screen, which shows the repository and its clone URL (built from the forge's origin). All three refuse alike: not found for anyone who may not see sealed reports, `not_sealed`, and `unverified`. | |
| 46 | ||
| 45 | 47 | Under the agents, each team that has a pull has one pooled row (its members' |
| 46 | 48 | counts added, the ratios taken from the sums; an agent on two teams counts in |
| 47 | 49 | both): `data-team` rows on the page, "team <name>" rows in that terminal screen. |
src/tui/screens/issue.ts +51 −1
| @@ -23,6 +23,7 @@ import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts"; | ||
| 23 | 23 | import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts"; |
| 24 | 24 | import { listTeams } from "../../core/teams.ts"; |
| 25 | 25 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 26 | import { privateRepoForSealed } from "../../actions/repos.ts"; | |
| 26 | 27 | import { alreadyDone } from "../../core/done_on_main.ts"; |
| 27 | 28 | import { getIssue, type Issue, type Label } from "../../core/issues.ts"; |
| 28 | 29 | import type { Repo } from "../../core/repos.ts"; |
| @@ -56,6 +57,19 @@ import { local, localRepo, whereRows, whereStrip } from "./where.ts"; | ||
| 56 | 57 | /** The crew card shows this many runs before it scrolls. */ |
| 57 | 58 | const CREW_ROWS = 4; |
| 58 | 59 | |
| 60 | /** The terminal's clone URL for a repository, from the forge's own origin. */ | |
| 61 | export function cloneUrl(f: Session, repo: Repo): string { | |
| 62 | const config = f.app.config; | |
| 63 | const origin = config.url ?? `http://${config.host ?? "localhost"}:${config.port ?? 3000}`; | |
| 64 | return `${origin.replace(/\/+$/, "")}/${repo.ownerName}/${repo.name}.git`; | |
| 65 | } | |
| 66 | ||
| 67 | /** The refusals of the private repository, in the web page's words. */ | |
| 68 | const PRIVATE_SAY: Record<string, string> = { | |
| 69 | not_sealed: "This report is not sealed: its fix can be worked in the open.", | |
| 70 | unverified: "Verify your email first: the private repository is for verified writers.", | |
| 71 | }; | |
| 72 | ||
| 59 | 73 | /** The status-line tail for an issue the crew found done on main. */ |
| 60 | 74 | export function doneText(commits: readonly string[], canClose: boolean): string { |
| 61 | 75 | if (commits.length === 0) return ""; |
| @@ -85,6 +99,8 @@ export function issueScreen( | ||
| 85 | 99 | const crew = new RunList(f); |
| 86 | 100 | /** Which pane `j`/`k`/`enter` belong to; `tab` moves between them. */ |
| 87 | 101 | let focus: "thread" | "crew" = "thread"; |
| 102 | /** The private repository made for this sealed report, shown until we leave. */ | |
| 103 | let privateNote: string | undefined; | |
| 88 | 104 | const mayWrite = (): boolean => src.capabilities.write && repo !== undefined; |
| 89 | 105 | scroll.top = keep.top ?? 0; |
| 90 | 106 | |
| @@ -203,6 +219,25 @@ export function issueScreen( | ||
| 203 | 219 | }); |
| 204 | 220 | }; |
| 205 | 221 | |
| 222 | /** `p` — the private repository to fix this sealed report in. */ | |
| 223 | const privateRepo = (): void => { | |
| 224 | const at = row(); | |
| 225 | if (at?.issue === undefined) return; | |
| 226 | const { repo: target, issue } = at; | |
| 227 | runAsync(ctx, async () => { | |
| 228 | const made = await privateRepoForSealed(f.app, f.user, target, issue); | |
| 229 | if (!made.ok) { | |
| 230 | ctx.toast(PRIVATE_SAY[made.error] ?? errorText(made.error), "danger"); | |
| 231 | return; | |
| 232 | } | |
| 233 | const there = `${made.value.ownerName}/${made.value.name}`; | |
| 234 | privateNote = `private repository ${there} · clone ${cloneUrl(f, made.value)} · open the pull from there, saying "Closes #${number}"`; | |
| 235 | width = 0; | |
| 236 | ctx.invalidate(); | |
| 237 | ctx.toast(`private repository ${there}`, "success"); | |
| 238 | }); | |
| 239 | }; | |
| 240 | ||
| 206 | 241 | const screen: Screen = { |
| 207 | 242 | title: `#${number}`, |
| 208 | 243 | get keys() { |
| @@ -225,6 +260,9 @@ export function issueScreen( | ||
| 225 | 260 | }), |
| 226 | 261 | binding(["l"], "labels"), |
| 227 | 262 | binding(["t"], "teams"), |
| 263 | ...(issueRow?.sealed === true | |
| 264 | ? [binding(["p"], "private repo")] | |
| 265 | : []), | |
| 228 | 266 | ] |
| 229 | 267 | : liveBindings(src)), |
| 230 | 268 | binding(["r"], "refresh"), |
| @@ -336,7 +374,15 @@ export function issueScreen( | ||
| 336 | 374 | ], |
| 337 | 375 | ] |
| 338 | 376 | : []; |
| 339 | const all = [...lines, ...hint]; | |
| 377 | const note = | |
| 378 | privateNote === undefined | |
| 379 | ? [] | |
| 380 | : threadLines( | |
The note is a fabricated |
||
| 381 | [{ kind: "comment" as const, actor: "private repository", at: new Date().toISOString(), body: privateNote }], | |
| 382 | Math.max(20, body!.w - 1), | |
| 383 | c, | |
| 384 | ); | |
| 385 | const all = [...lines, ...hint, ...note]; | |
| 340 | 386 | drawLines(s, body!, c, scroll, all); |
| 341 | 387 | hitScroll(ctx, body!, scroll, all.length); |
| 342 | 388 | if (foot > 0) composer.render(s, box!, c, ctx); |
| @@ -422,6 +468,10 @@ export function issueScreen( | ||
| 422 | 468 | ); |
| 423 | 469 | return true; |
| 424 | 470 | } |
| 471 | if (e.key === "p" && mayWrite()) { | |
| 472 | privateRepo(); | |
| 473 | return true; | |
| 474 | } | |
| 425 | 475 | if (e.key === "r") { |
| 426 | 476 | runAsync(ctx, reload); |
| 427 | 477 | return true; |
test/tui-workflows.test.ts +45
| @@ -520,4 +520,49 @@ describe("reviews, threads and the merge gate", () => { | ||
| 520 | 520 | h.stop(); |
| 521 | 521 | } |
| 522 | 522 | }); |
| 523 | test("p on a sealed issue shows the private repository and its clone URL, with the same refusals", async () => { | |
| 524 | const sealed = openIssue(forge, alice, repo, { title: "Token leak", sealed: true }); | |
| 525 | const plain = openIssue(forge, alice, repo, { title: "A typo" }); | |
| 526 | if (!sealed.ok || !plain.ok) throw new Error("issues"); | |
| 527 | const open = (user: User) => | |
| 528 | boot(forge, identityFor(user)); | |
| 529 | ||
| 530 | const h = open(alice); | |
| 531 | try { | |
| 532 | await h.settle(); | |
| 533 | await h.push((s, ctx) => issueScreen(s, ctx, local(s, repo), sealed.value.number)); | |
| 534 | await h.until("private repo"); | |
| 535 | await h.press("p"); | |
| 536 | await h.until("private repository alice/"); | |
| 537 | expect(h.text()).toContain("-sealed.git"); | |
| 538 | expect(h.text()).toContain("http://localhost"); | |
| 539 | } finally { | |
| 540 | h.stop(); | |
| 541 | } | |
| 542 | ||
| 543 | // An issue that is not sealed has nothing to hide, and no key for it. | |
| 544 | const g = open(alice); | |
| 545 | try { | |
| 546 | await g.settle(); | |
| 547 | await g.push((s, ctx) => issueScreen(s, ctx, local(s, repo), plain.value.number)); | |
| 548 | await g.until("A typo"); | |
| 549 | expect(g.text()).not.toContain("private repo"); | |
| 550 | await g.press("p"); | |
| 551 | expect(g.text()).not.toContain("private repository alice/"); | |
| 552 | } finally { | |
| 553 | g.stop(); | |
| 554 | } | |
| 555 | ||
| 556 | // A writer whose email is not verified is refused. | |
| 557 | const b = open(bob); | |
| 558 | try { | |
| 559 | await b.settle(); | |
| 560 | await b.push((s, ctx) => issueScreen(s, ctx, local(s, repo), sealed.value.number)); | |
| 561 | await b.until("Token leak"); | |
| 562 | await b.press("p"); | |
| 563 | await b.until("Verify your email first"); | |
| 564 | } finally { | |
| 565 | b.stop(); | |
| 566 | } | |
| 567 | }, 60_000); | |
| 523 | 568 | }); |
new above
offline
cloneFactsin about.ts already builds clone URLs, from host and port rather thanconfig.url. Two builders will drift. Share one.