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

The forge toolbar acts: who is working now, comment on this page, tell the crew, make it yours #2239

Merged opened this

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

Merged

scout/forge:scout/810-the-forge-toolbar-acts-who-is-working-no → main 11 commits fork 6 threads · 6 unresolved

  • Merged. scout/810-the-forge-toolbar-acts-who-is-working-no was merged into main as b32c429.

Review

Sign in to review this change.
16 files changed +957 −9
README.md +20
@@ -511,6 +511,26 @@ file), and if you named an agent, the sentence became issue #1, assigned.
511511 redeployed on each push and gone when the pull closes. Its data starts as
512512 a copy of production's — copy-on-write where the filesystem can — and
513513 never writes back. Merging moves code and touches no data.
514 - **The forge pill acts.** The pill the proxy adds to an app's pages
515 (`"toolbar": false` in `forge.json` turns it off) links to a forge page,
516 `/:owner/:name/toolbar?env=production|pull-N`, opened in a tab of its own: the
517 pill posts nothing and reads nothing of the app's page, and every action is a
518 same-origin form on the forge. Signed out, the page is where you are and Home.
519 Signed in it shows the live agent run on this app or preview, linked to its
520 run page (read once when the page opens; runs on work you may not see, such as
521 a sealed report, are left out); *Report this page* / *Comment on this pull*,
522 previewed first, then a receipt; *Tell the crew* for those who may direct it
523 (the one `/:owner/:name/tell`); and *Make it yours* for everyone who cannot
524 write the app. A production comment opens an issue carrying the page address;
525 a preview comment goes on its pull. From a terminal or an agent the same
526 actions are, in the SSH terminal, rows of the palette on a repository
527 screen: *Working now* (while an agent run is live, left out when it is on work
528 you may not see), *Tell the crew*, *Report a problem* (one text, an issue
529 titled by its first line, through `openIssueThrough`) and *Make it yours*
530 (the fork screen, for those who cannot write the repository); a pull is
531 commented on from its own screen. Agents have the MCP `tell` and
532 `make_it_yours` tools and the API's `POST …/issues`,
533 `…/pulls/:n/comments` and `…/make-it-yours`.
514534 - **A run has a budget** (`src/kernel/budgets.ts`): a check past `gate`
515535 (60 min) or a reviewer's run past `review` (10 min) is killed with
516536 `budget: <what> took <n> min, limit <m>`, queued again once, and failed
src/actions/page-report.ts added +13
@@ -0,0 +1,13 @@
1 /**
2 * The words of a report made from a page (#810): the first line is the
3 * title, the whole text is the body. One shape for the toolbar page and the
4 * terminal's palette, so the issue they open reads the same from either door.
5 */
6
7 const TITLE_MAX = 80;
8
9 /** The issue title for `text`: its first line, kept to {@link TITLE_MAX}. */
10 export function reportTitle(text: string): string {
11 const first = text.trim().split("\n")[0]!.trim();
12 return first.length <= TITLE_MAX ? first : `${first.slice(0, TITLE_MAX - 3).trimEnd()}…`;
13 }
src/apps/proxy.ts +10 −7
@@ -353,17 +353,20 @@ export function pillWhere(
353353 who: GrantWho | undefined,
354354 ): PillWhere {
355355 const where: PillWhere = { app: target.app.slug, env: target.env };
outdated

High: this runs for every viewer, guest included. A public app on a private repository now publishes the repository's owner/name in data-repo. Gate it the way the title below is gated: resolve the viewer first and set where.repo only if canRead(db, owner, user). The bar already hides its action link when d.repo is missing. Add a pill-where test for a public app on a private repo with a guest viewer.

356 const db = runtime.app.db;
357 const repo = getRepoById(db, target.app.repoId);
358 if (repo === undefined) return where;
359 const viewer = who === undefined ? undefined : getUserById(db, who.id);
360 const user = viewer !== undefined && isActive(viewer) ? viewer : undefined;
361 // An app's access is not its repository's: the name goes only to a reader.
362 const reads = canRead(db, repo, user);
363 if (reads) where.repo = `${repo.ownerName}/${repo.name}`;
356364 const number = /^pull-(\d+)$/.exec(target.env)?.[1];
357365 if (number === undefined) return where;
358366 where.pull = Number(number);
359 const db = runtime.app.db;
360 const repo = getRepoById(db, target.app.repoId);
361367 const pull = getIssue(db, target.app.repoId, where.pull);
362 if (repo === undefined || pull === undefined) return where;
363 const viewer = who === undefined ? undefined : getUserById(db, who.id);
364 const user = viewer !== undefined && isActive(viewer) ? viewer : undefined;
365 if (canRead(db, repo, user) && mayView(db, repo, pull, user))
366 where.title = pull.title;
368 if (pull === undefined) return where;
369 if (reads && mayView(db, repo, pull, user)) where.title = pull.title;
367370 return where;
368371 }
369372
src/apps/toolbar.ts +20
@@ -62,6 +62,23 @@ export const toolbarJs = `(function () {
6262 crew.setAttribute("aria-live", "polite");
6363 panel.appendChild(crew);
6464 panel.appendChild(link);
65 // The actions live on a forge page, opened in a tab of its own: the bar
66 // posts nothing and reads nothing of this page beyond its address.
67 if (d.repo && /^[\\w.-]+\\/[\\w.-]+$/.test(d.repo)) {
68 var act = document.createElement("a");
69 act.textContent = "Comment, tell, make it yours";
70 act.target = "_blank";
71 act.rel = "noopener noreferrer";
72 act.style.cssText = "color:#8ab4ff;text-decoration:none;margin-top:4px";
73 try {
74 var dest = new URL(home);
75 dest.pathname = "/" + d.repo + "/toolbar";
76 dest.searchParams.set("env", d.env === "production" ? "production" : "pull-" + d.pull);
77 dest.searchParams.set("page", location.origin + location.pathname);
78 act.href = dest.href;
79 panel.appendChild(act);
80 } catch (e) {}
81 }
6582 // Who of the crew is working here, asked once as the pill loads, of the
6683 // app's own origin (same-origin, so its grant cookie goes along); nothing
6784 // is shown idle.
@@ -155,6 +172,8 @@ export function rewritable(headers: Headers, status: number): boolean {
155172 /** What the pill says about where it is; the title only for a reader of the pull. */
156173 export type PillWhere = {
157174 app: string;
175 /** `owner/name`, where the forge page of the bar's actions lives. */
176 repo?: string;
158177 env: string;
159178 pull?: number;
160179 title?: string;
@@ -177,6 +196,7 @@ export function toolbarTag(forgeUrl: string, where?: PillWhere): string {
177196 ? ""
178197 : [
179198 ["app", where.app],
199 ["repo", where.repo],
180200 ["env", where.env],
181201 ["pull", where.pull === undefined ? undefined : String(where.pull)],
182202 ["title", where.title],
src/core/runs.ts +6
@@ -635,6 +635,8 @@ export interface ListRunsOptions {
635635 issueId?: string | undefined;
636636 /** Only runs for this environment. */
637637 env?: string | undefined;
638 /** Only runs in this state. */
639 state?: RunState | undefined;
638640 /** @default 50 */
639641 limit?: number | undefined;
640642 /** Resume after this run id (exclusive) — lists run newest first. */
@@ -660,6 +662,10 @@ export function listRuns(
660662 params.push(options.env);
661663 where += ` AND env = ?${params.length}`;
662664 }
665 if (options.state !== undefined) {
666 params.push(options.state);
667 where += ` AND state = ?${params.length}`;
668 }
663669 if (options.cursor !== undefined) {
664670 params.push(options.cursor);
665671 where += ` AND id < ?${params.length}`;
src/http/server.ts +4
@@ -6,6 +6,7 @@
66 */
77
88 import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
9 import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts";
910 import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts";
1011 import { unsealReleased } from "../actions/sealed.ts";
1112 import { parentCallback, parentStart } from "./rp.ts";
@@ -1225,6 +1226,9 @@ export function buildRouter(): Router<Handler> {
12251226 // Make it yours (#606): a fork with its app, planned, then made.
12261227 router.add("GET", "/:owner/:name/make-it-yours", makeItYoursPage);
12271228 router.add("POST", "/:owner/:name/make-it-yours", makeItYoursSubmit);
1229 // The toolbar's actions (#810): the bar links here, the forms post here.
1230 router.add("GET", "/:owner/:name/toolbar", toolbarPage);
1231 router.add("POST", "/:owner/:name/toolbar/comment", toolbarComment);
12281232 // A fork takes what it follows: git's part by itself, the rest by the crew.
12291233 router.add("POST", "/:owner/:name/following", followingSubmit);
12301234 router.add("GET", "/:owner/:name/following", chainPage);
src/tui/screens/report.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * Report a problem, from a repository screen's `:` palette (#810): the
3 * terminal's door to what the forge toolbar's "Report this page" does on the
4 * web. One text becomes an issue through `openIssueThrough`, titled by its
5 * first line. A pull is commented on from its own screen.
6 */
7
8 import { openIssueThrough } from "../../actions/issues.ts";
9 import { reportTitle } from "../../actions/page-report.ts";
10 import { MAX_BODY } from "../../core/issues.ts";
11 import type { Repo } from "../../core/repos.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import type { Command } from "../app/palette.ts";
14 import type { Session } from "../session.ts";
15 import { promptScreen } from "./common.ts";
16
17 /** The palette row for `repo`: any signed-in person may report. */
18 export function reportCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: Repo | undefined): Command[] {
19 if (repo === undefined || f.user === undefined) return [];
20 const name = `${repo.ownerName}/${repo.name}`;
21 return [
22 {
23 id: "report",
24 title: `${name} — Report a problem`,
25 hint: "opens an issue",
26 run: () =>
27 ctx.push(
28 promptScreen(ctx, parent(), {
29 title: "Report a problem",
30 label: name,
31 hint: "enter sends",
32 onSubmit: (said) => {
33 const text = said.trim();
34 if (text === "") return "Say something first.";
35 if (text.length > MAX_BODY) return "That is too long to send in one go.";
36 return (async () => {
37 const opened = await openIssueThrough(f.app, f.user, repo, { title: reportTitle(text), body: text });
38 if (opened.ok) ctx.toast(`Issue #${opened.value.number} opened.`, "success");
39 else ctx.toast("That could not be sent.", "danger");
40 })();
41 },
42 }),
43 ),
44 },
45 ];
46 }
src/tui/screens/working.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * Working now, from a repository screen's `:` palette (#810): the terminal's
3 * door to the toolbar page's live line. The row is there while an agent run
4 * is running on this repository and names it; a run on work the person may
5 * not see (a sealed report) is left out, as on the page. Read when the
6 * palette opens, which is as often as the page reads it.
7 */
8
9 import type { Repo } from "../../core/repos.ts";
10 import { listRuns } from "../../core/runs.ts";
11 import { mayViewId } from "../../core/sealed.ts";
12 import type { Ctx } from "../app/app.ts";
13 import type { Command } from "../app/palette.ts";
14 import type { Session } from "../session.ts";
15
16 export function workingCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
17 if (repo === undefined || f.user === undefined) return [];
18 const live = listRuns(f.db, repo.id, { kind: "agent", state: "running", limit: 200 }).find((r) =>
19 mayViewId(f.db, repo, r.issueId, f.user),
20 );
21 if (live === undefined) return [];
22 const summary = live.summary || "the crew is on this";
23 return [
24 {
25 id: "working",
26 title: `${repo.ownerName}/${repo.name} — Working now: ${summary}`,
27 hint: "the crew is running",
28 run: () => ctx.toast(`Working now: ${summary}`, "success"),
29 },
30 ];
31 }
src/tui/screens/workspace.ts +9 −1
@@ -112,7 +112,10 @@ import {
112112 slug,
113113 openOnWeb,
114114 } from "./common.ts";
115 import { reportCommands } from "./report.ts";
115116 import { tellCommands } from "./tell.ts";
117 import { workingCommands } from "./working.ts";
118 import { yoursCommands } from "./yours.ts";
116119 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
117120 import { finderScreen } from "./finder.ts";
118121 import { issuesScreen } from "./issues.ts";
@@ -1267,7 +1270,12 @@ export function workspaceScreen(
12671270 };
12681271
outdated

