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

Terminal: open the private repository a writer fixes a sealed report in #6492

Merged opened this

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

Merged

scout/forge:scout/6464-terminal-open-the-private-repository-a-w → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/6464-terminal-open-the-private-repository-a-w was merged into main as 454b745.

Review

Sign in to review this change.
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.
4242
4343 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`).
4444
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
4547 Under the agents, each team that has a pull has one pooled row (its members'
4648 counts added, the ratios taken from the sums; an agent on two teams counts in
4749 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";
2323 import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts";
2424 import { listTeams } from "../../core/teams.ts";
2525 import { listRepoRuns } from "../../actions/runs.ts";
26 import { privateRepoForSealed } from "../../actions/repos.ts";
2627 import { alreadyDone } from "../../core/done_on_main.ts";
2728 import { getIssue, type Issue, type Label } from "../../core/issues.ts";
2829 import type { Repo } from "../../core/repos.ts";
@@ -56,6 +57,19 @@ import { local, localRepo, whereRows, whereStrip } from "./where.ts";
5657 /** The crew card shows this many runs before it scrolls. */
5758 const CREW_ROWS = 4;
5859
60 /** The terminal's clone URL for a repository, from the forge's own origin. */

cloneFacts in about.ts already builds clone URLs, from host and port rather than config.url. Two builders will drift. Share one.

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
5973 /** The status-line tail for an issue the crew found done on main. */
6074 export function doneText(commits: readonly string[], canClose: boolean): string {
6175 if (commits.length === 0) return "";
@@ -85,6 +99,8 @@ export function issueScreen(
8599 const crew = new RunList(f);
86100 /** Which pane `j`/`k`/`enter` belong to; `tab` moves between them. */
87101 let focus: "thread" | "crew" = "thread";
102 /** The private repository made for this sealed report, shown until we leave. */
103 let privateNote: string | undefined;
88104 const mayWrite = (): boolean => src.capabilities.write && repo !== undefined;
89105 scroll.top = keep.top ?? 0;
90106
@@ -203,6 +219,25 @@ export function issueScreen(
203219 });
204220 };
205221
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
206241 const screen: Screen = {
207242 title: `#${number}`,
208243 get keys() {
@@ -225,6 +260,9 @@ export function issueScreen(
225260 }),
226261 binding(["l"], "labels"),
227262 binding(["t"], "teams"),
263 ...(issueRow?.sealed === true
264 ? [binding(["p"], "private repo")]
265 : []),
228266 ]
229267 : liveBindings(src)),
230268 binding(["r"], "refresh"),
@@ -336,7 +374,15 @@ export function issueScreen(
336374 ],
337375 ]
338376 : [];
339 const all = [...lines, ...hint];
377 const note =
378 privateNote === undefined
379 ? []
380 : threadLines(

The note is a fabricated comment entry stamped with new Date(). It looks like a thread comment and its timestamp changes on every render. Draw it as a plain line.

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];
340386 drawLines(s, body!, c, scroll, all);
341387 hitScroll(ctx, body!, scroll, all.length);
342388 if (foot > 0) composer.render(s, box!, c, ctx);
@@ -422,6 +468,10 @@ export function issueScreen(
422468 );
423469 return true;
424470 }
471 if (e.key === "p" && mayWrite()) {
472 privateRepo();
473 return true;
474 }
425475 if (e.key === "r") {
426476 runAsync(ctx, reload);
427477 return true;
test/tui-workflows.test.ts +45
@@ -520,4 +520,49 @@ describe("reviews, threads and the merge gate", () => {
520520 h.stop();
521521 }
522522 });
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);
523568 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs