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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9591-act-on-a-flow-line-where-it-stands-updat → main 3 commits fork
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"; | ||
| 11 | 11 | import { crewWithRole } from "../core/agents.ts"; |
| 12 | 12 | import { createIssue, type Issue } from "../core/issues.ts"; |
| 13 | 13 | 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"; | |
| 15 | 16 | import type { Gap } from "./semver.ts"; |
| 16 | 17 | import { parseSemver } from "./semver.ts"; |
| 17 | 18 | import type { DepLine } from "./radar.ts"; |
| @@ -100,3 +101,18 @@ export function fileUpdates(app: App, repo: Repo, lines: readonly DepLine[]): nu | ||
| 100 | 101 | } |
| 101 | 102 | return filed; |
| 102 | 103 | } |
| 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 @@ | ||
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | import { getCursor } from "../core/origins.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import type { User } from "../core/users.ts"; |
| 13 | 13 | import { aheadWords, parseAhead } from "../origins/ahead.ts"; |
| 14 | 14 | import { counts, lastScanAt } from "../checks/scan_store.ts"; |
| @@ -24,6 +24,8 @@ export interface FlowAction { | ||
| 24 | 24 | readonly label: string; |
| 25 | 25 | readonly href: string; |
| 26 | 26 | 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>>; | |
| 27 | 29 | } |
| 28 | 30 | |
| 29 | 31 | export interface FlowLine { |
| @@ -36,6 +38,8 @@ export interface FlowLine { | ||
| 36 | 38 | readonly at: string | null; |
| 37 | 39 | readonly problem: boolean; |
| 38 | 40 | readonly health: Health; |
| 41 | /** The followed source's id, for the line that stops it. */ | |
| 42 | readonly id?: string; | |
| 39 | 43 | readonly action?: FlowAction; |
| 40 | 44 | } |
| 41 | 45 | |
| @@ -79,6 +83,7 @@ export function followHealth( | ||
| 79 | 83 | |
| 80 | 84 | export function readFlows(db: Database, repo: Repo, viewer: User | undefined, now = Date.now()): FlowView { |
| 81 | 85 | const writer = viewer !== undefined && viewer.kind !== "agent" && canWrite(db, repo, viewer); |
| 86 | const admin = viewer !== undefined && viewer.kind !== "agent" && canAdmin(db, repo, viewer); | |
| 82 | 87 | const inbound: FlowLine[] = []; |
| 83 | 88 | const outbound: FlowLine[] = []; |
| 84 | 89 | if (repo.forkParentId !== null) { |
| @@ -100,17 +105,30 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined, no | ||
| 100 | 105 | kind: "upstream", label: `${plainUrl(up.url)} (${up.branch})`, href: "", at: up.checked_at, problem: up.error !== "", |
| 101 | 106 | health: followHealth({ enabled: up.enabled !== 0, checkedAt: up.checked_at, behind: up.behind, error: up.error }, now), |
| 102 | 107 | 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 | : {}), | |
| 103 | 118 | }); |
| 104 | 119 | } |
| 105 | 120 | 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", | |
| 108 | 123 | ) |
| 109 | 124 | .all(repo.id)) { |
| 110 | 125 | 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 !== "", | |
| 112 | 127 | health: followHealth({ enabled: s.enabled !== 0, checkedAt: s.checked_at, behind: s.behind, error: s.error }, now), |
| 113 | 128 | 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 | : {}), | |
| 114 | 132 | }); |
| 115 | 133 | } |
| 116 | 134 | if (repo.origin !== null) { |
| @@ -133,6 +151,9 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined, no | ||
| 133 | 151 | outbound.push({ |
| 134 | 152 | kind: "fork", label: `${f.ownerName}/${f.name}`, href: base(f), at: d?.pushedAt ?? d?.createdAt ?? null, problem: false, health: "none", |
| 135 | 153 | 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 | : {}), | |
| 136 | 157 | }); |
| 137 | 158 | } |
| 138 | 159 | const mirror = db |
src/http/server.ts +2 −1
| @@ -152,7 +152,7 @@ import { reconnectSubmit } from "../web/pages/frozen.ts"; | ||
| 152 | 152 | import { activityAtom, changelogAtom } from "../web/feeds.ts"; |
| 153 | 153 | import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts"; |
| 154 | 154 | 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"; | |
| 156 | 156 | import { findingAccept, findingsPage } from "../web/pages/findings.ts"; |
| 157 | 157 | import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts"; |
| 158 | 158 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; |
| @@ -1154,6 +1154,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1154 | 1154 | router.add("GET", "/:owner/:name/flows", flowsPage); |
| 1155 | 1155 | router.add("POST", "/:owner/:name/flows/check", flowsCheck); |
| 1156 | 1156 | router.add("POST", "/:owner/:name/flows/probe", flowsProbe); |
| 1157 | router.add("POST", "/:owner/:name/flows/update", flowsUpdate); | |
| 1157 | 1158 | router.add("GET", "/:owner/:name/findings", findingsPage); |
| 1158 | 1159 | router.add("POST", "/:owner/:name/findings/:id/accept", findingAccept); |
| 1159 | 1160 | 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"; | ||
| 11 | 11 | import { pinsOf } from "../../flows/run.ts"; |
| 12 | 12 | import { probeLinks } from "../../flows/probe.ts"; |
| 13 | 13 | import { mayStartRadar, startRadar } from "../../flows/start.ts"; |
| 14 | import { actOnFollow } from "../../flows/act.ts"; | |
| 15 | import { askUpdate, worthTaking } from "../../flows/file.ts"; | |
| 14 | 16 | import { readFlows, type FlowLine, type Health } from "../../flows/view.ts"; |
| 15 | 17 | import type { Ctx, Screen } from "../app/app.ts"; |
| 16 | 18 | import { binding } from "../app/keys.ts"; |
| @@ -37,21 +39,29 @@ export function flowsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 37 | 39 | const state = new ListState(); |
| 38 | 40 | let deps: DepLine[] = []; |
| 39 | 41 | 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 }[] => { | |
| 41 | 44 | if (!visible()) return []; |
| 42 | 45 | 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 | ]; | |
| 44 | 51 | }; |
| 52 | const rows = (): ListRow[] => items().map((i) => i.row); | |
| 53 | const selected = () => items()[state.index]; | |
| 45 | 54 | /** 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); | |
| 47 | 57 | 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()); | |
| 49 | 59 | deps = visible() ? readRadar(f.db, repo.id, await pinsOf(f.app, repo)) : []; |
| 50 | 60 | ctx.invalidate(); |
| 51 | 61 | }; |
| 52 | 62 | return { |
| 53 | 63 | 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")], | |
| 55 | 65 | load, |
| 56 | 66 | render(area, s) { |
| 57 | 67 | drawList(s, area, ctx.theme.c, state, rows()); |
| @@ -78,8 +88,26 @@ export function flowsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 78 | 88 | }); |
| 79 | 89 | return true; |
| 80 | 90 | } |
| 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 | } | |
| 81 | 108 | 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); | |
| 83 | 111 | if (!offer) return false; |
| 84 | 112 | ctx.toast(`Open ${offer.href} on the web to offer it.`); |
| 85 | 113 | return true; |
src/web/pages/flows.ts +24 −5
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 11 | import { canWrite } from "../../core/repos.ts"; | |
| 12 | import { askUpdate, worthTaking } from "../../flows/file.ts"; | |
| 11 | 13 | import { depState, readRadar } from "../../flows/radar.ts"; |
| 12 | 14 | import { pinsOf } from "../../flows/run.ts"; |
| 13 | 15 | import { probeLinks } from "../../flows/probe.ts"; |
| @@ -15,13 +17,14 @@ import { mayStartRadar, startRadar } from "../../flows/start.ts"; | ||
| 15 | 17 | import { readFlows, type FlowAction, type FlowLine, type Health } from "../../flows/view.ts"; |
| 16 | 18 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 19 | import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 18 | import { readForm } from "../form.ts"; | |
| 20 | import { field, readForm } from "../form.ts"; | |
| 19 | 21 | import { html, type Html } from "../html.ts"; |
| 20 | 22 | import { flashTo, page } from "../layout.ts"; |
| 21 | 23 | import { badge, btn, type Tone, card, listCard, row, time } from "../ui.ts"; |
| 22 | 24 | |
| 23 | 25 | const lineRow = (l: FlowLine): Html => |
| 24 | 26 | row({ |
| 27 | ...(l.action ? { after: actionForm(l.action) } : {}), | |
| 25 | 28 | ...(l.href === "" ? {} : { href: l.href }), |
| 26 | 29 | title: html`${l.label}`, |
| 27 | 30 | meta: html`${l.at === null ? "never moved" : time(l.at)}`, |
| @@ -35,8 +38,8 @@ const TONE: Record<Health, Tone> = { | ||
| 35 | 38 | |
| 36 | 39 | const actionForm = (a: FlowAction): Html => |
| 37 | 40 | 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 })}`; | |
| 40 | 43 | |
| 41 | 44 | function side(title: string, lines: readonly Html[], empty: string): Html { |
| 42 | 45 | 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> { | ||
| 51 | 54 | const flows = readFlows(ctx.app.db, repo, ctx.auth.user); |
| 52 | 55 | const deps = readRadar(ctx.app.db, repo.id, await pinsOf(ctx.app, repo)); |
| 53 | 56 | 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); | |
| 55 | 58 | const depRows = deps.map((d) => |
| 56 | 59 | row({ |
| 57 | 60 | title: html`${d.name}`, |
| 58 | 61 | 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}`}`, |
| 59 | 62 | 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 | : {}), | |
| 60 | 66 | }), |
| 61 | 67 | ); |
| 62 | 68 | const body = html`${repoHeader(ctx, repo, "flows")} |
| @@ -64,7 +70,6 @@ export async function flowsPage(ctx: Ctx): Promise<Response> { | ||
| 64 | 70 | <div class="split"> |
| 65 | 71 | <div> |
| 66 | 72 | ${side("Flows in", flows.inbound.map(lineRow), "Nothing flows in: no origin and no followed source.")} |
| 67 | ${actions} | |
| 68 | 73 | ${side("Packages", depRows, "No pinned packages.")} |
| 69 | 74 | </div> |
| 70 | 75 | <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> { | ||
| 103 | 108 | const n = probed.read; |
| 104 | 109 | return flashTo(ctx, back, { message: n === 0 ? "No link could be compared." : `Compared ${n} ${n === 1 ? "link" : "links"}.` }); |
| 105 | 110 | } |
| 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"; | ||
| 36 | 36 | import { field, readForm } from "../form.ts"; |
| 37 | 37 | import { html, when, type Html } from "../html.ts"; |
| 38 | 38 | import { icon } from "../icons.ts"; |
| 39 | import { isLocalPath } from "../flash.ts"; | |
| 39 | 40 | import { flashTo } from "../layout.ts"; |
| 40 | 41 | import { btn, card, field as formField, row, timeAgo } from "../ui.ts"; |
| 41 | 42 | |
| 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 | ||
| 42 | 49 | /** HH:MM in UTC, with the instant for machines and the title for people. */ |
| 43 | 50 | function clock(iso: string): Html { |
| 44 | 51 | const at = new Date(iso); |
| @@ -183,7 +190,7 @@ export async function followUpstreamUpdate(ctx: Ctx): Promise<Response> { | ||
| 183 | 190 | }); |
| 184 | 191 | return flashTo( |
| 185 | 192 | ctx, |
| 186 | `${repoBase(repo)}/settings#upstream`, | |
| 193 | backTo(form, `${repoBase(repo)}/settings#upstream`), | |
| 187 | 194 | saved.ok |
| 188 | 195 | ? { |
| 189 | 196 | message: saved.value.enabled |
| @@ -298,7 +305,7 @@ export async function followSourceRemove(ctx: Ctx): Promise<Response> { | ||
| 298 | 305 | const gone = removeFollowSource(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""); |
| 299 | 306 | return flashTo( |
| 300 | 307 | ctx, |
| 301 | `${repoBase(repo)}/settings#sources`, | |
| 308 | backTo(form, `${repoBase(repo)}/settings#sources`), | |
| 302 | 309 | gone.ok |
| 303 | 310 | ? { message: `No longer following ${upstreamShort(gone.value.url)}.` } |
| 304 | 311 | : { 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", () => { | ||
| 129 | 129 | guest.stop(); |
| 130 | 130 | forge.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?").run(repo.id); |
| 131 | 131 | }); |
| 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 | }); | |
| 132 | 153 | }); |