The terminal gets tell and report here, but not the page's other two doors. There's no Working now line (the running agent run, gated by mayViewId) and no Make it yours row for people who can't write. Add both next to these, each with a test.

12691272 const screen: Screen = {
1270 commands: () => tellCommands(f, ctx, () => screen, repo),
1273 commands: () => [
1274 ...workingCommands(f, ctx, repo),
1275 ...tellCommands(f, ctx, () => screen, repo),
1276 ...reportCommands(f, ctx, () => screen, repo),
1277 ...yoursCommands(f, ctx, repo),
1278 ],
12711279 get title() {
12721280 return sourceName(src);
12731281 },
src/tui/screens/yours.ts added +23
@@ -0,0 +1,23 @@
1 /**
2 * Make it yours, from a repository screen's `:` palette (#810): the
3 * terminal's door to the toolbar's button, for whoever cannot write the
4 * repository. It is the fork screen (#606) the `F` key opens.
5 */
6
7 import { canWrite, type Repo } from "../../core/repos.ts";
8 import type { Ctx } from "../app/app.ts";
9 import type { Command } from "../app/palette.ts";
10 import type { Session } from "../session.ts";
11 import { forkScreen } from "./newrepo.ts";
12
13 export function yoursCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
14 if (repo === undefined || f.user === undefined || canWrite(f.db, repo, f.user)) return [];
15 return [
16 {
17 id: "make-it-yours",
18 title: `${repo.ownerName}/${repo.name} — Make it yours`,
19 hint: "fork it",
20 run: () => ctx.push(forkScreen(f, ctx, repo)),
21 },
22 ];
23 }
src/web/studio/toolbar.ts added +225
@@ -0,0 +1,225 @@
1 /**
2 * The forge toolbar's actions (#810), as a forge page. The bar the app proxy
3 * adds to an app's pages runs on the app's origin, so it never posts: it
4 * opens this page on the forge's own origin, where every mutation is an
5 * ordinary same-origin form with the forge's CSRF check (`readForm`). The
6 * bar's script reads nothing of the app's page and nothing of the forge's
7 * session; the forge reads the session here.
8 *
9 * GET /:owner/:name/toolbar?env=&page= where you are, who is working,
10 * and the actions a person may take
11 * POST /:owner/:name/toolbar/comment a preview of the comment, then
12 * (step=post) the comment and a receipt
13 *
14 * Telling the crew posts to the one `/:owner/:name/tell`; making it yours
15 * links to #606's page. Signed out, the page is where you are and home.
16 */
17
18 import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts";
19 import { reportTitle } from "../../actions/page-report.ts";
20 import { getAppByRepo } from "../../core/apps.ts";
21 import { getIssue, MAX_BODY } from "../../core/issues.ts";
22 import { canWrite } from "../../core/repos.ts";
23 import { listRuns } from "../../core/runs.ts";
24 import { mayView, mayViewId } from "../../core/sealed.ts";
25 import { mayDirectCrew } from "../../crew/direct.ts";
26 import type { Ctx } from "../../http/context.ts";
27 import { notFoundPage, pageHead, loadRepo, repoBase } from "../chrome.ts";
28 import { field, readForm } from "../form.ts";
29 import { html, when } from "../html.ts";
30 import { page } from "../layout.ts";
31 import { originError } from "../pages/thread.ts";
32 import { btn, card } from "../ui.ts";
33
34 /** The longest page address the bar may carry. */
35 const PAGE_MAX = 512;
36
37 /** `raw` as a page address (http or https, no credentials), or "" for no.
38 * Only origin and path survive: a query or fragment may carry a token, and
39 * the address is published in an issue or comment. */
40 export function pageAddress(raw: string): string {
41 if (raw.length === 0 || raw.length > PAGE_MAX) return "";
42 const url = URL.parse(raw);
43 if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return "";
44 if (url.username !== "" || url.password !== "") return "";
45 url.search = "";
46 url.hash = "";
47 return url.href.length <= PAGE_MAX ? url.href : "";
48 }
49
50 /** `env` as the bar names it: production, or a preview's pull number. */
51 function parseEnv(raw: string): { env: string; pull?: number } | undefined {
52 if (raw === "production") return { env: raw };
53 const n = /^pull-([1-9]\d{0,8})$/.exec(raw)?.[1];
54 return n === undefined ? undefined : { env: raw, pull: Number(n) };
55 }
56
57 /** The toolbar page's own path, for forms to come back to. */
58 const toolbarPath = (base: string, env: string, address: string): string =>
59 `${base}/toolbar?env=${env}${address === "" ? "" : `&page=${encodeURIComponent(address)}`}`;
60
61 // GET /:owner/:name/toolbar
62 export function toolbarPage(ctx: Ctx): Response {
63 const repo = loadRepo(ctx);
64 if (!repo) return notFoundPage(ctx);
65 const where = parseEnv(ctx.url.searchParams.get("env") ?? "production");
66 if (where === undefined || getAppByRepo(ctx.app.db, repo.id) === undefined)
67 return notFoundPage(ctx);
68 const db = ctx.app.db;
69 const user = ctx.auth.user;
70 const pull = where.pull === undefined ? undefined : getIssue(db, repo.id, where.pull);
71 if (where.pull !== undefined && (!pull || pull.kind !== "pull" || !mayView(db, repo, pull, user)))
72 return notFoundPage(ctx);
73 const address = pageAddress(ctx.url.searchParams.get("page") ?? "");
74 const base = repoBase(repo);
75 const here = html`${repo.name}, ${pull ? html`preview of pull #${pull.number}` : "production"}`;
76 const home = btn("Home", { href: "/", variant: "ghost" });
77 if (!user) {
78 return page({
79 ctx,
80 title: `${repo.name} · forge`,
81 body: html`<div class="narrow-wide">${pageHead(here)}<div class="form-actions">${home}</div></div>`,
82 });
83 }
84 // Read once, now, as the page opens: the live agent run on this app, or
outdated

Not blocking: a run that is still going but older than the 20 newest agent runs is never found. Filtering on running state in the query would be exact.

85 // on this preview's pull.
86 // A run on work the viewer may not see (a sealed report) is not theirs to read.
87 const live = listRuns(db, repo.id, {
88 kind: "agent",
89 ...(pull ? { issueId: pull.id } : {}),
90 state: "running",
91 limit: 200,
92 }).find((r) => mayViewId(db, repo, r.issueId, user));
93 const comment = pull ? "Comment on this pull" : "Report this page";
94 const body = html`<div class="narrow-wide">
95 ${pageHead(here)}
96 ${when(
97 live !== undefined,
98 () => html`<p class="mono" data-working>
99 <a href="${base}/runs/${live!.id}">Working now: ${live!.summary || "the crew is on this"}</a>
100 </p>`,
101 )}
102 ${card(
103 html`<form method="post" action="${base}/toolbar/comment" class="stack">
104 <input type="hidden" name="env" value="${where.env}" />
105 <input type="hidden" name="page" value="${address}" />
106 <input type="hidden" name="step" value="preview" />
107 <label>${comment}
108 <textarea name="body" rows="4" maxlength="${MAX_BODY}" required></textarea>
109 </label>
110 <div class="form-actions">${btn("Preview", { variant: "primary" })}</div>
111 </form>`,
112 )}
113 ${when(
114 mayDirectCrew(db, repo, user),
115 () =>
116 card(
117 html`<form method="post" action="${base}/tell" class="stack">
118 <input type="hidden" name="from" value="${toolbarPath(base, where.env, "")}" />
119 <label>Tell the crew
120 <textarea name="text" rows="3" maxlength="${MAX_BODY}" required></textarea>
121 </label>
122 <div class="form-actions">${btn("Tell", { variant: "primary" })}</div>
123 </form>`,
124 ),
125 )}
126 <div class="form-actions">
127 ${when(
128 !canWrite(db, repo, user),
129 () => btn("Make it yours", { href: `${base}/make-it-yours`, icon: "fork" }),
130 )}
131 ${home}
132 </div>
133 </div>`;
134 return page({ ctx, title: `${repo.name} · forge`, body });
135 }
136
137 // POST /:owner/:name/toolbar/comment
138 export async function toolbarComment(ctx: Ctx): Promise<Response> {
139 const repo = loadRepo(ctx);
140 if (!repo) return notFoundPage(ctx);
141 const form = await readForm(ctx, "rw");
142 if (form instanceof Response) return form;
143 const user = ctx.auth.user;
144 if (!user) return new Response("forbidden", { status: 403 });
145 const where = parseEnv(field(form, "env"));
146 if (where === undefined) return new Response("bad request", { status: 400 });
147 const db = ctx.app.db;
148 const pull = where.pull === undefined ? undefined : getIssue(db, repo.id, where.pull);
149 if (where.pull !== undefined && (!pull || pull.kind !== "pull" || !mayView(db, repo, pull, user)))
150 return notFoundPage(ctx);
151 const text = field(form, "body").trim();
152 const address = pageAddress(field(form, "page"));
153 const base = repoBase(repo);
154 const back = toolbarPath(base, where.env, address);
155 const refuse = (message: string, status = 422): Response =>
156 page(
157 {
158 ctx,
159 title: "Not sent",
outdated

This checks text, but said (next line) is what gets stored, and the page suffix adds up to about 520 characters. A body near MAX_BODY passes the preview and then fails on Send with a misleading 403. Check the length of said.

160 body: html`<div class="narrow-wide">${pageHead("Not sent", { sub: message })}
161 <div class="form-actions">${btn("Back", { href: back, variant: "ghost" })}</div></div>`,
162 },
163 status,
164 );
165 if (text === "") return refuse("A comment needs something in it.");
166 const said = address === "" ? text : `${text}\n\nPage: <${address}>`;
167 if (said.length > MAX_BODY) return refuse("That is too long to send in one go.");
168 const target = pull ? `pull #${pull.number}` : "an issue on this app";
169
170 if (field(form, "step") !== "post") {
171 return page({
172 ctx,
173 title: "Preview",
174 body: html`<div class="narrow-wide">
175 ${pageHead("Preview", { sub: html`This will be sent as ${target}.` })}
176 ${card(html`<form method="post" action="${base}/toolbar/comment" class="stack">
177 <input type="hidden" name="env" value="${where.env}" />
178 <input type="hidden" name="page" value="${address}" />
179 <input type="hidden" name="step" value="post" />
180 <input type="hidden" name="body" value="${text}" />
181 <pre class="mono">${said}</pre>
182 <div class="form-actions">
183 ${btn("Send", { variant: "primary" })}
184 ${btn("Edit", { href: back, variant: "ghost" })}
185 </div>
186 </form>`)}
187 </div>`,
188 });
189 }
190
outdated

Use openIssueThrough, as every other door that opens an issue does. On a mirrored repo this call returns mirrored and the reader sees a 403 "could not be sent", where /issues/new would have written the issue to the origin. Map the errors the way newIssueSubmit does.

191 if (pull) {
192 const added = await addCommentThrough(ctx.app, user, repo, pull, said);
193 if (!added.ok)
194 return refuse(originError(added.error) ?? "That comment could not be posted.", 403);
195 return receipt(`${base}/pulls/${pull.number}#conversation`, "Comment posted");
196 }
197 // The `…Through` twin, as every door that opens an issue: on a mirror the
198 // origin creates it.
199 const opened = await openIssueThrough(ctx.app, user, repo, {
200 title: reportTitle(text),
201 body: said,
202 });
203 if (!opened.ok) {
204 return refuse(
205 originError(opened.error) ??
206 (opened.error === "body_too_long"
207 ? "That is too long to send in one go."
208 : "That could not be sent."),
209 opened.error === "body_too_long" ? 422 : opened.error === "not_found" ? 404 : 403,
210 );
211 }
212 return receipt(`${base}/issues/${opened.value.number}`, "Issue opened");
213
214 function receipt(href: string, title: string): Response {
215 return page({
216 ctx,
217 title,
218 body: html`<div class="narrow-wide">
219 ${pageHead(title)}
220 <p><a href="${href}">See it</a></p>
221 <div class="form-actions">${btn("Back to the page", { href: back, variant: "ghost" })}</div>
222 </div>`,
223 });
224 }
225 }
test/apps-pill-where.test.ts +22
@@ -45,9 +45,13 @@ describe("the pill's data", () => {
4545 for (const [name, visibility] of [
4646 ["open", "public"],
4747 ["closed", "private"],
48 ["leaky", "private"],
4849 ] as const) {
4950 const made = await newApp(app, a.value, { name, description: "x", visibility });
5051 if (!made.ok) throw new Error(String(made.error));
52 // The app's access is its own: this one is open on a private repo.
53 if (name === "leaky")
54 app.db.run("UPDATE app SET access='public' WHERE repo_id=?1", [made.value.repo.id]);
5155 const record = getAppByRepo(app.db, made.value.repo.id)!;
5256 targets[name] = { app: record, env: "production" } as AppTarget;
5357 const pull = createIssue(app.db, {
@@ -72,6 +76,7 @@ describe("the pill's data", () => {
7276 test("production names the app and the environment, no pull", () => {
7377 expect(pillWhere(runtime, targets.open!, undefined)).toEqual({
7478 app: targets.open!.app.slug,
79 repo: "alice/open",
7580 env: "production",
7681 });
7782 });
@@ -91,6 +96,23 @@ describe("the pill's data", () => {
9196 expect(owner.title).toContain("closed");
9297 });
9398
99 test("a public app on a private repo names the repo only to a reader", async () => {
100 expect(targets.leaky!.app.access).toBe("public");
101 expect(pillWhere(runtime, targets.leaky!, undefined).repo).toBeUndefined();
102 expect(pillWhere(runtime, preview("leaky"), undefined).title).toBeUndefined();
103 const bob = await createUser(app.db, {
104 trusted: true,
105 username: "bob",
106 email: "[email protected]",
107 password: "a-good-passphrase",
108 });
109 if (!bob.ok) throw new Error(bob.error);
110 const stranger = pillWhere(runtime, targets.leaky!, { id: bob.value.id } as never);
111 expect(stranger.repo).toBeUndefined();
112 const owner = pillWhere(runtime, targets.leaky!, { id: aliceId } as never);
113 expect(owner.repo).toBe("alice/leaky");
114 });
115
94116 test("a title with markup is escaped inside the attribute", () => {
95117 const tag = toolbarTag("http://f.test", {
96118 app: "a",
test/apps-toolbar.test.ts +1 −1
@@ -128,7 +128,7 @@ describe("the forge pill", () => {
128128 redirect: "manual",
129129 });
130130 const tag = () =>
131 `<script src="${servers.url}${TOOLBAR_PATH}" data-app="notes" data-env="production" defer></script>`;
131 `<script src="${servers.url}${TOOLBAR_PATH}" data-app="notes" data-repo="alice/notes" data-env="production" defer></script>`;
132132
133133 test("HTML gets one script tag before </body>, with its length fixed", async () => {
134134 toolbar = undefined;
test/toolbar-actions.test.ts added +324
@@ -0,0 +1,324 @@
1 /**
2 * The toolbar's actions (#810): the page the bar opens on the forge, the
3 * comment (a pull's comment on a preview, an issue on production) with a
4 * preview first and a receipt after, the tell through the one tell door,
5 * make it yours hidden from writers, signed-out showing nothing to do, a
6 * cross-origin POST refused, and the working-now line only while a run is live.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { newApp } from "../src/actions/apps.ts";
14 import { toolbarJs, toolbarTag } from "../src/apps/toolbar.ts";
15 import { createOrigin, linkRepoToOrigin } from "../src/core/origins.ts";
16 import { createIssue, getIssue, listComments, listIssues } from "../src/core/issues.ts";
17 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
18 import { createSession } from "../src/core/sessions.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24
25 describe("toolbar actions", () => {
26 let dataDir: string;
27 let app: App;
28 let servers: Servers;
29 let alice: User;
30 let bob: User;
31 let repoId: string;
32 let pullId: string;
33 let pullNumber: number;
34 let aliceCookie: string;
35 let bobCookie: string;
36 const PAGE = "https://notes.example.test/a?b=1";
37
38 const get = (path: string, cookie?: string) =>
39 fetch(`${servers.url}${path}`, {
40 headers: cookie ? { cookie } : {},
41 redirect: "manual",
42 });
43 const post = (path: string, cookie: string, fields: Record<string, string>, origin = servers.url) =>
44 fetch(`${servers.url}${path}`, {
45 method: "POST",
46 headers: { cookie, origin, "content-type": "application/x-www-form-urlencoded" },
47 body: new URLSearchParams(fields),
48 redirect: "manual",
49 });
50
51 beforeAll(async () => {
52 dataDir = await mkdtemp(join(tmpdir(), "forge-toolbar-actions-"));
53 app = createApp({ dataDir, port: 0 });
54 servers = await serve(app);
55 const mk = async (username: string, first = false) => {
56 const r = await createUser(app.db, {
57 trusted: true,
58 username,
59 email: `${username}@forge.local`,
60 password: "a-good-passphrase",
61 adminIfFirst: first,
62 });
63 if (!r.ok) throw new Error(r.error);
64 return r.value;
65 };
66 alice = await mk("alice", true);
67 bob = await mk("bob");
68 const made = await newApp(app, alice, { name: "notes", description: "x", visibility: "public" });
69 if (!made.ok) throw new Error(String(made.error));
70 repoId = made.value.repo.id;
71 const pull = createIssue(app.db, { repoId, authorId: alice.id, title: "A change", kind: "pull" });
72 if (!pull.ok) throw new Error(pull.error);
73 pullId = pull.value.id;
74 pullNumber = pull.value.number;
75 aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`;
76 bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
77 }, 60_000);
78
79 afterAll(async () => {
80 servers.stop();
81 app.close();
82 await rm(dataDir, { recursive: true, force: true });
83 });
84
85 const url = (env: string) =>
86 `/alice/notes/toolbar?env=${env}&page=${encodeURIComponent(PAGE)}`;
87
88 test("the bar links to the forge page and never posts or reads the app", () => {
89 expect(toolbarJs).toContain('"/toolbar"');
90 // Its one request is the crew line (#2233): a GET of the app's own origin.
91 expect(toolbarJs.match(/fetch\(/g)?.length).toBe(1);
92 expect(toolbarJs).toContain('fetch("/__forge/crew"');
93 expect(toolbarJs).not.toMatch(/method:|FormData|\.send\(/);
94 expect(toolbarJs).not.toContain("XMLHttpRequest");
95 expect(toolbarJs).not.toContain("document.cookie");
96 expect(toolbarTag("https://forge.test", { app: "notes", repo: "alice/notes", env: "production" })).toContain(
97 'data-repo="alice/notes"',
98 );
99 });
100
101 test("signed out: where you are and home only", async () => {
102 const text = await (await get(url("production"))).text();
103 expect(text).toContain("production");
104 expect(text).toContain("Home");
105 expect(text).not.toContain("Tell the crew");
106 expect(text).not.toContain("Report this page");
107 expect(text).not.toContain("Make it yours");
108 });
109
110 test("an issue number is not a pull: pull-N for a plain issue is not found, page and comment", async () => {
111 const plain = createIssue(app.db, { repoId, authorId: alice.id, title: "Just an issue" });
112 if (!plain.ok) throw new Error(plain.error);
113 const env = `pull-${plain.value.number}`;
114 expect((await get(url(env), aliceCookie)).status).toBe(404);
115 const before = listComments(app.db, plain.value.id).length;
116 const res = await post("/alice/notes/toolbar/comment", aliceCookie, { env, body: "hi", step: "post" });
117 expect(res.status).toBe(404);
118 expect(listComments(app.db, plain.value.id).length).toBe(before);
119 });
120
121 test("a bad env is not found", async () => {
122 expect((await get(url("pull-abc"), aliceCookie)).status).toBe(404);
123 expect((await get(url("pull-9999"), aliceCookie)).status).toBe(404);
124 });
125
126 test("writers do not see make it yours; others do; tell is offered to who may", async () => {
127 const mine = await (await get(url("production"), aliceCookie)).text();
128 expect(mine).not.toContain("make-it-yours");
129 expect(mine).toContain("Tell the crew");
130 const theirs = await (await get(url("production"), bobCookie)).text();
131 expect(theirs).toContain("/alice/notes/make-it-yours");
132 });
133
134 test("working now appears only while a run is live, linked to its page", async () => {
135 expect(await (await get(url("production"), bobCookie)).text()).not.toContain("data-working");
136 const run = createRun(app.db, { repoId, kind: "agent", issueId: pullId, actorId: alice.id, summary: "wiring it" });
137 claimRun(app.db, run.id);
138 const live = await (await get(url("production"), bobCookie)).text();
139 expect(live).toContain("data-working");
140 expect(live).toContain(`/alice/notes/runs/${run.id}`);
141 expect(await (await get(url(`pull-${pullNumber}`), bobCookie)).text()).toContain(`/runs/${run.id}`);
142 finishRun(app.db, run.id, { state: "ok", summary: "done" });
143 expect(await (await get(url("production"), bobCookie)).text()).not.toContain("data-working");
144 });
145
146 test("a preview comment: previewed first, then posted to the pull with a receipt", async () => {
147 const fields = { env: `pull-${pullNumber}`, page: PAGE, body: "Looks <b>off</b>" };
148 const preview = await post("/alice/notes/toolbar/comment", bobCookie, { ...fields, step: "preview" });
149 expect(preview.status).toBe(200);
150 const text = await preview.text();
151 expect(text).toContain("Looks &lt;b&gt;off&lt;/b&gt;");
152 expect(listComments(app.db, pullId).length).toBe(0);
153 const sent = await post("/alice/notes/toolbar/comment", bobCookie, { ...fields, step: "post" });
154 expect(sent.status).toBe(200);
155 expect(await sent.text()).toContain(`/alice/notes/pulls/${pullNumber}#conversation`);
156 const comments = listComments(app.db, pullId);
157 expect(comments.length).toBe(1);
158 expect(comments[0]!.body).toContain("<https://notes.example.test/a>");
159 });
160
161 test("a production comment opens an issue carrying the page address", async () => {
162 const sent = await post("/alice/notes/toolbar/comment", bobCookie, {
163 env: "production",
164 page: PAGE,
165 body: "The header overlaps",
166 step: "post",
167 });
168 expect(sent.status).toBe(200);
169 const link = /\/alice\/notes\/issues\/(\d+)/.exec(await sent.text());
170 expect(link).not.toBeNull();
171 const issue = getIssue(app.db, repoId, Number(link![1]));
172 expect(issue?.title).toBe("The header overlaps");
173 expect(issue?.body).toContain("<https://notes.example.test/a>");
174 });
175
176 test("a query or fragment on the page address never reaches the body", async () => {
177 const sent = await post("/alice/notes/toolbar/comment", bobCookie, {
178 env: "production",
179 page: "https://notes.example.test/a?b=1#t=secret",
180 body: "leaky?",
181 step: "post",
182 });
183 const n = /issues\/(\d+)/.exec(await sent.text())![1]!;
184 const body = getIssue(app.db, repoId, Number(n))!.body;
185 expect(body).toContain("<https://notes.example.test/a>");
186 expect(body).not.toContain("b=1");
187 expect(body).not.toContain("secret");
188 const pr = await post("/alice/notes/toolbar/comment", bobCookie, {
189 env: `pull-${pullNumber}`,
190 page: "https://notes.example.test/a?b=1#t=secret",
191 body: "leaky?",
192 step: "post",
193 });
194 expect(pr.status).toBe(200);
195 for (const c of listComments(app.db, pullId)) {
196 expect(c.body).not.toContain("b=1");
197 expect(c.body).not.toContain("secret");
198 }
199 });
200
201 test("a body that only overflows with the page line is refused as too long", async () => {
202 const res = await post("/alice/notes/toolbar/comment", bobCookie, {
203 env: "production",
204 page: PAGE,
205 body: "x".repeat(65_536),
206 step: "post",
207 });
208 expect(res.status).toBe(422);
209 expect(await res.text()).toContain("too long");
210 });
211
212 test("an address that is not http(s) is dropped, not carried", async () => {
213 const sent = await post("/alice/notes/toolbar/comment", bobCookie, {
214 env: "production",
215 page: "javascript:alert(1)",
216 body: "hello",
217 step: "post",
218 });
219 const n = /issues\/(\d+)/.exec(await sent.text())![1]!;
220 expect(getIssue(app.db, repoId, Number(n))?.body).not.toContain("javascript");
221 });
222
223 test("a cross-origin POST is refused and nothing is made", async () => {
224 const before = listComments(app.db, pullId).length;
225 const res = await post(
226 "/alice/notes/toolbar/comment",
227 bobCookie,
228 { env: `pull-${pullNumber}`, body: "evil", step: "post" },
229 "https://notes.example.test",
230 );
231 expect(res.status).toBe(403);
232 expect(listComments(app.db, pullId).length).toBe(before);
233 });
234
235 test("signed-out comment is refused", async () => {
236 const res = await post("/alice/notes/toolbar/comment", "", { env: "production", body: "hi", step: "post" });
237 expect(res.status).toBe(403);
238 });
239
240 test("the page's tell form posts to the one tell door and the tell lands back on the page", async () => {
241 const text = await (await get(url("production"), aliceCookie)).text();
242 const form = /<form method="post" action="([^"]+\/tell)"[^>]*>([\s\S]*?)<\/form>/.exec(text);
243 expect(form).not.toBeNull();
244 const from = /name="from" value="([^"]+)"/.exec(form![2]!)![1]!.replaceAll("&amp;", "&");
245 expect(form![1]).toBe("/alice/notes/tell");
246 const before = listIssues(app.db, repoId, {}).length;
247 const res = await post(form![1]!, aliceCookie, { text: "Make the header smaller", from });
248 expect(res.status).toBe(303);
249 expect(res.headers.get("location")).toBe(from);
250 expect(from.startsWith("/alice/notes/toolbar?env=production")).toBe(true);
251 const made = listIssues(app.db, repoId, {}).filter((i) => i.title === "Make the header smaller");
252 expect(made.length).toBe(1);
253 expect(listIssues(app.db, repoId, {}).length).toBe(before + 1);
254 expect(made[0]!.body).toContain("Told from");
255 });
256
257 test("a live run on a sealed issue is not shown to a reader who did not file it", async () => {
258 const sealed = createIssue(app.db, {
259 repoId,
260 authorId: alice.id,
261 title: "SEALEDTITLE hole",
262 sealed: true,
263 });
264 if (!sealed.ok) throw new Error(sealed.error);
265 const run = createRun(app.db, {
266 repoId,
267 kind: "agent",
268 issueId: sealed.value.id,
269 actorId: alice.id,
270 summary: "SEALEDSUMMARY fixing the hole",
271 });
272 claimRun(app.db, run.id);
273 const theirs = await (await get(url("production"), bobCookie)).text();
274 expect(theirs).not.toContain("data-working");
275 expect(theirs).not.toContain("SEALEDSUMMARY");
276 expect(theirs).not.toContain(run.id);
277 const mine = await (await get(url("production"), aliceCookie)).text();
278 expect(mine).toContain("SEALEDSUMMARY");
279 finishRun(app.db, run.id, { state: "ok", summary: "done" });
280 });
281
282 test("reporting on a mirrored repository goes through the origin's rules, not around them", async () => {
283 const made = await newApp(app, alice, { name: "mirrored", description: "x", visibility: "public" });
284 if (!made.ok) throw new Error(String(made.error));
285 const origin = createOrigin(app.db, dataDir, {
286 userId: alice.id,
287 kind: "forgejo",
288 apiUrl: "https://origin.example.test/api/v1",
289 webUrl: "https://origin.example.test",
290 login: "alice",
291 token: "t",
292 });
293 if (!origin.ok) throw new Error(origin.error);
294 linkRepoToOrigin(app.db, made.value.repo.id, {
295 originId: origin.value.id,
296 fullName: "alice/mirrored",
297 remoteId: "1",
298 htmlUrl: "https://origin.example.test/alice/mirrored",
299 });
300 // Bob did not connect the origin: write-through refuses him, so no issue
301 // is invented locally.
302 const res = await post("/alice/mirrored/toolbar/comment", bobCookie, {
303 env: "production",
304 body: "hello",
305 step: "post",
306 });
307 expect(res.status).toBe(403);
308 expect(await res.text()).toContain("Only the person who connected this origin");
309 expect(listIssues(app.db, made.value.repo.id, {}).filter((i) => i.title === "hello")).toEqual([]);
310 });
311
312 test("a running run older than the twenty newest is still found", async () => {
313 const old = createRun(app.db, { repoId, kind: "agent", issueId: pullId, actorId: alice.id, summary: "OLDRUNNING" });
314 claimRun(app.db, old.id);
315 for (let i = 0; i < 25; i++) {
316 const r = createRun(app.db, { repoId, kind: "agent", issueId: pullId, actorId: alice.id, summary: `n${i}` });
317 claimRun(app.db, r.id);
318 finishRun(app.db, r.id, { state: "ok", summary: "done" });
319 }
320 const text = await (await get(url("production"), aliceCookie)).text();
321 expect(text).toContain("OLDRUNNING");
322 finishRun(app.db, old.id, { state: "ok", summary: "done" });
323 });
324 });
test/tui-report.test.ts added +98
@@ -0,0 +1,98 @@
1 /**
2 * Report a problem from a repository screen's palette (#810): the terminal's
3 * door to the toolbar's "Report this page" — one text, one issue, titled by
4 * its first line.
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 { createRepo } from "../src/actions/repos.ts";
11 import { reportTitle } from "../src/actions/page-report.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { listIssues } from "../src/core/issues.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { repoScreen } from "../src/tui/screens/repo.ts";
18 import { local } from "../src/tui/screens/where.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp } from "./helpers/tui.ts";
22 import { proveAll } from "./helpers/verified.ts";
23
24 quietLogs();
25
26 let dir: string;
27 let app: App;
28 let alice: User;
29 let bob: User;
30 let repo: Repo;
31
32 async function palette(user: User, keys: string[]): Promise<string> {
33 const terminal = headlessTerminal(120, 40);
34 const { app: tui, session } = createForgeApp(
35 app,
36 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
37 { terminal, exitHooks: false },
38 );
39 tui.start();
40 tui.ctx.push(repoScreen(session, tui.ctx, local(session, repo)));
41 await settleApp(tui);
42 for (const k of keys) {
43 terminal.feed(k);
44 await settleApp(tui);
45 }
46 const text = tui.screen.toText();
47 tui.stop?.();
48 return text;
49 }
50
51 beforeAll(async () => {
52 dir = await mkdtemp(join(tmpdir(), "forge-tui-report-"));
53 app = createApp({ dataDir: dir, port: 0 });
54 const mk = async (username: string, first = false) => {
55 const r = await createUser(app.db, {
56 trusted: true, username, email: `${username}@forge.local`,
57 password: "a-good-passphrase", adminIfFirst: first,
58 });
59 if (!r.ok) throw new Error(r.error);
60 return r.value;
61 };
62 alice = await mk("alice", true);
63 bob = await mk("bob");
64 proveAll(app);
65 const made = await createRepo(app, alice, { name: "lobby", visibility: "public" });
66 if (!made.ok) throw new Error(String(made.error));
67 repo = made.value;
68 });
69 afterAll(async () => {
70 app.db.close();
71 await rm(dir, { recursive: true, force: true });
72 });
73
74 describe("report a problem in the terminal", () => {
75 test("the palette has the row", async () => {
76 expect(await palette(bob, [":"])).toContain("alice/lobby — Report a problem");
77 });
78
79 test("a text opens an issue titled by its first line", async () => {
80 const text = await palette(bob, [":", "report", "\r", ..."the door sticks".split(""), "\r"]);
81 expect(text).toContain("opened");
82 const issue = listIssues(app.db, repo.id, {}).find((i) => i.title === "the door sticks");
83 expect(issue?.body).toBe("the door sticks");
84 });
85
86 test("an empty text says so and opens nothing", async () => {
87 const before = listIssues(app.db, repo.id, {}).length;
88 const text = await palette(bob, [":", "report", "\r", "\r"]);
89 expect(text).toContain("Say something first.");
90 expect(listIssues(app.db, repo.id, {}).length).toBe(before);
91 });
92
93 test("a long first line is cut for the title", () => {
94 const title = reportTitle(`${"x".repeat(200)}\nmore`);
95 expect(title.length).toBe(78);
96 expect(title.endsWith("…")).toBe(true);
97 });
98 });
test/tui-working-yours.test.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * The toolbar page's other two doors in the terminal (#810): "Working now"
3 * while an agent run is live (a sealed issue's run hidden from who may not
4 * see it), and "Make it yours" for whoever cannot write the repository.
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 { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import { createIssue } from "../src/core/issues.ts";
14 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { repoScreen } from "../src/tui/screens/repo.ts";
18 import { local } from "../src/tui/screens/where.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp } from "./helpers/tui.ts";
22 import { proveAll } from "./helpers/verified.ts";
23
24 quietLogs();
25
26 let dir: string;
27 let app: App;
28 let alice: User;
29 let bob: User;
30 let repo: Repo;
31
32 async function palette(user: User, keys: string[] = [":"]): Promise<string> {
33 const terminal = headlessTerminal(120, 40);
34 const { app: tui, session } = createForgeApp(
35 app,
36 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
37 { terminal, exitHooks: false },
38 );
39 tui.start();
40 tui.ctx.push(repoScreen(session, tui.ctx, local(session, repo)));
41 await settleApp(tui);
42 for (const k of keys) {
43 terminal.feed(k);
44 await settleApp(tui);
45 }
46 const text = tui.screen.toText();
47 tui.stop?.();
48 return text;
49 }
50
51 beforeAll(async () => {
52 dir = await mkdtemp(join(tmpdir(), "forge-tui-working-"));
53 app = createApp({ dataDir: dir, port: 0 });
54 const mk = async (username: string, first = false) => {
55 const r = await createUser(app.db, {
56 trusted: true, username, email: `${username}@forge.local`,
57 password: "a-good-passphrase", adminIfFirst: first,
58 });
59 if (!r.ok) throw new Error(r.error);
60 return r.value;
61 };
62 alice = await mk("alice", true);
63 bob = await mk("bob");
64 proveAll(app);
65 const made = await createRepo(app, alice, { name: "lobby", visibility: "public" });
66 if (!made.ok) throw new Error(String(made.error));
67 repo = made.value;
68 });
69 afterAll(async () => {
70 app.db.close();
71 await rm(dir, { recursive: true, force: true });
72 });
73
74 describe("working now in the terminal", () => {
75 test("shown while a run is live, gone once it finishes", async () => {
76 expect(await palette(bob, [":", "Working"])).not.toContain("Working now");
77 const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: alice.id, summary: "LIVEJOB sanding the door" });
78 claimRun(app.db, run.id);
79 expect(await palette(bob, [":", "Working"])).toContain("Working now: LIVEJOB sanding the door");
80 finishRun(app.db, run.id, { state: "ok", summary: "done" });
81 expect(await palette(bob, [":", "Working"])).not.toContain("Working now");
82 });
83
84 test("a run on a sealed issue is hidden from bob and shown to its filer", async () => {
85 const sealed = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "hole", sealed: true });
86 if (!sealed.ok) throw new Error(sealed.error);
87 const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: sealed.value.id, actorId: alice.id, summary: "SEALEDJOB" });
88 claimRun(app.db, run.id);
89 expect(await palette(bob, [":", "Working"])).not.toContain("SEALEDJOB");
90 expect(await palette(alice, [":", "Working"])).toContain("SEALEDJOB");
91 finishRun(app.db, run.id, { state: "ok", summary: "done" });
92 });
93 });
94
95 describe("make it yours in the terminal", () => {
96 test("a writer does not see it; a reader who cannot write does", async () => {
97 expect(await palette(alice, [":", "Make it"])).not.toContain("Make it yours");
98 expect(await palette(bob, [":", "Make it"])).toContain("alice/lobby — Make it yours");
99 });
100
101 test("it opens the fork screen", async () => {
102 const text = await palette(bob, [":", "make it", "\r"]);
103 expect(text).toContain("Fork");
104 });
105 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs