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

Act on a flow line where it stands: update a dependency, follow or stop, pull a change in, send it back #9619

Merged opened this

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

Merged

scout/forge:scout/9591-act-on-a-flow-line-where-it-stands-updat → main 3 commits fork

  • Merged. scout/9591-act-on-a-flow-line-where-it-stands-updat was merged into main as acd0b35.

Review

Sign in to review this change.
10 files changed +340 −19
docs/features/flows-actions.md added +16
@@ -0,0 +1,16 @@
1 # Flow line actions (#9591)
2
3 Each line on `/:owner/:name/flows` carries its one action. The terminal's Flows screen (palette: "Flows of a repository") offers the same, decided by the same reads (`readFlows`), so a person and the page never disagree.
4
5 | Line | Action | Who | Page | Terminal |
6 | --- | --- | --- | --- | --- |
7 | Followed upstream | Stop following / Follow again | repo admin | POST `/settings/upstream` | `s` |
8 | Followed source | Stop following | repo admin | POST `/settings/sources/:id/remove` | `s` |
9 | Mirror behind its origin | Pull in what's new | writer | POST `/sync/take` | `p` |
10 | Fork of another repo | Offer what's useful | writer | link to `/pulls/new` | `o` |
11 | Fork of this repo | Follow mode | the fork's admin | link to the fork's `/following` | `o` |
12 | Pinned package with a newer stable release | Update this dependency | writer (not agents) | POST `/flows/update` | `u` |
13
14 - Every POST is same-origin and goes through `readForm`; the follow routes honour a `back` field only when it is a local path (`isLocalPath`), so the action returns you to Flows.
15 - Update this dependency files the radar's own issue (`src/flows/file.ts`, `askUpdate`), once per package. On an autonomous repository the issue is assigned to the crew's builder, who proposes the pull; otherwise it is assigned to the owner.
16 - Live: the terminal redraws on `issue.opened`, `repo.links_probed` and `repo.upstream_checked`. The web page is server-rendered and shows the new state on the next load.
src/flows/act.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * The follow actions of a flow line, for a door that has no form (the
3 * terminal): the same two functions the settings routes call, so the same
4 * admin check decides. Plain data in (a line the view produced), a verdict out.
5 */
6
7 import type { App } from "../app.ts";
8 import { removeFollowSource } from "../actions/follow_sources.ts";
9 import { setFollowUpstream } from "../actions/upstream_follow.ts";
10 import type { Repo } from "../core/repos.ts";
11 import type { User } from "../core/users.ts";
12 import { getFollow } from "../core/upstream_follow.ts";
13 import type { FlowLine } from "./view.ts";
14
15 export type Acted = { ok: true; message: string } | { ok: false; why: string };
16
17 /** Stop (or resume) following the line's source; refused for anyone the page would not offer it to. */
18 export function actOnFollow(app: App, repo: Repo, user: User | undefined, line: FlowLine): Acted {
19 if (user === undefined || user.kind === "agent") return { ok: false, why: "Only an admin of this repository can do this." };
20 if (line.kind === "source" && line.id !== undefined) {
21 const gone = removeFollowSource(app, user, repo, line.id);
22 return gone.ok ? { ok: true, message: "No longer following that source." } : { ok: false, why: "Only an admin of this repository can do this." };
23 }
24 if (line.kind === "upstream") {
25 const follow = getFollow(app.db, repo.id);
26 if (follow === undefined) return { ok: false, why: "Nothing is followed." };
27 const saved = setFollowUpstream(app, user, repo, { url: follow.url, branch: follow.branch, enabled: !follow.enabled });
28 return saved.ok ? { ok: true, message: saved.value.enabled ? "Following the upstream again." : "Following the upstream is off." } : { ok: false, why: "Only an admin of this repository can do this." };
29 }
30 return { ok: false, why: "That line has nothing to follow." };
31 }
src/flows/file.ts +17 −1
@@ -11,7 +11,8 @@ import { assignIssue } from "../actions/issues.ts";
1111 import { crewWithRole } from "../core/agents.ts";
1212 import { createIssue, type Issue } from "../core/issues.ts";
1313 import { getUserById } from "../core/users.ts";
14 import type { Repo } from "../core/repos.ts";
14 import { canWrite, type Repo } from "../core/repos.ts";
15 import type { User } from "../core/users.ts";
1516 import type { Gap } from "./semver.ts";
1617 import { parseSemver } from "./semver.ts";
1718 import type { DepLine } from "./radar.ts";
@@ -100,3 +101,18 @@ export function fileUpdates(app: App, repo: Repo, lines: readonly DepLine[]): nu
100101 }
101102 return filed;
102103 }
104
105 export type Asked = { ok: true; filed: number | null } | { ok: false; why: string };
106
107 /**
108 * "Update this dependency" for one named package: a writer (never an agent)
109 * files the same issue the radar would, routed the same way, so an autonomous
110 * repository's crew proposes the pull. `filed` is null when nothing is worth
111 * taking or one is already open.
112 */
113 export function askUpdate(app: App, repo: Repo, user: User | undefined, lines: readonly DepLine[], name: string): Asked {
114 if (user === undefined || user.kind === "agent" || !canWrite(app.db, repo, user)) return { ok: false, why: "Only a writer of this repository can do this." };
115 const line = lines.find((l) => l.name === name);
116 if (line === undefined) return { ok: false, why: "That package is not pinned here." };
117 return { ok: true, filed: fileUpdates(app, repo, [line])[0] ?? null };
118 }
src/flows/view.ts +25 −4
@@ -8,7 +8,7 @@
88 import type { Database } from "bun:sqlite";
99 import { getCursor } from "../core/origins.ts";
1010 import { forkFollowMode } from "../core/fork_follow.ts";
11 import { canRead, canWrite, getRepoById, repoDatesFor, visibleForks, type Repo } from "../core/repos.ts";
11 import { canAdmin, canRead, canWrite, getRepoById, repoDatesFor, visibleForks, type Repo } from "../core/repos.ts";
1212 import type { User } from "../core/users.ts";
1313 import { aheadWords, parseAhead } from "../origins/ahead.ts";
1414 import { counts, lastScanAt } from "../checks/scan_store.ts";
@@ -24,6 +24,8 @@ export interface FlowAction {
2424 readonly label: string;
2525 readonly href: string;
2626 readonly post: boolean;
27 /** Hidden fields the POST carries (a form needs them to reach an existing action). */
28 readonly fields?: Readonly<Record<string, string>>;
2729 }
2830
2931 export interface FlowLine {
@@ -36,6 +38,8 @@ export interface FlowLine {
3638 readonly at: string | null;
3739 readonly problem: boolean;
3840 readonly health: Health;
41 /** The followed source's id, for the line that stops it. */
42 readonly id?: string;
3943 readonly action?: FlowAction;
4044 }
4145
@@ -79,6 +83,7 @@ export function followHealth(
7983
8084 export function readFlows(db: Database, repo: Repo, viewer: User | undefined, now = Date.now()): FlowView {
8185 const writer = viewer !== undefined && viewer.kind !== "agent" && canWrite(db, repo, viewer);
86 const admin = viewer !== undefined && viewer.kind !== "agent" && canAdmin(db, repo, viewer);
8287 const inbound: FlowLine[] = [];
8388 const outbound: FlowLine[] = [];
8489 if (repo.forkParentId !== null) {
@@ -100,17 +105,30 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined, no
100105 kind: "upstream", label: `${plainUrl(up.url)} (${up.branch})`, href: "", at: up.checked_at, problem: up.error !== "",
101106 health: followHealth({ enabled: up.enabled !== 0, checkedAt: up.checked_at, behind: up.behind, error: up.error }, now),
102107 state: up.enabled === 0 ? "not following" : behindWords(up.behind, up.error),
108 ...(admin
109 ? {
110 action: {
111 label: up.enabled === 0 ? "Follow again" : "Stop following",
112 href: `${base(repo)}/settings/upstream`,
113 post: true,
114 fields: { url: up.url, branch: up.branch, ...(up.enabled === 0 ? { enabled: "on" } : {}), back: `${base(repo)}/flows` },
115 },
116 }
117 : {}),
103118 });
104119 }
105120 for (const s of db
106 .query<{ url: string; branch: string; enabled: number; checked_at: string | null; behind: number; error: string }, [string]>(
107 "SELECT url, branch, enabled, checked_at, behind, error FROM repo_source WHERE repo_id = ?1 ORDER BY created_at",
121 .query<{ id: string; url: string; branch: string; enabled: number; checked_at: string | null; behind: number; error: string }, [string]>(
122 "SELECT id, url, branch, enabled, checked_at, behind, error FROM repo_source WHERE repo_id = ?1 ORDER BY created_at",
108123 )
109124 .all(repo.id)) {
110125 inbound.push({
111 kind: "source", label: `${plainUrl(s.url)} (${s.branch})`, href: "", at: s.checked_at, problem: s.error !== "",
126 kind: "source", id: s.id, label: `${plainUrl(s.url)} (${s.branch})`, href: "", at: s.checked_at, problem: s.error !== "",
112127 health: followHealth({ enabled: s.enabled !== 0, checkedAt: s.checked_at, behind: s.behind, error: s.error }, now),
113128 state: s.enabled === 0 ? "not following" : behindWords(s.behind, s.error),
129 ...(admin
130 ? { action: { label: "Stop following", href: `${base(repo)}/settings/sources/${s.id}/remove`, post: true, fields: { back: `${base(repo)}/flows` } } }
131 : {}),
114132 });
115133 }
116134 if (repo.origin !== null) {
@@ -133,6 +151,9 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined, no
133151 outbound.push({
134152 kind: "fork", label: `${f.ownerName}/${f.name}`, href: base(f), at: d?.pushedAt ?? d?.createdAt ?? null, problem: false, health: "none",
135153 state: mode === "take-updates" ? "fork, takes updates" : "fork, told of updates",
154 ...(viewer !== undefined && viewer.kind !== "agent" && canAdmin(db, f, viewer)
155 ? { action: { label: "Follow mode", href: `${base(f)}/following`, post: false } }
156 : {}),
136157 });
137158 }
138159 const mirror = db
src/http/server.ts +2 −1
@@ -152,7 +152,7 @@ import { reconnectSubmit } from "../web/pages/frozen.ts";
152152 import { activityAtom, changelogAtom } from "../web/feeds.ts";
153153 import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts";
154154 import { apiPage, appApiPage } from "../web/pages/api.ts";
155 import { flowsCheck, flowsPage, flowsProbe } from "../web/pages/flows.ts";
155 import { flowsCheck, flowsPage, flowsProbe, flowsUpdate } from "../web/pages/flows.ts";
156156 import { findingAccept, findingsPage } from "../web/pages/findings.ts";
157157 import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts";
158158 import { changelogJson, changelogPage } from "../web/pages/changelog.ts";
@@ -1154,6 +1154,7 @@ export function buildRouter(): Router<Handler> {
11541154 router.add("GET", "/:owner/:name/flows", flowsPage);
11551155 router.add("POST", "/:owner/:name/flows/check", flowsCheck);
11561156 router.add("POST", "/:owner/:name/flows/probe", flowsProbe);
1157 router.add("POST", "/:owner/:name/flows/update", flowsUpdate);
11571158 router.add("GET", "/:owner/:name/findings", findingsPage);
11581159 router.add("POST", "/:owner/:name/findings/:id/accept", findingAccept);
11591160 router.add("GET", "/:owner/:name/bets", betsPage);
src/tui/screens/flows.ts +34 −6
@@ -11,6 +11,8 @@ import { depState, readRadar, type DepLine } from "../../flows/radar.ts";
1111 import { pinsOf } from "../../flows/run.ts";
1212 import { probeLinks } from "../../flows/probe.ts";
1313 import { mayStartRadar, startRadar } from "../../flows/start.ts";
14 import { actOnFollow } from "../../flows/act.ts";
15 import { askUpdate, worthTaking } from "../../flows/file.ts";
1416 import { readFlows, type FlowLine, type Health } from "../../flows/view.ts";
1517 import type { Ctx, Screen } from "../app/app.ts";
1618 import { binding } from "../app/keys.ts";
@@ -37,21 +39,29 @@ export function flowsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
3739 const state = new ListState();
3840 let deps: DepLine[] = [];
3941 const visible = (): boolean => canRead(f.db, repo, f.user);
40 const rows = (): ListRow[] => {
42 /** Rows in draw order, each with the line or package it stands for. */
43 const items = (): { row: ListRow; line?: FlowLine; dep?: DepLine }[] => {
4144 if (!visible()) return [];
4245 const view = readFlows(f.db, repo, f.user);
43 return [...view.inbound.map(flowRow("in ")), ...deps.map(depRow), ...view.outbound.map(flowRow("out"))];
46 return [
47 ...view.inbound.map((line) => ({ row: flowRow("in ")(line), line })),
48 ...deps.map((dep) => ({ row: depRow(dep), dep })),
49 ...view.outbound.map((line) => ({ row: flowRow("out")(line), line })),
50 ];
4451 };
52 const rows = (): ListRow[] => items().map((i) => i.row);
53 const selected = () => items()[state.index];
4554 /** The actions the page would offer this person: the same reads decide. */
46 const actions = () => (visible() ? readFlows(f.db, repo, f.user).inbound.flatMap((l) => (l.action ? [l.action] : [])) : []);
55 const actions = () => (visible() ? readFlows(f.db, repo, f.user).inbound.flatMap((l) => (l.action && l.kind === "origin" ? [l.action] : [])) : []);
56 const mayFollow = () => (visible() ? readFlows(f.db, repo, f.user).inbound.some((l) => l.action?.post && (l.kind === "source" || l.kind === "upstream")) : false);
4757 const load = async (signal?: AbortSignal): Promise<void> => {
48 if (signal) live(f, signal, { repoId: repo.id, kinds: ["repo.links_probed", "repo.upstream_checked"] }, () => ctx.invalidate());
58 if (signal) live(f, signal, { repoId: repo.id, kinds: ["repo.links_probed", "repo.upstream_checked", "issue.opened"] }, () => ctx.invalidate());
4959 deps = visible() ? readRadar(f.db, repo.id, await pinsOf(f.app, repo)) : [];
5060 ctx.invalidate();
5161 };
5262 return {
5363 title: "Flows",
54 keys: [binding(["j", "k"], "move cursor"), ...(mayStartRadar(f.app, repo, f.user) ? [binding(["r"], "read upstream now"), binding(["c"], "compare heads now")] : []), ...(actions().some((a) => a.post) ? [binding(["p"], "pull in what's new")] : []), ...(actions().some((a) => !a.post) ? [binding(["o"], "offer what's useful")] : []), binding(["esc"], "back")],
64 keys: [binding(["j", "k"], "move cursor"), ...(mayStartRadar(f.app, repo, f.user) ? [binding(["r"], "read upstream now"), binding(["c"], "compare heads now")] : []), ...(actions().some((a) => a.post) ? [binding(["p"], "pull in what's new")] : []), ...(actions().some((a) => !a.post) ? [binding(["o"], "offer what's useful")] : []), ...(mayFollow() ? [binding(["s"], "stop or resume following")] : []), ...(f.user !== undefined && f.user.kind !== "agent" ? [binding(["u"], "update this dependency")] : []), binding(["esc"], "back")],
5565 load,
5666 render(area, s) {
5767 drawList(s, area, ctx.theme.c, state, rows());
@@ -78,8 +88,26 @@ export function flowsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7888 });
7989 return true;
8090 }
91 if (e.key === "s") {
92 const line = selected()?.line;
93 if (!line?.action?.post || (line.kind !== "source" && line.kind !== "upstream")) return false;
94 const done = actOnFollow(f.app, repo, f.user, line);
95 ctx.toast(done.ok ? done.message : done.why, done.ok ? undefined : "danger");
96 ctx.invalidate();
97 return true;
98 }
99 if (e.key === "u") {
100 const dep = selected()?.dep;
101 if (dep === undefined || !worthTaking(dep)) return false;
102 const asked = askUpdate(f.app, repo, f.user, deps, dep.name);
103 if (!asked.ok) ctx.toast(asked.why, "danger");
104 else ctx.toast(asked.filed === null ? "An update is already open." : `Filed issue #${asked.filed}.`);
105 ctx.invalidate();
106 return true;
107 }
81108 if (e.key === "o") {
82 const offer = actions().find((a) => !a.post);
109 const own = selected()?.line?.action;
110 const offer = own && !own.post ? own : actions().find((a) => !a.post);
83111 if (!offer) return false;
84112 ctx.toast(`Open ${offer.href} on the web to offer it.`);
85113 return true;
src/web/pages/flows.ts +24 −5
@@ -8,6 +8,8 @@
88 */
99
1010 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
11 import { canWrite } from "../../core/repos.ts";
12 import { askUpdate, worthTaking } from "../../flows/file.ts";
1113 import { depState, readRadar } from "../../flows/radar.ts";
1214 import { pinsOf } from "../../flows/run.ts";
1315 import { probeLinks } from "../../flows/probe.ts";
@@ -15,13 +17,14 @@ import { mayStartRadar, startRadar } from "../../flows/start.ts";
1517 import { readFlows, type FlowAction, type FlowLine, type Health } from "../../flows/view.ts";
1618 import type { Ctx } from "../../http/context.ts";
1719 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette } from "../chrome.ts";
18 import { readForm } from "../form.ts";
20 import { field, readForm } from "../form.ts";
1921 import { html, type Html } from "../html.ts";
2022 import { flashTo, page } from "../layout.ts";
2123 import { badge, btn, type Tone, card, listCard, row, time } from "../ui.ts";
2224
2325 const lineRow = (l: FlowLine): Html =>
2426 row({
27 ...(l.action ? { after: actionForm(l.action) } : {}),
2528 ...(l.href === "" ? {} : { href: l.href }),
2629 title: html`${l.label}`,
2730 meta: html`${l.at === null ? "never moved" : time(l.at)}`,
@@ -35,8 +38,8 @@ const TONE: Record<Health, Tone> = {
3538
3639 const actionForm = (a: FlowAction): Html =>
3740 a.post
38 ? html`<form method="post" action="${a.href}" class="mt">${btn(a.label, { variant: "secondary" })}</form>`
39 : html`<p class="mt">${btn(a.label, { variant: "secondary", href: a.href })}</p>`;
41 ? html`<form method="post" action="${a.href}" class="inline">${Object.entries(a.fields ?? {}).map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}" />`)}${btn(a.label, { size: "sm", variant: "secondary" })}</form>`
42 : html`${btn(a.label, { size: "sm", variant: "secondary", href: a.href })}`;
4043
4144 function side(title: string, lines: readonly Html[], empty: string): Html {
4245 return card(lines.length === 0 ? html`<p class="muted">${empty}</p>` : listCard([...lines]), { head: html`${title}`, pad: lines.length === 0 });
@@ -51,12 +54,15 @@ export async function flowsPage(ctx: Ctx): Promise<Response> {
5154 const flows = readFlows(ctx.app.db, repo, ctx.auth.user);
5255 const deps = readRadar(ctx.app.db, repo.id, await pinsOf(ctx.app, repo));
5356 const admin = mayStartRadar(ctx.app, repo, ctx.auth.user);
54 const actions = flows.inbound.flatMap((l) => (l.action ? [actionForm(l.action)] : []));
57 const writer = ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canWrite(ctx.app.db, repo, ctx.auth.user);
5558 const depRows = deps.map((d) =>
5659 row({
5760 title: html`${d.name}`,
5861 meta: html`${d.pinned === "" ? d.range : `pinned ${d.pinned}`}${d.latest === "" ? "" : ` · newest ${d.latest}`}${d.checkedAt === null ? "" : html` · read ${time(d.checkedAt)}`}${d.advisory === "" ? "" : html` · advisory: ${d.advisory}`}`,
5962 trail: badge(depState(d).label, { tone: depState(d).tone }),
63 ...(writer && worthTaking(d)
64 ? { after: actionForm({ label: "Update this dependency", href: `${repoBase(repo)}/flows/update`, post: true, fields: { name: d.name } }) }
65 : {}),
6066 }),
6167 );
6268 const body = html`${repoHeader(ctx, repo, "flows")}
@@ -64,7 +70,6 @@ export async function flowsPage(ctx: Ctx): Promise<Response> {
6470 <div class="split">
6571 <div>
6672 ${side("Flows in", flows.inbound.map(lineRow), "Nothing flows in: no origin and no followed source.")}
67 ${actions}
6873 ${side("Packages", depRows, "No pinned packages.")}
6974 </div>
7075 <div>${side("Flows out", flows.outbound.map(lineRow), "Nothing flows out: no forks, no mirror and no forge follows.")}</div>
@@ -103,3 +108,17 @@ export async function flowsProbe(ctx: Ctx): Promise<Response> {
103108 const n = probed.read;
104109 return flashTo(ctx, back, { message: n === 0 ? "No link could be compared." : `Compared ${n} ${n === 1 ? "link" : "links"}.` });
105110 }
111
112 // POST /:owner/:name/flows/update
113 export async function flowsUpdate(ctx: Ctx): Promise<Response> {
114 const repo = loadRepo(ctx);
115 if (!repo) return notFoundPage(ctx);
116 const form = await readForm(ctx, "rw");
117 if (form instanceof Response) return form;
118 const back = `${repoBase(repo)}/flows`;
119 const asked = askUpdate(ctx.app, repo, ctx.auth.user, readRadar(ctx.app.db, repo.id, await pinsOf(ctx.app, repo)), field(form, "name"));
120 if (!asked.ok) return flashTo(ctx, back, { tone: "danger", message: asked.why });
121 if (asked.filed === null) return flashTo(ctx, back, { message: "Nothing to file: an update is already open, or none is worth taking." });
122 const where = `${repoBase(repo)}/issues/${asked.filed}`;
123 return flashTo(ctx, where, { message: repo.autonomy === "autonomous" ? "Filed for the crew, who will propose a pull." : "Filed as an issue." });
124 }
src/web/pages/upstream-follow.ts +9 −2
@@ -36,9 +36,16 @@ import { loadRepo, repoBase, repoNotFound } from "../chrome.ts";
3636 import { field, readForm } from "../form.ts";
3737 import { html, when, type Html } from "../html.ts";
3838 import { icon } from "../icons.ts";
39 import { isLocalPath } from "../flash.ts";
3940 import { flashTo } from "../layout.ts";
4041 import { btn, card, field as formField, row, timeAgo } from "../ui.ts";
4142
43 /** Where to land: a validated same-origin path the form names as `back`, else the settings card. */
44 function backTo(form: Awaited<ReturnType<typeof readForm>> & object, fallback: string): string {
45 const back = (form as FormData).get("back");
46 return typeof back === "string" && isLocalPath(back) ? back : fallback;
47 }
48
4249 /** HH:MM in UTC, with the instant for machines and the title for people. */
4350 function clock(iso: string): Html {
4451 const at = new Date(iso);
@@ -183,7 +190,7 @@ export async function followUpstreamUpdate(ctx: Ctx): Promise<Response> {
183190 });
184191 return flashTo(
185192 ctx,
186 `${repoBase(repo)}/settings#upstream`,
193 backTo(form, `${repoBase(repo)}/settings#upstream`),
187194 saved.ok
188195 ? {
189196 message: saved.value.enabled
@@ -298,7 +305,7 @@ export async function followSourceRemove(ctx: Ctx): Promise<Response> {
298305 const gone = removeFollowSource(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "");
299306 return flashTo(
300307 ctx,
301 `${repoBase(repo)}/settings#sources`,
308 backTo(form, `${repoBase(repo)}/settings#sources`),
302309 gone.ok
303310 ? { message: `No longer following ${upstreamShort(gone.value.url)}.` }
304311 : { tone: "danger", message: SOURCE_REFUSALS.not_found },
test/flows-actions.test.ts added +161
@@ -0,0 +1,161 @@
1 /** Acting on a flow line where it stands (#9591): follow or stop, update a dependency; writer yes, reader and cross-origin no. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { createHmac } from "node:crypto";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { getRepo } from "../src/core/repos.ts";
9 import { getUserById, type User } from "../src/core/users.ts";
10 import { askUpdate } from "../src/flows/file.ts";
11 import { commitFiles } from "../src/git/write.ts";
12 import { repoDir } from "../src/git/repos.ts";
13 import { readPins } from "../src/flows/pins.ts";
14 import { readRadar, refreshRadar } from "../src/flows/radar.ts";
15 import { readFlows } from "../src/flows/view.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { PREVIEW_ADMIN, PREVIEW_REPO } from "../src/spawn/preview-looks.ts";
18 import { seedPreview } from "../src/spawn/preview-seed.ts";
19
20 let dir: string;
21 let app: App;
22 let servers: Servers;
23 const KEY = "flows-actions-key";
24 beforeAll(async () => {
25 dir = await mkdtemp(join(tmpdir(), "forge-flows-actions-"));
26 app = createApp({ dataDir: dir, port: 0, previewIdentityKey: KEY });
27 const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" });
28 if (!seeded.ok) throw new Error(seeded.error);
29 servers = await serve(app);
30 }, 30_000);
31 afterAll(async () => {
32 servers.stop();
33 app.close();
34 await rm(dir, { recursive: true, force: true });
35 });
36
37 const repoNow = () => getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!;
38 const admin = () => getUserById(app.db, app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?").get(PREVIEW_ADMIN)!.id)!;
39 const stranger = () => ({ ...admin(), id: "nobody", isAdmin: false }) as User;
40 const headers = (id = "u1", name = "trevato", role = "admin") => {
41 const said = `${id}|${name}|${role}|${Date.now() + 60_000}`;
42 return { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") };
43 };
44 const issues = () => app.db.query<{ title: string }, [string]>("SELECT title FROM issue WHERE repo_id = ?1 AND title LIKE 'Update zed to %'").all(repoNow().id);
45
46 describe("follow or stop, on the line", () => {
47 test("an admin's source and upstream lines carry one action; a reader's carry none", () => {
48 const repo = repoNow();
49 app.db.query("INSERT INTO repo_source (id, repo_id, url, branch, by_id, created_at) VALUES ('sa', ?1, 'https://example.com/a.git', 'main', 'u1', '2026-11-01T00:00:00Z')").run(repo.id);
50 app.db.query("INSERT OR REPLACE INTO repo_upstream (repo_id, url, branch, enabled, by_id, updated_at) VALUES (?1, 'https://example.net/u.git', 'main', 1, 'u1', '2026-11-02T00:00:00Z')").run(repo.id);
51 const mine = readFlows(app.db, repo, admin()).inbound;
52 expect(mine.find((l) => l.kind === "source")?.action).toEqual({ label: "Stop following", href: `/${PREVIEW_ADMIN}/${PREVIEW_REPO}/settings/sources/sa/remove`, post: true, fields: { back: `/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows` } });
53 const up = mine.find((l) => l.kind === "upstream")!.action!;
54 expect([up.label, up.href, up.fields?.enabled]).toEqual(["Stop following", `/${PREVIEW_ADMIN}/${PREVIEW_REPO}/settings/upstream`, undefined]);
55 for (const who of [undefined, stranger()]) {
56 const lines = readFlows(app.db, repo, who).inbound;
57 expect(lines.find((l) => l.kind === "source")?.action).toBeUndefined();
58 expect(lines.find((l) => l.kind === "upstream")?.action).toBeUndefined();
59 }
60 app.db.query("UPDATE repo_upstream SET enabled = 0 WHERE repo_id = ?1").run(repo.id);
61 const off = readFlows(app.db, repo, admin()).inbound.find((l) => l.kind === "upstream")!.action!;
62 expect([off.label, off.fields?.enabled]).toEqual(["Follow again", "on"]);
63 });
64
65 test("the action posts through the existing route: admin stops it, cross-origin and anonymous do not", async () => {
66 const url = `${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/settings/sources/sa/remove`;
67 const count = () => app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM repo_source WHERE id = 'sa'").get()!.n;
68 const cross = await fetch(url, { method: "POST", redirect: "manual", headers: { ...headers(), origin: "https://evil.example" } });
69 expect(cross.status).toBe(403);
70 const anon = await fetch(url, { method: "POST", redirect: "manual", headers: { origin: servers.url } });
71 expect([303, 200]).not.toContain(anon.status);
72 expect(count()).toBe(1);
73 const reader = await fetch(url, { method: "POST", redirect: "manual", headers: { ...headers("u7", "reader", "user"), origin: servers.url } });
74 expect([303, 200]).not.toContain(reader.status);
75 expect(count()).toBe(1);
76 const ok = await fetch(url, { method: "POST", redirect: "manual", body: new URLSearchParams({ back: `/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows` }), headers: { ...headers(), origin: servers.url } });
77 expect(ok.status).toBe(303);
78 expect(ok.headers.get("location")).toContain(`/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows`);
79 expect(count()).toBe(0);
80 });
81
82 test("a back that leaves the site is ignored", async () => {
83 const repo = repoNow();
84 app.db.query("INSERT INTO repo_source (id, repo_id, url, branch, by_id, created_at) VALUES ('sb', ?1, 'https://example.com/b.git', 'main', 'u1', '2026-11-01T00:00:00Z')").run(repo.id);
85 const res = await fetch(`${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/settings/sources/sb/remove`, { method: "POST", redirect: "manual", body: new URLSearchParams({ back: "//evil.example/x" }), headers: { ...headers(), origin: servers.url } });
86 expect(res.status).toBe(303);
87 expect(res.headers.get("location")).toContain("/settings");
88 expect(res.headers.get("location")).not.toContain("evil");
89 });
90
91 test("the settings upstream route refuses a reader and keeps the follow", async () => {
92 const url = `${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/settings/upstream`;
93 const was = app.db.query("SELECT enabled FROM repo_upstream").all();
94 const reader = await fetch(url, { method: "POST", redirect: "manual", body: new URLSearchParams({ url: "https://example.net/u.git", branch: "main" }), headers: { ...headers("u7", "reader", "user"), origin: servers.url } });
95 expect([303, 200]).not.toContain(reader.status);
96 expect(app.db.query("SELECT enabled FROM repo_upstream").all()).toEqual(was);
97 });
98 });
99
100 describe("update this dependency", () => {
101 const lines = async () => {
102 const repo = repoNow();
103 const pins = readPins(JSON.stringify({ dependencies: { zed: "1.0.0" } }));
104 await refreshRadar(app.db, repo.id, pins, async () => ({ latest: "1.2.0" }));
105 return readRadar(app.db, repo.id, pins);
106 };
107
108 test("a reader, an agent and an unknown package file nothing", async () => {
109 const l = await lines();
110 const agent = { ...admin(), kind: "agent" } as User;
111 for (const who of [undefined, stranger(), agent]) expect(askUpdate(app, repoNow(), who, l, "zed")).toEqual({ ok: false, why: "Only a writer of this repository can do this." });
112 expect(askUpdate(app, repoNow(), admin(), l, "nope")).toEqual({ ok: false, why: "That package is not pinned here." });
113 expect(issues()).toEqual([]);
114 });
115
116 test("a writer files one issue, and a second ask files none", async () => {
117 const l = await lines();
118 const first = askUpdate(app, repoNow(), admin(), l, "zed");
119 expect(first.ok && first.filed).toBeGreaterThan(0);
120 expect(issues()).toEqual([{ title: "Update zed to 1.2.0" }]);
121 expect(askUpdate(app, repoNow(), admin(), l, "zed")).toEqual({ ok: true, filed: null });
122 expect(issues().length).toBe(1);
123 });
124
125 test("the page shows Update this dependency to a writer, not to a reader; the route is same-origin only and refuses a stranger", async () => {
126 const repo = repoNow();
127 const pkg = JSON.stringify({ dependencies: { zed: "1.0.0" } });
128 const made = await commitFiles(repoDir(app.reposRoot, PREVIEW_ADMIN, PREVIEW_REPO), { branch: repo.defaultBranch, message: "pins", files: [{ path: "package.json", content: pkg }], author: { name: "T", email: "[email protected]" } });
129 if (!made.ok) throw new Error(JSON.stringify(made.error));
130 await refreshRadar(app.db, repo.id, readPins(pkg), async () => ({ latest: "1.2.0" }));
131 app.db.query("DELETE FROM issue WHERE repo_id = ?1 AND title LIKE 'Update zed to %'").run(repo.id);
132 const flows = `${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows`;
133 expect(await (await fetch(flows, { headers: headers() })).text()).toContain("Update this dependency");
134 expect(await (await fetch(flows, { headers: headers("u7", "reader", "user") })).text()).not.toContain("Update this dependency");
135 expect(await (await fetch(flows)).text()).not.toContain("Update this dependency");
136 const url = `${flows}/update`;
137 const body = () => new URLSearchParams({ name: "zed" });
138 const cross = await fetch(url, { method: "POST", body: body(), redirect: "manual", headers: { ...headers(), origin: "https://evil.example" } });
139 expect(cross.status).toBe(403);
140 expect(issues()).toEqual([]);
141 const reader = await fetch(url, { method: "POST", body: body(), redirect: "manual", headers: { ...headers("u7", "reader", "user"), origin: servers.url } });
142 expect(reader.headers.get("location") ?? "").not.toContain("/issues/");
143 const anon = await fetch(url, { method: "POST", body: body(), redirect: "manual", headers: { origin: servers.url } });
144 expect(anon.headers.get("location") ?? "").not.toContain("/issues/");
145 expect(issues()).toEqual([]);
146 const ok = await fetch(url, { method: "POST", body: body(), redirect: "manual", headers: { ...headers(), origin: servers.url } });
147 expect(ok.status).toBe(303);
148 expect(ok.headers.get("location")).toContain("/issues/");
149 expect(issues().length).toBe(1);
150 });
151 });
152
153 describe("fork lines", () => {
154 test("a fork's admin gets a link to its follow mode; a reader gets none", () => {
155 const parent = repoNow();
156 app.db.query("INSERT INTO repo (id, owner_id, name, visibility, fork_parent_id, default_branch, created_at) SELECT 'fk1', owner_id, 'forky', 'public', ?1, default_branch, created_at FROM repo WHERE id = ?1").run(parent.id);
157 const line = readFlows(app.db, parent, admin()).outbound.find((l) => l.label.endsWith("/forky"))!;
158 expect(line.action).toEqual({ label: "Follow mode", href: `/${PREVIEW_ADMIN}/forky/following`, post: false });
159 expect(readFlows(app.db, parent, undefined).outbound.find((l) => l.label.endsWith("/forky"))?.action).toBeUndefined();
160 });
161 });
test/flows-tui.test.ts +21
@@ -129,4 +129,25 @@ describe("flows in the terminal", () => {
129129 guest.stop();
130130 forge.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?").run(repo.id);
131131 });
132
133 test("stop following and update a dependency work for an admin and are refused for a reader", async () => {
134 const sources = () => forge.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM repo_source WHERE repo_id = ?").get(repo.id)!.n;
135 const updates = () => forge.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE repo_id = ? AND title LIKE 'Update leftish to %'").get(repo.id)!.n;
136 const reader = boot(eve);
137 await reader.open();
138 expect(reader.text()).not.toContain("stop or resume following");
139 await reader.press("s");
140 await reader.press("j", "j", "u");
141 expect(sources()).toBe(1);
142 expect(updates()).toBe(0);
143 reader.stop();
144 const admin = boot(ada);
145 await admin.open();
146 expect(admin.text()).toContain("stop or resume following");
147 await admin.press("s");
148 expect(sources()).toBe(0);
149 await admin.press("j", "u");
150 expect(updates()).toBe(1);
151 admin.stop();
152 });
132153 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs