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

A migrated repository shows its trail: "migrated from github.com/Trevato/SO-ARM100, a fork of TheRobotStudio/SO-ARM100", each a link up - hideable per repository #3312

Merged opened this in answer to

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

Merged

torque/forge:torque/3293-a-migrated-repository-shows-its-trail-mi → main 11 commits fork 7 threads · 7 unresolved

  • Merged. torque/3293-a-migrated-repository-shows-its-trail-mi was merged into main as dac8451.

Review

Sign in to review this change.
22 files changed +1463 −25
README.md +30
@@ -661,6 +661,36 @@ fleet's receipt: the page that answers lists what was archived and an **Undo**
661661 button for the person who ran it (kept for a day), after which the apps are back
662662 on the home. The terminal's dashboard archives and undoes the same way (below), and so does the MCP `archive` tool given `repos` (at most 50; it answers the `operation` id, which `limb` with `action: "undo"` reverts for a day); each door refreshes an open web home (the `fleet.operated` event).
663663
664 ## Where it came from
665
666 A repository brought here with **Migrate** (`/migrate`) keeps where it came
667 from as links, never as a connection: the source's address, and — when its host
668 says it is a fork — what it forks (GitHub's `parent` and `source`, Forgejo's and
669 Gitea's `parent`; a pasted address's host is asked once, anonymously, after the
670 clone). The repository header then reads "Migrated from
671 github.com/Trevato/SO-ARM100 · fork of TheRobotStudio/SO-ARM100", each part a
672 link up. A fork made here of a migrated repository inherits its parent's
673 trail and reads "forked from … · migrated from … · fork of …" — at every door
674 below, for anyone who may read the parent; only the parent's admin can hide
675 that inherited part, and a fork's own record, if it has one, wins. The
676 terminal's About card (`I`) shows the same
677 as `migrated from` and `fork of` rows, `GET /api/v1/repos/:owner/:name` as
678 `provenance` `{ source, sourceUrl, parents: [{ fullName, url }], migratedAt }`,
679 and the MCP `repo` tool the same field.
680
681 An admin turns it off with **Settings → Show where this came from**,
682 `PATCH /api/v1/repos/:owner/:name {"showProvenance":false}`, `t` on the About
683 card, or the MCP `trail` tool (dry by default, admin scope). Off hides the line
684 on every page, in the terminal, and `provenance` is `null` in the API and MCP;
685 the record is kept, so on brings it back as it was. A source that was private
686 on its connected origin starts off, whatever it is migrated here as: its name
687 and owner on the other host are not published until an admin turns the line
688 on (a later re-record never overrides that choice). It is a person's call:
689 anyone else, an agent included, is refused (the API answers 404 before any
690 other field in the same request is saved). Repositories migrated from a pasted
691 address before this existed were filled in from their migration's `push`
692 event, without a fork chain; one picked from a connected origin then has none.
693
664694 ## Search
665695
666696 `/search` answers repositories and issues in full, and greps code repository by repository. A first search starts no new `git grep` after 30 ms (`codeBudgetMs` in the forge config; a soft stop, since the grep in flight still finishes, one batch of four) and shows what it has with one link, **More code results** (`?code=all`), that greps the rest. The API and the terminal palette always grep everything.
docs/MCP.md +2 −1
@@ -29,7 +29,7 @@ for a tunnel and a forge that sleeps.
2929 | -------------- | ------------------------------------------------------------------------------------------------- | ------ |
3030 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
3131 | `new_app` | `{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken) | yes |
32 | `repo` | `{ repo }`: one repository's description, visibility, autonomy and `audience { level, sentence }`, the same as `GET /api/v1/repos/:owner/:name` | |
32 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
3333 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
3434 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
3535 | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found | |
@@ -45,6 +45,7 @@ for a tunnel and a forge that sleeps.
4545 | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
4646 | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
4747 | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes |
48 | `trail` | `{ repo, shown?, dryRun? }`: where a migrated repository came from (`provenance`: source, sourceUrl, parents, migratedAt) and whether it is shown; with `shown`, shows or hides the trail — planned first (`dryRun` is true unless `false`), only the repository's admin with an admin token, never an agent. Hiding keeps the record; a hidden one reads `null` to anyone but an admin | yes |
4849 | `branches` | `{ repo, name?, action?, from? }`: without `name`, lists the branches; with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act | yes |
4950 | `commits` | `{ repo, ref?, limit? }`: a branch's recent commits, newest first (`limit` 1–100, default 30; refused outside that, where REST clamps), as `GET …/commits` answers | |
5051 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
src/actions/migrate.ts +42 −1
@@ -4,6 +4,9 @@
44 * history — every branch, every tag — into a native repository that is
55 * yours from the first second: no `origin_id`, no write-through, nothing
66 * that reaches back. After it, the forge is where the repository lives.
7 * What it keeps of the source is where it came from, as links and nothing
8 * more (`core/provenance.ts`, #3293): the address, and what that forks when
9 * its host says — the header's "Migrated from … · fork of …" line.
710 *
811 * The source is one of two things. A repository on an origin you have
912 * connected is read with that origin's credential, and the credential
@@ -32,6 +35,7 @@ import { getAgentByName } from "../core/agents.ts";
3235 import { storageStale } from "../core/storage.ts";
3336 import { setMirrorTarget } from "../core/migrate_mirror.ts";
3437 import { getOriginFor } from "../core/origins.ts";
38 import { recordProvenance } from "../core/provenance.ts";
3539 import { getRepoById, type Repo } from "../core/repos.ts";
3640 import type { User } from "../core/users.ts";
3741 import { runGitCapture } from "../git/env.ts";
@@ -44,6 +48,7 @@ import { carryConversations, plural } from "../origins/carry.ts";
4448 import { providerForOrigin } from "../origins/credential.ts";
4549 import { redact, safeRemoteUrl, secretsIn } from "../origins/credentials.ts";
4650 import { MIRROR_REFSPECS, snapshotRefs } from "../origins/git-mirror.ts";
51 import { lookupLineage } from "../origins/lineage.ts";
4752 import {
4853 localTips,
4954 pickWorkBranch,
@@ -136,6 +141,18 @@ interface Resolved {
136141 readonly originId: string;
137142 readonly name: string;
138143 readonly description: string;
144 /** Where a person reads the source: the address the line links (#3293). */
145 readonly webUrl: string;
146 /**
147 * What the source forks, nearest first, as its origin reported it — or
148 * `undefined` for a pasted address, whose host is asked after the clone.
149 */
150 readonly parents: readonly string[] | undefined;
151 /**
152 * Private where it came from: its name and owner are not this forge's to
153 * publish, so its trail starts hidden whatever it is migrated as.
154 */
155 readonly private: boolean;
139156 readonly authEnv: () => Promise<Record<string, string>>;
140157 /** The address `vetHost` passed, when the host was vetted: git must dial it, not look the name up again. */
141158 readonly pin?: Pin;
@@ -210,6 +227,7 @@ export async function migrateRepo(
210227 crew: input.crew,
211228 after: input.after ?? "leave",
212229 jobId: job.id,
230 lookup: input.resolve,
213231 });
214232 return ok({ repo, job: getJob(job.id) ?? job });
215233 }
@@ -420,6 +438,10 @@ export async function resolveSource(
420438 originId: "",
421439 name: nameFromUrl(url),
422440 description: "",
441 webUrl: url,
442 parents: undefined,
443 // Cloned without logging in, so anyone could already read it.
444 private: false,
423445 authEnv: () => Promise.resolve({}),
424446 who: { logins: [user.username], emails: [user.email] },
425447 });
@@ -441,6 +463,12 @@ export async function resolveSource(
441463 originId: origin.id,
442464 name: remote.value.name,
443465 description: remote.value.description,
466 webUrl: remote.value.htmlUrl,
467 parents: [
468 ...(remote.value.parent === null ? [] : [remote.value.parent]),
469 ...(remote.value.root === undefined ? [] : [remote.value.root]),
470 ],
471 private: remote.value.private,
444472 // Only the header: the origin's credential reaches this one git
445473 // invocation scoped to its host, and the repository never learns it.
446474 authEnv: () => provider.value.gitAuthEnv(url),
@@ -471,9 +499,11 @@ async function runMigration(
471499 readonly crew: MigrateInput["crew"];
472500 readonly after: AfterMigration;
473501 readonly jobId: string;
502 /** How a pasted address's host is looked up for its lineage (#3293). */
503 readonly lookup: Resolve | undefined;
474504 },
475505 ): Promise<void> {
476 const { crew, jobId } = asked;
506 const { crew, jobId, lookup } = asked;
477507 const fail = async (error: string): Promise<void> => {
478508 // An empty repository nobody asked for is clutter, and it holds the
479509 // name a retry wants: a clone that did not happen leaves nothing.
@@ -567,6 +597,17 @@ async function runMigration(
567597 });
568598 }
569599
600 // Where it came from, kept as links and nothing more (#3293): a pasted
601 // address's host is asked once, anonymously, what it forks. A private
602 // source's trail is kept hidden until its owner turns it on.
603 recordProvenance(app.db, current.id, {
604 shown: !source.private,
605 url: source.webUrl,
606 parents:
607 source.parents ??
608 (await lookupLineage(source.url, lookup ? { resolve: lookup } : {})),
609 });
610
570611 // The source becomes the upstream this repository follows (#3442): on
571612 // when the crew runs it on its own, off — but filled in — otherwise.
572613 // Only an https or http source can be followed.
src/actions/provenance.ts added +48
@@ -0,0 +1,48 @@
1 /**
2 * "Show where this came from" (#3293): a repository's admin turns the
3 * migrated-from line on or off. Off hides it on every page, in the
4 * terminal and in the API's and MCP's fields; the record stays, so on
5 * brings it back as it was.
6 */
7
8 import type { App } from "../app.ts";
9 import { getProvenance, setProvenanceShown } from "../core/provenance.ts";
10 import type { Repo } from "../core/repos.ts";
11 import type { User } from "../core/users.ts";
12 import { err, ok, type Result } from "../kernel/result.ts";
13 import { requireRepoAdmin } from "./access.ts";
14
15 export interface ProvenanceAnswer {
16 /** Whether the trail is shown, after this call. */
17 readonly shown: boolean;
18 /** Whether it was before it. */
19 readonly was: boolean;
20 /** Nothing changed: this was the plan. */
21 readonly dryRun: boolean;
22 }
23
24 export function showProvenance(
25 app: App,
26 actor: User | undefined,
27 repo: Repo,
28 shown: boolean,
29 dryRun = false,
30 ): Result<ProvenanceAnswer, "not_found" | "no_provenance"> {
31 const admin = requireRepoAdmin(app, actor, repo);
32 if (!admin.ok) return admin;
33 // What the repository says about where it came from is a person's call,
34 // like archiving it: an agent never changes it, nor plans to.
35 if (admin.value.kind === "agent") return err("not_found");
36 const trail = getProvenance(app.db, repo.id);
37 if (trail === undefined) return err("no_provenance");
38 const was = trail.shown;
39 if (dryRun) return ok({ shown, was, dryRun });
40 setProvenanceShown(app.db, repo.id, shown);
41 app.bus.append({
42 kind: "repo.settings_updated",
43 repoId: repo.id,
44 actorId: admin.value.id,
45 payload: { changed: was === shown ? [] : ["provenance"], shown },
46 });
47 return ok({ shown, was, dryRun });
48 }
src/contract/openapi.ts +20 −2
@@ -50,7 +50,13 @@ export const ROUTES: readonly Route[] = [
5050 refusals: APP_REFUSALS,
5151 },
5252 { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false },
53 { method: "get", path: REPO, summary: "Read one repository", auth: false },
53 {
54 method: "get",
55 path: REPO,
56 summary:
57 "Read one repository; `provenance` is where a migrated one came from ({ source, sourceUrl, parents: [{ fullName, url }], migratedAt }), null when it was not migrated or its owner hid it",
58 auth: false,
59 },
5460 {
5561 method: "post",
5662 path: `${REPO}/reports`,
@@ -99,7 +105,19 @@ export const ROUTES: readonly Route[] = [
99105 body: { verdict: "string", body: "string" },
100106 },
101107 { method: "post", path: `${REPO}/pulls/{number}/merge`, summary: "Merge a pull (a person's)", auth: true },
102 { method: "patch", path: REPO, summary: "Change a repository's settings", auth: true },
108 {
109 method: "patch",
110 path: REPO,
111 summary:
112 "Change a repository's settings (an admin's); `showProvenance` shows or hides where a migrated one came from (a person's, never an agent's)",
113 auth: true,
114 body: {
115 description: "string",
116 visibility: "string",
117 archived: "boolean",
118 showProvenance: "boolean",
119 },
120 },
103121 { method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false },
104122 { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars", auth: false },
105123 {
src/core/migrations/20261020180000-repo-provenance.ts added +31
@@ -0,0 +1,31 @@
1 // Where a migrated repository came from (#3293): the source's address and,
2 // when its host said it is a fork, the chain it forks — links only, no
3 // credential and no connection. `shown` is the owner's "Show where this
4 // came from"; off hides the line and keeps the record.
5 //
6 // The backfill reads the one durable trace a migration left before this:
7 // its `push` event, `via: "migrate"`, whose `from` is the pasted address. A
8 // migration picked from a connected origin said only `owner/name` there,
9 // with no host to link, so it is left without a line.
10 export default `
11 CREATE TABLE IF NOT EXISTS repo_provenance (
12 repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE,
13 source_url TEXT NOT NULL,
14 parents TEXT NOT NULL DEFAULT '[]',
15 shown INTEGER NOT NULL DEFAULT 1,
16 migrated_at TEXT NOT NULL
17 );
18 INSERT OR IGNORE INTO repo_provenance (repo_id, source_url, migrated_at)
19 SELECT e.repo_id,
20 CASE WHEN lower(json_extract(e.payload, '$.from')) LIKE '%.git'
21 THEN substr(json_extract(e.payload, '$.from'), 1,
22 length(json_extract(e.payload, '$.from')) - 4)
23 ELSE json_extract(e.payload, '$.from') END,
24 e.created_at
25 FROM event e JOIN repo r ON r.id = e.repo_id
26 WHERE e.kind = 'push'
27 AND json_valid(e.payload)
28 AND json_extract(e.payload, '$.via') = 'migrate'
29 AND (json_extract(e.payload, '$.from') LIKE 'https://%'
30 OR json_extract(e.payload, '$.from') LIKE 'http://%');
31 `;
src/core/provenance.ts added +212
@@ -0,0 +1,212 @@
1 /**
2 * Provenance: where a migrated repository came from, kept as data and
3 * never as a connection (#3293). The source's address, and the chain it
4 * forks when its host said so — `owner/name` on that same host, nearest
5 * first. Nothing here reaches the source again; these are links for a
6 * person to follow, and `shown` is the owner's switch for them.
7 *
8 * Pure data, no authz, no events: `actions/provenance.ts` decides who may
9 * turn it off, and `web/chrome.ts` draws it.
10 */
11
12 import type { Database } from "bun:sqlite";
13
14 export interface Provenance {
15 readonly repoId: string;
16 /** The source's web address: http(s), no credential, no `.git`. */
17 readonly sourceUrl: string;
18 /** `owner/name` of what the source forks, then what that forks; on the source's host. */
19 readonly parents: readonly string[];
20 /** "Show where this came from": off hides the line, the record stays. */
21 readonly shown: boolean;
22 readonly migratedAt: string;
23 }
24
25 /** The longest chain kept: a parent and its root is what the hosts report. */
26 export const MAX_PARENTS = 4;
27
28 const FULL_NAME = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
29
30 /** `owner/name` as a host names a repository, and nothing that could be a path trick. */
31 export function isFullName(value: string): boolean {
32 return FULL_NAME.test(value) && !value.split("/").some((s) => /^\.+$/.test(s));
33 }
34
35 /**
36 * A source address fit to keep and to link: http or https, a host, no
37 * user or password, no query or fragment, `.git` and trailing slashes
38 * dropped — or `undefined`. Read rows pass through it again, so a value
39 * the backfill took from an old event is held to the same rule.
40 */
41 export function provenanceUrl(raw: string): string | undefined {
42 const parsed = URL.parse(raw.trim());
43 if (parsed === null) return undefined;
44 if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
45 return undefined;
46 if (parsed.hostname === "" || parsed.username !== "" || parsed.password !== "")
47 return undefined;
48 const path = parsed.pathname.replace(/\/+$/, "").replace(/\.git$/i, "");
49 if (path === "") return undefined;
50 return `${parsed.protocol}//${parsed.host}${path}`;
51 }
52
53 /** What the line says for the source: `github.com/Trevato/SO-ARM100`. */
54 export function sourceLabel(sourceUrl: string): string {
55 const parsed = new URL(sourceUrl);
56 return `${parsed.host}${parsed.pathname}`;
57 }
58
59 /** A parent's page, on the source's host: `https://github.com/TheRobotStudio/SO-ARM100`. */
60 export function parentUrl(sourceUrl: string, parent: string): string {
61 return `${new URL(sourceUrl).origin}/${parent}`;
62 }
63
64 /** The chain as kept: valid names only, no repeats, the source itself left out, capped. */
65 export function cleanParents(
66 parents: readonly (string | null | undefined)[],
67 self = "",
68 ): string[] {
69 const out: string[] = [];
70 for (const p of parents) {
71 if (p == null || !isFullName(p)) continue;
72 if (p.toLowerCase() === self.toLowerCase()) continue;
73 if (out.some((o) => o.toLowerCase() === p.toLowerCase())) continue;
74 out.push(p);
75 if (out.length === MAX_PARENTS) break;
76 }
77 return out;
78 }
79
80 /**
81 * The trail a viewer is shown (#3293), resolved once for every door — the
82 * header, the API, MCP and the terminal's About card. A repository's own
83 * record decides first: shown, it is the trail; hidden, there is none, and
84 * a parent's does not stand in for it. Without a record, a fork made here
85 * inherits its parent's trail, only while that one is shown. `parent` is
86 * the fork parent the viewer may read (`forkParentFor`), never the raw id.
87 */
88 export function shownTrail(
89 db: Database,
90 repo: { readonly id: string },
91 parent: { readonly id: string } | undefined,
92 ): Provenance | undefined {
93 const trail =
94 getProvenance(db, repo.id) ??
95 (parent === undefined ? undefined : getProvenance(db, parent.id));
96 return trail?.shown === true ? trail : undefined;
97 }
98
99 /**
100 * The trail as the API and MCP give it out: the source and the chain it
101 * forks, each with its address — or `null` when `shownTrail` has none.
102 */
103 export function provenanceFacts(
104 db: Database,
105 repo: { readonly id: string },
106 parent: { readonly id: string } | undefined,
107 ) {
108 const trail = shownTrail(db, repo, parent);
109 return trail === undefined ? null : trailFacts(trail);
110 }
111
112 /** One record in the shape `provenanceFacts` gives out, shown or not. */
113 export function trailFacts(trail: Provenance) {
114 return {
115 sourceUrl: trail.sourceUrl,
116 source: sourceLabel(trail.sourceUrl),
117 parents: trail.parents.map((fullName) => ({
118 fullName,
119 url: parentUrl(trail.sourceUrl, fullName),
120 })),
121 migratedAt: trail.migratedAt,
122 };
123 }
124
125 interface Row {
126 repo_id: string;
127 source_url: string;
128 parents: string;
129 shown: number;
130 migrated_at: string;
131 }
132
133 function parseParents(text: string): string[] {
134 try {
135 const value: unknown = JSON.parse(text);
136 return Array.isArray(value)
137 ? cleanParents(value.filter((v): v is string => typeof v === "string"))
138 : [];
139 } catch {
140 return [];
141 }
142 }
143
144 /** The record, or `undefined` for a repository that was not migrated (or whose record is unusable). */
145 export function getProvenance(
146 db: Database,
147 repoId: string,
148 ): Provenance | undefined {
149 const row = db
150 .query<Row, [string]>(
151 "SELECT repo_id, source_url, parents, shown, migrated_at FROM repo_provenance WHERE repo_id = ?1",
152 )
153 .get(repoId);
154 if (row === null) return undefined;
155 const sourceUrl = provenanceUrl(row.source_url);
156 if (sourceUrl === undefined) return undefined;
157 return {
158 repoId: row.repo_id,
159 sourceUrl,
160 parents: parseParents(row.parents),
161 shown: row.shown !== 0,
162 migratedAt: row.migrated_at,
163 };
164 }
165
166 /**
167 * Keep where a repository came from. A second record for the same
168 * repository replaces the address and chain and leaves the owner's
169 * switch as they set it: `shown` is written only when the record is new.
170 * An address that is not fit to link keeps nothing.
171 */
172 export function recordProvenance(
173 db: Database,
174 repoId: string,
175 source: {
176 readonly url: string;
177 readonly parents: readonly string[];
178 /** On a new record only. @default true */
179 readonly shown?: boolean;
180 },
181 at = new Date().toISOString(),
182 ): boolean {
183 const url = provenanceUrl(source.url);
184 if (url === undefined) return false;
185 const self = new URL(url).pathname.slice(1);
186 db.query(
187 `INSERT INTO repo_provenance (repo_id, source_url, parents, migrated_at, shown)
188 VALUES (?1, ?2, ?3, ?4, ?5)
189 ON CONFLICT (repo_id) DO UPDATE SET source_url = excluded.source_url,
190 parents = excluded.parents, migrated_at = excluded.migrated_at`,
191 ).run(
192 repoId,
193 url,
194 JSON.stringify(cleanParents(source.parents, self)),
195 at,
196 source.shown === false ? 0 : 1,
197 );
198 return true;
199 }
200
201 /** Turn the line on or off; `false` when there is no record to turn. */
202 export function setProvenanceShown(
203 db: Database,
204 repoId: string,
205 shown: boolean,
206 ): boolean {
207 return (
208 db
209 .query("UPDATE repo_provenance SET shown = ?2 WHERE repo_id = ?1")
210 .run(repoId, shown ? 1 : 0).changes > 0
211 );
212 }
src/http/api.ts +26
@@ -8,6 +8,8 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { showProvenance } from "../actions/provenance.ts";
12 import { getProvenance, provenanceFacts } from "../core/provenance.ts";
1113 import { goldenPath } from "../apps/golden_path.ts";
1214 import { upstreamPayload } from "../spawn/upstream.ts";
1315 import { setRole } from "../actions/roles.ts";
@@ -603,6 +605,13 @@ function repoJson(ctx: Ctx, repo: Repo) {
603605 pinned:
604606 ctx.auth.user !== undefined &&
605607 isPinned(ctx.app.db, ctx.auth.user.id, repo.id),
608 provenance: provenanceFacts(
609 ctx.app.db,
610 repo,
611 repo.forkParentId === null
612 ? undefined
613 : forkParentFor(ctx.app.db, repo, ctx.auth.user),
614 ),
606615 };
607616 }
608617
@@ -2375,6 +2384,18 @@ export function apiRoutes(router: Router<Handler>): void {
23752384 );
23762385 if (input.plansNeedNod !== undefined && typeof input.plansNeedNod !== "boolean")
23772386 return apiError("bad_plans_need_nod", 422, "plansNeedNod is true or false");
2387 if (input.showProvenance !== undefined) {
2388 if (typeof input.showProvenance !== "boolean")
2389 return apiError("bad_show_provenance", 422, "showProvenance is true or false");
2390 // A person's call and an admin's, refused before anything is written,
2391 // so a request that cannot have this field saves none of the others —
2392 // and before the record is looked at, so the refusal is the same
2393 // whether there is a hidden trail or none.
2394 if (ctx.auth.user?.kind === "agent" || !canAdmin(ctx.app.db, repo, ctx.auth.user))
2395 return respondAccess("not_found", 422);
2396 if (getProvenance(ctx.app.db, repo.id) === undefined)
2397 return apiError("no_provenance", 422, "this repository was not migrated here");
2398 }
23782399 const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, {
23792400 ...(input.visibility === "public" || input.visibility === "private"
23802401 ? { visibility: input.visibility }
@@ -2412,6 +2433,11 @@ export function apiRoutes(router: Router<Handler>): void {
24122433 : {}),
24132434 });
24142435 if (!updated.ok) return respondAccess(updated.error, 422);
2436 // "Show where this came from" (#3293): an admin's, never an agent's.
2437 if (typeof input.showProvenance === "boolean") {
2438 const shown = showProvenance(ctx.app, ctx.auth.user, repo, input.showProvenance);
2439 if (!shown.ok) return respondAccess(shown.error, 422);
2440 }
24152441 return json(repoJson(ctx, updated.value));
24162442 });
24172443
src/http/mcp.ts +71 −11
@@ -17,6 +17,8 @@
1717 * no page. `ro` tokens read; `rw` tokens act.
1818 */
1919
20 import { showProvenance } from "../actions/provenance.ts";
21 import { getProvenance, provenanceFacts, trailFacts } from "../core/provenance.ts";
2022 import { upstreamPayload } from "../spawn/upstream.ts";
2123 import { NOT_A_MEMBER } from "../core/roles.ts";
2224 import { voteVariant } from "../actions/variants.ts";
@@ -130,6 +132,7 @@ import { askToLiveFor } from "../web/ask_to_live.ts";
130132 import { getAppByRepo } from "../core/apps.ts";
131133 import {
132134 ARCHIVED_WHY,
135 canAdmin,
133136 canRead,
134137 canWrite,
135138 forkParentFor,
@@ -379,7 +382,7 @@ const APP_ARG = {
379382 const REVEAL_ARG = {
380383 type: "boolean",
381384 description:
382 "Put the live claim link in this conversation (default false); only when its person reads it.",
385 "Put the live claim link here (default false); only when its person reads it.",
383386 } as const;
384387 const LINK_KEPT =
385388 "The claim link is a secret, so it is not in this conversation: a person admin of this forge gets a fresh one on /forges (Send a fresh link) and hands it to the child's person. Send reveal: true only to have it here on purpose.";
@@ -515,7 +518,7 @@ const TOOLS: readonly Tool[] = [
515518 {
516519 name: "repo",
517520 description:
518 "One repository you can read: name, description, visibility, autonomy, `audience`. Same as GET /api/v1/repos/:owner/:name.",
521 "One repository you can read: name, description, visibility, autonomy, `audience`, `provenance`. Same as GET /api/v1/repos/:owner/:name.",
519522 inputSchema: {
520523 type: "object",
521524 properties: { repo: REPO_ARG },
@@ -531,6 +534,11 @@ const TOOLS: readonly Tool[] = [
531534 visibility: repo.visibility,
532535 autonomy: repo.autonomy,
533536 audience: audienceFor(ctx.app.db, repo, user),
537 provenance: provenanceFacts(
538 ctx.app.db,
539 repo,
540 forkParentFor(ctx.app.db, repo, user),
541 ),
534542 });
535543 },
536544 },
@@ -564,7 +572,7 @@ const TOOLS: readonly Tool[] = [
564572 {
565573 name: "tell",
566574 description:
567 "Tell an app's crew what should change; the first line becomes the title. A builder starts on the next tick. Without `repo` nothing is built: it is kept for the forge and answers with a thought id.",
575 "Tell an app's crew what should change (first line is the title); a builder starts next tick. Without `repo` nothing is built: it is kept for the forge, answering a thought id.",
568576 inputSchema: {
569577 type: "object",
570578 properties: {
@@ -702,7 +710,7 @@ const TOOLS: readonly Tool[] = [
702710 {
703711 name: "issue",
704712 description:
705 "Read one issue: title, state, closeReason, body, labels, assignee, its comments (author, whether the author is an agent, body, time), and `blockedBy` and `blocks` (what it waits for, and what waits for it). Text written by someone who cannot write the repository carries `untrusted: true`, as in GET /api/v1/repos/:owner/:name/issues/:number (shown to a crew agent only). A sealed or private issue you cannot see is not found.",
713 "Read one issue: title, state, closeReason, body, labels, assignee, comments (author, agent or not, body, time), `blockedBy`, `blocks`. A crew agent sees `untrusted: true` on text by non-writers, as in the REST API. Sealed or private issues you cannot see are not found.",
706714 inputSchema: {
707715 type: "object",
708716 properties: {
@@ -816,7 +824,7 @@ const TOOLS: readonly Tool[] = [
816824 {
817825 name: "pulls",
818826 description:
819 "List a repository's pulls briefly, newest first: number, title, author, head, check, approvals, changesRequested, mergeable. `state` open (default) or closed; `limit` 1-50 (default 20).",
827 "A repository's pulls, briefly, newest first: number, title, author, head, check, approvals, changesRequested, mergeable. `state` open (default) or closed; `limit` 1-50 (default 20).",
820828 inputSchema: {
821829 type: "object",
822830 properties: {
@@ -997,7 +1005,7 @@ const TOOLS: readonly Tool[] = [
9971005 {
9981006 name: "asks",
9991007 description:
1000 "What waits on you, most consequential first: each row names the decision, options, and the cost of inaction. Your saved filter (`feed_chips`) applies.",
1008 "What waits on you, most consequential first: each row's decision, options, and cost of inaction. Your saved filter (`feed_chips`) applies.",
10011009 inputSchema: { type: "object", properties: { ...CHIP_ARGS } },
10021010 writes: false,
10031011 run(ctx, user, args) {
@@ -1190,7 +1198,7 @@ const TOOLS: readonly Tool[] = [
11901198 {
11911199 name: "now",
11921200 description:
1193 "What the crew is doing right now across your apps: who, on what, since when, cost so far; what waits on your review comes first.",
1201 "What the crew is doing now across your apps: who, on what, since when, cost so far; what waits on your review first.",
11941202 inputSchema: { type: "object", properties: {} },
11951203 writes: false,
11961204 run(ctx, user) {
@@ -1644,6 +1652,58 @@ const TOOLS: readonly Tool[] = [
16441652 });
16451653 },
16461654 },
1655 {
1656 name: "trail",
1657 description:
1658 "A migrated repository's source and forks; `shown` shows or hides them (admin).",
1659 inputSchema: {
1660 type: "object",
1661 properties: {
1662 repo: REPO_ARG,
1663 shown: { type: "boolean" },
1664 dryRun: { type: "boolean", description: "false to apply (default plans)." },
1665 },
1666 required: ["repo"],
1667 },
1668 writes: true,
1669 dryByDefault: true,
1670 run(ctx, user, args) {
1671 const repo = repoArg(ctx, user, args);
1672 if (isAnswer(repo)) return repo;
1673 const shown = args["shown"];
1674 if (shown === undefined) {
1675 const found = getProvenance(ctx.app.db, repo.id);
1676 // The hidden record is the admin's to read, as on the settings page;
1677 // to anyone else a hidden trail reads exactly like no trail at all.
1678 const trail =
1679 found === undefined || (!found.shown && !canAdmin(ctx.app.db, repo, user))
1680 ? undefined
1681 : found;
1682 return good({
1683 migrated: trail !== undefined,
1684 shown: trail?.shown ?? false,
1685 provenance: trail === undefined ? null : trailFacts(trail),
1686 });
1687 }
1688 if (typeof shown !== "boolean") return bad("invalid", "`shown` is true or false");
1689 const dryRun = args["dryRun"] !== false;
1690 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
1691 return bad("forbidden", "showing or hiding the trail needs an admin token");
1692 const set = showProvenance(ctx.app, user, repo, shown, dryRun);
1693 if (!set.ok)
1694 return set.error === "no_provenance"
1695 ? bad("no_provenance", "this repository was not migrated here")
1696 : bad("forbidden", "only an admin of this repository shows or hides its trail");
1697 return good({
1698 ...set.value,
1699 says: dryRun
1700 ? `This is the plan; nothing changed. It is ${set.value.was ? "shown" : "hidden"} now; send dryRun: false to ${set.value.shown ? "show" : "hide"} it.`
1701 : set.value.shown
1702 ? "Where this came from is shown."
1703 : "Where this came from is hidden; the record is kept.",
1704 });
1705 },
1706 },
16471707 {
16481708 name: "branches",
16491709 description:
@@ -1820,12 +1880,12 @@ const TOOLS: readonly Tool[] = [
18201880 direction: {
18211881 type: "string",
18221882 description:
1823 "What the child is for (at most 2000 characters); the planner's first issue there.",
1883 "What the child is for (max 2000 chars); its planner's first issue.",
18241884 },
18251885 fresh: {
18261886 type: "string",
18271887 description:
1828 "One line (at most 200 characters) on a fresh app to start beside the copied ones.",
1888 "One line (max 200 chars): a fresh app to start beside the copied ones.",
18291889 },
18301890 followMode: {
18311891 type: "string",
@@ -2338,7 +2398,7 @@ const TOOLS: readonly Tool[] = [
23382398 {
23392399 name: "make_it_yours",
23402400 description:
2341 "Make an app you can see yours: a fork of its code and history, following the original; data, secrets, crew and domains are not copied.",
2401 "Make an app you can see yours: a fork of its code and history, following the original; data, secrets, crew, domains are not copied.",
23422402 inputSchema: {
23432403 type: "object",
23442404 properties: {
@@ -3166,7 +3226,7 @@ const TOOLS: readonly Tool[] = [
31663226 {
31673227 name: "access",
31683228 description:
3169 "What outside agents tried: the redacted log of non-browser requests to public paths, newest first, at most 500, for a forge admin. Needs a write-scope token.",
3229 "What outside agents tried: the redacted log of non-browser requests to public paths, newest first, max 500. Forge admin, write-scope token.",
31703230 inputSchema: {
31713231 type: "object",
31723232 properties: {
src/http/server.ts +2
@@ -381,6 +381,7 @@ import {
381381 settingsUpdate,
382382 waveCut,
383383 archiveSubmit,
384 provenanceSubmit,
384385 mirrorStop,
385386 webhookCreate,
386387 webhookDelete,
@@ -1339,6 +1340,7 @@ export function buildRouter(): Router<Handler> {
13391340 router.add("POST", "/:owner/:name/settings", settingsUpdate);
13401341 router.add("POST", "/:owner/:name/settings/wave", waveCut);
13411342 router.add("POST", "/:owner/:name/settings/archive", archiveSubmit);
1343 router.add("POST", "/:owner/:name/settings/provenance", provenanceSubmit);
13421344 router.add("POST", "/:owner/:name/settings/mirror/stop", mirrorStop);
13431345 router.add("POST", "/:owner/:name/settings/proposal-tokens", proposalTokenMint);
13441346 router.add(
src/origins/lineage.ts added +75
@@ -0,0 +1,75 @@
1 /**
2 * Lineage: what a pasted repository address forks, asked of its host once,
3 * anonymously, for the migrated-from line (#3293). GitHub answers at
4 * `api.github.com/repos/:owner/:name` with `parent` (what it forks) and
5 * `source` (the root of the network); Forgejo and Gitea answer at
6 * `/api/v1/repos/:owner/:name` with `parent`. Any other host, a private
7 * repository, a timeout or a strange answer is the same outcome: no chain.
8 * It is a nicety, never a reason a migration fails.
9 *
10 * Written like every fetch of a host a person named (`discover.ts`): the
11 * address is vetted (`outboundFetch` refuses a private one and follows no
12 * redirect), the wait is short and the body is capped.
13 */
14
15 import { cleanParents, isFullName } from "../core/provenance.ts";
16 import { outboundFetch, type OutboundOptions } from "../kernel/outbound.ts";
17 import { asString, dig, readCapped } from "./providers/http.ts";
18
19 const TIMEOUT_MS = 5_000;
20 const MAX_BYTES = 256 * 1024;
21
22 /** The API address that describes the repository at `url`, or `undefined` when it does not name one. */
23 export function lineageApiUrl(url: string): string | undefined {
24 const parsed = URL.parse(url);
25 if (parsed === null) return undefined;
26 const parts = parsed.pathname
27 .replace(/\/+$/, "")
28 .replace(/\.git$/i, "")
29 .split("/")
30 .filter((s) => s !== "");
31 if (parts.length !== 2 || !isFullName(parts.join("/"))) return undefined;
32 const [owner, name] = parts;
33 return parsed.hostname.toLowerCase() === "github.com"
34 ? `https://api.github.com/repos/${owner}/${name}`
35 : `${parsed.origin}/api/v1/repos/${owner}/${name}`;
36 }
37
38 /** The fork chain the host reports, nearest first — `[]` for anything else. */
39 export function lineageFrom(body: unknown, self: string): string[] {
40 return cleanParents(
41 [
42 asString(dig(body, "parent", "full_name")),
43 asString(dig(body, "source", "full_name")),
44 ],
45 self,
46 );
47 }
48
49 export async function lookupLineage(
50 url: string,
51 options: OutboundOptions = {},
52 ): Promise<string[]> {
53 const api = lineageApiUrl(url);
54 if (api === undefined) return [];
55 try {
56 const response = await outboundFetch(
57 api,
58 {
59 headers: { accept: "application/json", "user-agent": "forge-migrate" },
60 signal: AbortSignal.timeout(TIMEOUT_MS),
61 },
62 options,
63 );
64 if (response.status !== 200) {
65 await response.body?.cancel().catch(() => undefined);
66 return [];
67 }
68 const text = await readCapped(response, MAX_BYTES);
69 if (text === undefined) return [];
70 const body: unknown = JSON.parse(text);
71 return lineageFrom(body, asString(dig(body, "full_name")));
72 } catch {
73 return [];
74 }
75 }
src/origins/providers/github.ts +9
@@ -722,6 +722,14 @@ function repoFromApiUrl(url: string): string {
722722 return owner === "" || name === "" ? "" : `${owner}/${name}`;
723723 }
724724
725 /** GitHub's `source` — the root of the fork network — when it is not the parent. */
726 function rootOf(raw: unknown): { root?: string } {
727 const root = asString(dig(raw, "source", "full_name"));
728 return root === "" || root === asString(dig(raw, "parent", "full_name"))
729 ? {}
730 : { root };
731 }
732
725733 function toRepo(raw: unknown): RemoteRepo {
726734 const fullName = asString(field(raw, "full_name"));
727735 const named = splitFullName(fullName);
@@ -734,6 +742,7 @@ function toRepo(raw: unknown): RemoteRepo {
734742 private: asBool(field(raw, "private")),
735743 fork: asBool(field(raw, "fork")),
736744 parent: asString(dig(raw, "parent", "full_name")) || null,
745 ...rootOf(raw),
737746 defaultBranch: asString(field(raw, "default_branch")) || "main",
738747 cloneUrl: asString(field(raw, "clone_url")),
739748 sshUrl: asString(field(raw, "ssh_url")),
src/origins/types.ts +5
@@ -61,6 +61,11 @@ export interface RemoteRepo {
6161 * keeps the value it already has rather than letting a listing clear it.
6262 */
6363 readonly parent: string | null;
64 /**
65 * `owner/name` of the root of the fork network, when the origin says and
66 * it is not `parent` — GitHub's `source`. Forgejo reports no such thing.
67 */
68 readonly root?: string;
6469 readonly defaultBranch: string;
6570 readonly cloneUrl: string;
6671 readonly sshUrl: string;
src/tui/screens/about.ts +59 −1
@@ -23,7 +23,18 @@ import { checkMemoryAt } from "../../apps/check-memory.ts";
2323 import { repoDir } from "../../git/repos.ts";
2424 import { listRefs } from "../../git/read.ts";
2525 import { updateRepoSettings } from "../../actions/repos.ts";
26 import { canAdmin, getRepoById, type Repo } from "../../core/repos.ts";
26 import { showProvenance } from "../../actions/provenance.ts";
27 import {
28 getProvenance,
29 shownTrail,
30 sourceLabel,
31 } from "../../core/provenance.ts";
32 import {
33 canAdmin,
34 forkParentFor,
35 getRepoById,
36 type Repo,
37 } from "../../core/repos.ts";
2738 import type { RepoFacts, RepoSource } from "../../view/source.ts";
2839 import type { Ctx, Screen } from "../app/app.ts";
2940 import { binding } from "../app/keys.ts";
@@ -54,9 +65,17 @@ export function aboutScreen(
5465 const may = (): boolean =>
5566 repo !== undefined && f.user !== undefined && canAdmin(f.db, repo, f.user);
5667 let cell: [string, string][] = [];
68 // The trail's switch (#3293) is a person's, an admin's, and only where
69 // there is a trail to switch: the same rule as the settings page.
70 const mayTrail = (): boolean =>
71 may() &&
72 f.user?.kind !== "agent" &&
73 repo !== undefined &&
74 getProvenance(f.db, repo.id) !== undefined;
5775 const factRows = (): [string, string][] => [
5876 ...archivedRow(repo),
5977 ...aboutFacts(f, src, facts, ref),
78 ...trailRows(f, repo),
6079 ...cell,
6180 ];
6281 let rows = factRows();
@@ -96,6 +115,16 @@ export function aboutScreen(
96115 ),
97116 ]
98117 : []),
118 ...(mayTrail()
119 ? [
120 binding(
121 ["t"],
122 getProvenance(f.db, repo!.id)?.shown === true
123 ? "hide where this came from"
124 : "show where this came from",
125 ),
126 ]
127 : []),
99128 // A site admin also reaches the repository's private egress grants.
100129 ...(f.user?.isAdmin === true && repo !== undefined
101130 ? [binding(["e"], "egress"), binding(["c"], "connections")]
@@ -205,6 +234,16 @@ export function aboutScreen(
205234 });
206235 return true;
207236 }
237 if (e.key === "t" && repo !== undefined && mayTrail()) {
238 const shown = getProvenance(f.db, repo.id)?.shown === true;
239 const done = showProvenance(f.app, f.user, repo, !shown);
240 if (done.ok) {
241 rows = factRows();
242 ctx.toast(done.value.shown ? "where this came from is shown" : "where this came from is hidden", "success");
243 } else ctx.toast("that could not be changed", "danger");
244 ctx.invalidate();
245 return true;
246 }
208247 if (e.key === "e" && repo !== undefined && f.user?.isAdmin === true) {
209248 ctx.push(egressScreen(f, ctx, repo));
210249 return true;
@@ -222,6 +261,25 @@ export function aboutScreen(
222261 };
223262 }
224263
264 /**
265 * Where a migrated repository came from (#3293), as the web header says it:
266 * the source, then each repository up the fork chain — a fork made here of
267 * a migrated repository inherits its parent's. Nothing when `shownTrail`
268 * has none.
269 */
270 export function trailRows(
271 f: Session,
272 repo: Repo | undefined,
273 ): [string, string][] {
274 if (repo === undefined) return [];
275 const trail = shownTrail(f.db, repo, forkParentFor(f.db, repo, f.user));
276 if (trail === undefined) return [];
277 return [
278 ["migrated from", sourceLabel(trail.sourceUrl)],
279 ...trail.parents.map((p): [string, string] => ["fork of", p]),
280 ];
281 }
282
225283 /** One line when an admin has put the repository away. */
226284 function archivedRow(repo: Repo | undefined): [string, string][] {
227285 if (repo === undefined || repo.archivedAt === null) return [];
src/web/chrome.ts +41 −8
@@ -22,6 +22,8 @@ import {
2222 visibleForks,
2323 } from "../core/repos.ts";
2424 import type { Repo } from "../core/repos.ts";
25 import { parentUrl, shownTrail, sourceLabel } from "../core/provenance.ts";
26 import type { Database } from "bun:sqlite";
2527 import type { Ctx } from "../http/context.ts";
2628 import { html, raw, when, type Html } from "./html.ts";
2729 import { icon } from "./icons.ts";
@@ -322,14 +324,7 @@ export function scopeHeader(
322324 !options.compact && facts.description !== "",
323325 () => html`<p class="repo-desc">${facts.description}</p>`,
324326 )}
325 ${when(
326 parent,
327 (p) =>
328 html`<p class="repo-lineage">
329 forked from
330 <a href="/${p.ownerName}/${p.name}">${p.ownerName}/${p.name}</a>
331 </p>`,
332 )}
327 ${repo === undefined ? null : lineageLine(ctx.app.db, repo, parent)}
333328 ${when(
334329 scope.upstream !== undefined && upstreamHref(scope) === undefined,
335330 // The where strip carries "fork of octo/widgets" and links it, which
@@ -347,6 +342,44 @@ export function scopeHeader(
347342 </header>`;
348343 }
349344
345 /**
346 * The trail up, as one quiet line however it was built (#3293): "forked
347 * from alice/arm" for a fork made here, "Migrated from github.com/a/arm ·
348 * fork of b/arm" for a repository brought here, and the two joined when a
349 * fork here was made of a migrated one. Every segment links up; a link off
350 * this forge opens the original and hands it nothing (`noopener
351 * noreferrer`). A migrated trail whose owner turned it off is not drawn.
352 */
353 export function lineageLine(
354 db: Database,
355 repo: Repo,
356 parent: Repo | undefined,
357 ): Html | null {
358 const shown = shownTrail(db, repo, parent);
359 if (parent === undefined && shown === undefined) return null;
360 const out = (href: string, text: string): Html =>
361 html`<a href="${href}" rel="noopener noreferrer">${text}</a>`;
362 const segments: Html[] = [];
363 if (parent !== undefined)
364 segments.push(
365 html`forked from
366 <a href="/${parent.ownerName}/${parent.name}"
367 >${parent.ownerName}/${parent.name}</a
368 >`,
369 );
370 if (shown !== undefined) {
371 segments.push(
372 html`${segments.length === 0 ? "Migrated" : "migrated"} from
373 ${out(shown.sourceUrl, sourceLabel(shown.sourceUrl))}`,
374 );
375 for (const p of shown.parents)
376 segments.push(html`fork of ${out(parentUrl(shown.sourceUrl, p), p)}`);
377 }
378 return html`<p class="repo-lineage" data-lineage>
379 ${segments.map((s, i) => (i === 0 ? s : html` · ${s}`))}
380 </p>`;
381 }
382
350383 /**
351384 * Who can see this repository, in the one sentence `audienceOf` gives, beside
352385 * the badge. A link to the settings for whoever can open them, plain text for
src/web/pages/settings.ts +62 −1
@@ -9,6 +9,8 @@
99 */
1010
1111 import { appLimitWords } from "../../actions/app_limit.ts";
12 import { showProvenance } from "../../actions/provenance.ts";
13 import { getProvenance, parentUrl, sourceLabel } from "../../core/provenance.ts";
1214 import {
1315 deleteAppEnvVar,
1416 disableApp,
@@ -351,6 +353,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
351353 { head: html`${icon("rocket")}<h2>Cut a wave</h2>` },
352354 );
353355 const proposals = proposalTokensSection(ctx, repo);
356 const trail = provenanceCard(ctx, repo);
354357 const archive = card(
355358 html`<form method="post" action="${base}/settings/archive">
356359 <input type="hidden" name="archived" value="${repo.archivedAt === null ? "1" : "0"}" />
@@ -388,7 +391,9 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
388391 repo,
389392 "general",
390393 "Settings",
391 html`${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div><div class="mt">${wave}</div><div class="mt">${archive}</div>${proposals
394 html`${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div>${trail
395 ? html`<div class="mt">${trail}</div>`
396 : null}<div class="mt">${wave}</div><div class="mt">${archive}</div>${proposals
392397 ? html`<div class="mt">${proposals}</div>`
393398 : null}${secrets
394399 ? html`<div class="mt">${secrets}</div>`
@@ -474,6 +479,62 @@ export async function archiveSubmit(ctx: Ctx): Promise<Response> {
474479 );
475480 }
476481
482 /**
483 * "Show where this came from" (#3293), drawn only for a repository that
484 * has a trail to show: the line as it reads, and the one switch for it.
outdated

DESIGN.md ("One accent per page" / --primary: "the one filled button per page"). Settings already has "Save changes" as its primary button. This is a secondary switch, so use variant: "outline" (or secondary).

485 */
486 function provenanceCard(ctx: Ctx, repo: Repo): Html | null {
487 const trail = getProvenance(ctx.app.db, repo.id);
488 if (trail === undefined) return null;
489 const base = repoBase(repo);
490 return card(
491 html`<form method="post" action="${base}/settings/provenance">
492 <input type="hidden" name="shown" value="${trail.shown ? "0" : "1"}" />
493 <p class="muted">
494 Migrated from
495 <a href="${trail.sourceUrl}" rel="noopener noreferrer"
496 >${sourceLabel(trail.sourceUrl)}</a
497 >${trail.parents.map(
498 (p) =>
499 html` · fork of
500 <a href="${parentUrl(trail.sourceUrl, p)}" rel="noopener noreferrer"
501 >${p}</a
502 >`,
503 )}
504 on ${trail.migratedAt.slice(0, 10)}.
505 ${trail.shown
506 ? "Shown under the repository's name on every page and in the API. Hiding it keeps the record, so it can come back."
507 : "Hidden: nobody else sees it, on a page or in the API. The record is kept."}
508 </p>
509 <div class="form-actions">
510 ${btn(trail.shown ? "Hide where this came from" : "Show where this came from", {
511 variant: "outline",
512 })}
513 </div>
514 </form>`,
515 { head: html`${icon("fork")}<h2>Show where this came from</h2>` },
516 );
517 }
518
519 /** POST /:owner/:name/settings/provenance — show or hide the trail. */
520 export async function provenanceSubmit(ctx: Ctx): Promise<Response> {
521 const gate = await adminForm(ctx);
522 if (gate instanceof Response) return gate;
523 const { repo, form } = gate;
524 const asked = field(form, "shown");
525 const back = `${repoBase(repo)}/settings`;
526 if (asked !== "0" && asked !== "1")
527 return flashTo(ctx, back, { tone: "danger", message: "That could not be changed." });
528 const set = showProvenance(ctx.app, ctx.auth.user, repo, asked === "1");
529 return flashTo(
530 ctx,
531 back,
532 set.ok
533 ? { message: set.value.shown ? "Where this came from is shown." : "Where this came from is hidden." }
534 : { tone: "danger", message: "That could not be changed." },
535 );
536 }
537
477538 /** POST /:owner/:name/settings/mirror/stop — stop mirroring to the source. */
478539 export async function mirrorStop(ctx: Ctx): Promise<Response> {
479540 const gate = await adminForm(ctx);
test/fake-origin.ts +5
@@ -41,6 +41,8 @@ export interface FakeRepo {
4141 fork?: boolean;
4242 /** `owner/name` this repo forks, as `getRepo` reports it. */
4343 parent?: string;
44 /** `owner/name` of the network's root: GitHub's `source`, not sent by Forgejo. */
45 root?: string;
4446 defaultBranch?: string;
4547 cloneUrl: string;
4648 htmlUrl?: string;
@@ -981,6 +983,9 @@ function repoJson(state: FakeState, repo: FakeRepo): Record<string, unknown> {
981983 ...(repo.parent === undefined
982984 ? {}
983985 : { parent: { full_name: repo.parent } }),
986 ...(repo.root === undefined || state.kind !== "github"
987 ? {}
988 : { source: { full_name: repo.root } }),
984989 };
985990 return state.kind === "github"
986991 ? {
test/mcp.test.ts +1
@@ -202,6 +202,7 @@ describe("the forge over MCP", () => {
202202 "outsiders",
203203 "decisions_by",
204204 "archive",
205 "trail",
205206 "branches",
206207 "commits",
207208 "probe",
test/migrate.test.ts +136
@@ -41,6 +41,11 @@ import {
4141 import { getAppByRepo } from "../src/core/apps.ts";
4242 import { listIssues } from "../src/core/issues.ts";
4343 import { listOrigins } from "../src/core/origins.ts";
44 import {
45 getProvenance,
46 recordProvenance,
47 setProvenanceShown,
48 } from "../src/core/provenance.ts";
4449 import { getRepo, type Repo } from "../src/core/repos.ts";
4550 import { createUser, type User } from "../src/core/users.ts";
4651 import { repoDir } from "../src/git/repos.ts";
@@ -272,6 +277,29 @@ describe("migrate", () => {
272277 defaultBranch: "main",
273278 private: true,
274279 });
280 // A public fork on the origin, whose host says what it forks (#3293).
281 state.repos.push({
282 id: 78,
283 fullName: "octo/arm",
284 description: "an arm, forked",
285 cloneUrl: `${base}/octo/secret-widgets.git`,
286 htmlUrl: "https://code.example/octo/arm",
287 defaultBranch: "main",
288 fork: true,
289 parent: "TheRobotStudio/arm",
290 });
291 // A private fork: its name is not ours to publish, whatever it becomes.
292 state.repos.push({
293 id: 79,
294 fullName: "octo/hush",
295 description: "a quiet fork",
296 cloneUrl: `${base}/octo/secret-widgets.git`,
297 htmlUrl: "https://code.example/octo/hush",
298 defaultBranch: "main",
299 private: true,
300 fork: true,
301 parent: "TheRobotStudio/hush",
302 });
275303 forgejo = serveFakeForge(state);
276304 forgetProviders(dataDir);
277305 const connected = await browser(aliceCookie, "/settings/origins/token", {
@@ -309,6 +337,13 @@ describe("migrate", () => {
309337 expect(job?.detail).toContain("3 branches, 2 tags");
310338
311339 const migrated = getRepo(app.db, "bob", "fixture")!;
340 // Where it came from is kept as a link, the `.git` dropped; a plain
341 // http host on loopback is not asked what it forks (#3293).
342 expect(getProvenance(app.db, migrated.id)).toMatchObject({
343 sourceUrl: `${base}/alice/fixture`,
344 parents: [],
345 shown: true,
346 });
312347 const dir = repoDir(app.reposRoot, "bob", "fixture");
313348 // Every commit, every branch, every tag — the annotated one as itself.
314349 expect(await allCommits(dir)).toEqual(await allCommits(sourceDir));
@@ -539,6 +574,107 @@ describe("migrate", () => {
539574 );
540575 });
541576
577 test("a migrated fork shows its trail, and its owner can hide it and bring it back", async () => {
578 const job = await migrate(alice, {
579 source: { originId, fullName: "octo/arm" },
580 visibility: "public",
581 });
582 expect(job?.error).toBeUndefined();
583 const repo = getRepo(app.db, "alice", "arm")!;
584 const lineage = async (cookie: string): Promise<string> => {
585 const page = await (await browser(cookie, "/alice/arm")).text();
586 return /<p class="repo-lineage" data-lineage>[\s\S]*?<\/p>/.exec(page)?.[0] ?? "";
587 };
588 const shown = await lineage("");
589 expect(shown).toContain("Migrated from");
590 expect(shown).toContain(
591 '<a href="https://code.example/octo/arm" rel="noopener noreferrer">code.example/octo/arm</a>',
592 );
593 expect(shown).toContain("fork of");
594 expect(shown).toContain(
595 '<a href="https://code.example/TheRobotStudio/arm" rel="noopener noreferrer">TheRobotStudio/arm</a>',
596 );
597 const facts = async () =>
598 ((await (await browser("", "/api/v1/repos/alice/arm")).json()) as {
599 provenance: { source: string; parents: { url: string }[] } | null;
600 }).provenance;
601 expect((await facts())?.source).toBe("code.example/octo/arm");
602 expect((await facts())?.parents[0]?.url).toBe(
603 "https://code.example/TheRobotStudio/arm",
604 );
605
606 // Not bob's to hide: he can read it, not administer it.
607 const refused = await browser(bobCookie, "/alice/arm/settings/provenance", {
608 shown: "0",
609 });
610 expect(refused.status).not.toBe(303);
611 expect(getProvenance(app.db, repo.id)?.shown).toBe(true);
612
613 // Off: gone for anyone else and from the API, the record kept.
614 const settings = await (await browser(aliceCookie, "/alice/arm/settings")).text();
615 expect(settings).toContain("Hide where this came from");
616 const off = await browser(aliceCookie, "/alice/arm/settings/provenance", {
617 shown: "0",
618 });
619 expect(off.status).toBe(303);
620 expect(await lineage("")).toBe("");
621 expect(await lineage(bobCookie)).toBe("");
622 expect(await facts()).toBeNull();
623 expect(getProvenance(app.db, repo.id)).toMatchObject({
624 sourceUrl: "https://code.example/octo/arm",
625 parents: ["TheRobotStudio/arm"],
626 shown: false,
627 });
628
629 // On again, by the API this time: back as it was.
630 const on = await fetch(`${base}/api/v1/repos/alice/arm`, {
631 method: "PATCH",
632 headers: {
633 "content-type": "application/json",
634 cookie: aliceCookie,
635 origin: new URL(base).origin,
636 },
637 body: JSON.stringify({ showProvenance: true }),
638 });
639 expect(on.status).toBe(200);
640 expect(await lineage("")).toBe(shown);
641 });
642
643 test("a private source migrated as public keeps its trail hidden until its owner shows it", async () => {
644 const job = await migrate(alice, {
645 source: { originId, fullName: "octo/hush" },
646 visibility: "public",
647 });
648 expect(job?.error).toBeUndefined();
649 const repo = getRepo(app.db, "alice", "hush")!;
650 expect(repo.visibility).toBe("public");
651 const page = await (await browser("", "/alice/hush")).text();
652 expect(page).not.toContain("data-lineage");
653 expect(page).not.toContain("octo/hush");
654 const api = (await (await browser("", "/api/v1/repos/alice/hush")).json()) as {
655 provenance: unknown;
656 };
657 expect(api.provenance).toBeNull();
658 expect(getProvenance(app.db, repo.id)).toMatchObject({
659 sourceUrl: "https://code.example/octo/hush",
660 parents: ["TheRobotStudio/hush"],
661 shown: false,
662 });
663 const settings = await (await browser(aliceCookie, "/alice/hush/settings")).text();
664 expect(settings).toContain("Hidden: nobody else sees it");
665 expect(settings).not.toContain("Hide where this came from");
666 expect(settings).toMatch(/<button[^>]*>\s*Show where this came from/);
667
668 // A second record (a re-migration's) leaves the owner's choice alone.
669 setProvenanceShown(app.db, repo.id, true);
670 recordProvenance(app.db, repo.id, {
671 url: "https://code.example/octo/hush",
672 parents: [],
673 shown: false,
674 });
675 expect(getProvenance(app.db, repo.id)?.shown).toBe(true);
676 });
677
542678 test("one click from GitHub: the repository, 3 issues, 1 open pull request and a crew run on the app", async () => {
543679 // Upstream: a private repository with a branch an open pull stands on.
544680 const made = await createRepo(app, octo, {
test/provenance-doors.test.ts added +220
@@ -0,0 +1,220 @@
1 /**
2 * "Show where this came from" through the API and MCP (#3293): a person who
3 * administers the repository switches it; an admin agent is refused before
4 * anything in the same request is saved; a reader sees the trail only while
5 * it is shown.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { addTeamMember, createOrg, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts";
14 import { getProvenance, recordProvenance } from "../src/core/provenance.ts";
15 import {
16 createRepo,
17 forkRepo,
18 getRepoById,
19 type Repo,
20 } from "../src/core/repos.ts";
21 import { createToken } from "../src/core/tokens.ts";
22 import { createUser } from "../src/core/users.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { trailRows } from "../src/tui/screens/about.ts";
25 import type { Session } from "../src/tui/session.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27
28 quietLogs();
29
30 describe("the trail through the API and MCP", () => {
31 let dataDir: string;
32 let app: App;
33 let servers: Servers;
34 let repo: Repo;
35 let fork: Repo;
36 let botToken = "";
37 let founderToken = "";
38 let readerToken = "";
39 let nextId = 1;
40
41 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
42 if (!r.ok) throw new Error(JSON.stringify(r.error));
43 return r.value;
44 };
45 const patch = (token: string, body: unknown) =>
46 fetch(`${servers.url}/api/v1/repos/acme/arm`, {
47 method: "PATCH",
48 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
49 body: JSON.stringify(body),
50 });
51 const read = async (token: string) =>
52 ((await (
53 await fetch(`${servers.url}/api/v1/repos/acme/arm`, {
54 headers: { authorization: `Bearer ${token}` },
55 })
56 ).json()) as { provenance: { source: string } | null }).provenance;
57 const tool = async (token: string, name: string, args: unknown) => {
58 const res = await fetch(`${servers.url}/mcp`, {
59 method: "POST",
60 headers: {
61 "content-type": "application/json",
62 accept: "application/json, text/event-stream",
63 authorization: `Bearer ${token}`,
64 },
65 body: JSON.stringify({
66 jsonrpc: "2.0",
67 id: nextId++,
68 method: "tools/call",
69 params: { name, arguments: args },
70 }),
71 });
72 const body = (await res.json()) as any;
73 const text: string = body.result.content[0].text;
74 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
75 };
76
77 beforeAll(async () => {
78 dataDir = await mkdtemp(join(tmpdir(), "forge-provenance-doors-"));
79 app = createApp({ dataDir, port: 0 });
80 servers = await serve(app);
81 const person = async (username: string, adminIfFirst: boolean) =>
82 ok(
83 await createUser(app.db, {
84 origin: "seed",
85 trusted: true,
86 username,
87 email: `${username}@forge.local`,
88 adminIfFirst,
89 }),
90 );
91 const founder = await person("founder", true);
92 const reader = await person("reader", false);
93 const org = ok(createOrg(app.db, "acme", founder.id));
94 repo = ok(
95 await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "arm", visibility: "public" }),
96 );
97 ok(
98 await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "plain", visibility: "public" }),
99 );
100 recordProvenance(app.db, repo.id, {
101 url: "https://github.com/Trevato/SO-ARM100",
102 parents: ["TheRobotStudio/SO-ARM100"],
103 });
104 // A fork made here of the migrated one, with no record of its own.
105 fork = ok(await forkRepo(app.db, app.reposRoot, repo, reader.id, "arm"));
106 const bot = ok(await createAgent(app, founder, { username: "bot" }));
107 expect(addToCrew(app, founder, repo, "bot").ok).toBe(true);
108 const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!;
109 addTeamMember(app.db, owners, bot.user.id);
110 botToken = createToken(app.db, bot.user.id, "admin", "admin").token;
111 founderToken = createToken(app.db, founder.id, "admin", "admin").token;
112 readerToken = createToken(app.db, reader.id, "admin", "admin").token;
113 }, 30_000);
114
115 afterAll(async () => {
116 servers?.stop();
117 app?.close();
118 await rm(dataDir, { recursive: true, force: true });
119 });
120
121 test("an admin agent's PATCH saves nothing at all; a person's does both", async () => {
122 const refused = await patch(botToken, { description: "renamed", showProvenance: false });
123 expect(refused.status).toBe(404);
124 expect(getRepoById(app.db, repo.id)!.description).toBe("");
125 expect(getProvenance(app.db, repo.id)?.shown).toBe(true);
126
127 expect((await patch(readerToken, { showProvenance: false })).status).toBe(404);
128 // The same refusal on a repository with no record: nothing to tell them apart.
129 const plainPatch = await fetch(`${servers.url}/api/v1/repos/acme/plain`, {
130 method: "PATCH",
131 headers: { authorization: `Bearer ${readerToken}`, "content-type": "application/json" },
132 body: JSON.stringify({ showProvenance: false }),
133 });
134 expect(plainPatch.status).toBe(404);
135 expect((await patch(founderToken, { showProvenance: "no" })).status).toBe(422);
136 expect(getProvenance(app.db, repo.id)?.shown).toBe(true);
137
138 const done = await patch(founderToken, { description: "an arm", showProvenance: false });
139 expect(done.status).toBe(200);
140 expect(getRepoById(app.db, repo.id)!.description).toBe("an arm");
141 expect(getProvenance(app.db, repo.id)?.shown).toBe(false);
142 expect(await read(readerToken)).toBeNull();
143 expect((await patch(founderToken, { showProvenance: true })).status).toBe(200);
144 expect((await read(readerToken))?.source).toBe("github.com/Trevato/SO-ARM100");
145 });
146
147 test("MCP: `repo` carries the trail, `trail` plans by default and refuses an agent and a reader", async () => {
148 const facts = await tool(readerToken, "repo", { repo: "acme/arm" });
149 expect(facts.value.provenance.parents).toEqual([
150 { fullName: "TheRobotStudio/SO-ARM100", url: "https://github.com/TheRobotStudio/SO-ARM100" },
151 ]);
152 expect((await tool(readerToken, "trail", { repo: "acme/arm" })).value).toMatchObject({
153 migrated: true,
154 shown: true,
155 });
156
157 const plan = await tool(founderToken, "trail", { repo: "acme/arm", shown: false });
158 expect(plan.value).toMatchObject({ shown: false, was: true, dryRun: true });
159 expect(getProvenance(app.db, repo.id)?.shown).toBe(true);
160
161 for (const token of [botToken, readerToken]) {
162 const no = await tool(token, "trail", { repo: "acme/arm", shown: false, dryRun: false });
163 expect(no.error).toContain("only an admin");
164 expect(getProvenance(app.db, repo.id)?.shown).toBe(true);
165 }
166
167 const hid = await tool(founderToken, "trail", { repo: "acme/arm", shown: false, dryRun: false });
168 expect(hid.value).toMatchObject({ shown: false, dryRun: false });
169 expect((await tool(readerToken, "repo", { repo: "acme/arm" })).value.provenance).toBeNull();
170 // Hidden from a reader; the record is still the admin's to read.
171 // To a reader a hidden trail reads exactly like a repository never migrated.
172 const hidden = (await tool(readerToken, "trail", { repo: "acme/arm" })).value;
173 const never = (await tool(readerToken, "trail", { repo: "acme/plain" })).value;
174 expect(hidden).toEqual({ migrated: false, shown: false, provenance: null });
175 expect(hidden).toEqual(never);
176 expect(
177 (await tool(founderToken, "trail", { repo: "acme/arm" })).value.provenance.source,
178 ).toBe("github.com/Trevato/SO-ARM100");
179 await tool(founderToken, "trail", { repo: "acme/arm", shown: true, dryRun: false });
180 expect(getProvenance(app.db, repo.id)?.shown).toBe(true);
181 });
182
183 test("a fork made here inherits the parent's trail through every door, and loses it when the parent hides it", async () => {
184 const page = async (): Promise<string> => {
185 const text = await (await fetch(`${servers.url}/reader/arm`)).text();
186 return /<p class="repo-lineage" data-lineage>[\s\S]*?<\/p>/.exec(text)?.[0] ?? "";
187 };
188 const api = async () =>
189 ((await (await fetch(`${servers.url}/api/v1/repos/reader/arm`)).json()) as {
190 provenance: unknown;
191 }).provenance;
192 const mcp = async () =>
193 (await tool(readerToken, "repo", { repo: "reader/arm" })).value.provenance;
194 const about = () =>
195 trailRows({ db: app.db, user: undefined } as unknown as Session, fork);
196 const parentFacts = (await tool(readerToken, "repo", { repo: "acme/arm" })).value.provenance;
197
198 expect(await page()).toContain("forked from");
199 expect(await page()).toContain("migrated from");
200 expect(await page()).toContain("TheRobotStudio/SO-ARM100");
201 expect(await api()).toEqual(parentFacts);
202 expect(await mcp()).toEqual(parentFacts);
203 expect(about()).toEqual([
204 ["migrated from", "github.com/Trevato/SO-ARM100"],
205 ["fork of", "TheRobotStudio/SO-ARM100"],
206 ]);
207
208 // Only the parent's admin hides it; then no door names it.
209 expect((await patch(founderToken, { showProvenance: false })).status).toBe(200);
210 try {
211 expect(await page()).toContain("forked from");
212 expect(await page()).not.toContain("migrated from");
213 expect(await api()).toBeNull();
214 expect(await mcp()).toBeNull();
215 expect(about()).toEqual([]);
216 } finally {
217 expect((await patch(founderToken, { showProvenance: true })).status).toBe(200);
218 }
219 });
220 });
test/provenance.test.ts added +242
@@ -0,0 +1,242 @@
1 /**
2 * Where a migrated repository came from (#3293): the record kept as data,
3 * the address held to one rule, the chain a host reports, the one line the
4 * header draws however the trail was built, and the backfill from the
5 * migrations that happened before there was a record.
6 */
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 { createRepo } from "../src/actions/repos.ts";
14 import { showProvenance } from "../src/actions/provenance.ts";
15 import {
16 cleanParents,
17 getProvenance,
18 provenanceUrl,
19 recordProvenance,
20 sourceLabel,
21 } from "../src/core/provenance.ts";
22 import type { Repo } from "../src/core/repos.ts";
23 import { createUser, type User } from "../src/core/users.ts";
24 import backfill from "../src/core/migrations/20261020180000-repo-provenance.ts";
25 import { lineageApiUrl, lineageFrom, lookupLineage } from "../src/origins/lineage.ts";
26 import { lineageLine } from "../src/web/chrome.ts";
27 import { quietLogs } from "./helpers/globals.ts";
28 import { promote } from "./helpers/members.ts";
29
30 quietLogs();
31
32 describe("a source address", () => {
33 test("is kept as a web link: no .git, no trailing slash, no query", () => {
34 expect(provenanceUrl("https://github.com/Trevato/SO-ARM100.git")).toBe(
35 "https://github.com/Trevato/SO-ARM100",
36 );
37 expect(provenanceUrl("https://code.example:8443/a/b/?x=1#y")).toBe(
38 "https://code.example:8443/a/b",
39 );
40 expect(sourceLabel("https://github.com/Trevato/SO-ARM100")).toBe(
41 "github.com/Trevato/SO-ARM100",
42 );
43 });
44
45 test("anything that is not a plain http(s) link keeps nothing", () => {
46 for (const raw of [
47 "javascript:alert(1)",
48 "file:///etc/passwd",
49 "git://host/a/b",
50 "https://user:secret@host/a/b",
51 "https://host/",
52 "not a url",
53 ])
54 expect(provenanceUrl(raw)).toBeUndefined();
55 });
56
57 test("a chain keeps names only, once each, without the source itself", () => {
58 expect(
59 cleanParents(
60 ["b/arm", "B/ARM", "../x", "a/arm", null, "c/arm/extra", "root/arm"],
61 "a/arm",
62 ),
63 ).toEqual(["b/arm", "root/arm"]);
64 });
65 });
66
67 describe("what a host says the source forks", () => {
68 test("GitHub is asked at its API host, a Forgejo at its own", () => {
69 expect(lineageApiUrl("https://github.com/Trevato/SO-ARM100.git")).toBe(
70 "https://api.github.com/repos/Trevato/SO-ARM100",
71 );
72 expect(lineageApiUrl("https://codeberg.org/a/b")).toBe(
73 "https://codeberg.org/api/v1/repos/a/b",
74 );
75 // Not a repository's address: nothing is asked.
76 expect(lineageApiUrl("https://gitlab.com/group/sub/b")).toBeUndefined();
77 expect(lineageApiUrl("https://host/a/%2e%2e")).toBeUndefined();
78 });
79
80 test("GitHub's parent then its source; one that is the parent counts once", () => {
81 expect(
82 lineageFrom(
83 {
84 full_name: "Trevato/SO-ARM100",
85 parent: { full_name: "mid/SO-ARM100" },
86 source: { full_name: "TheRobotStudio/SO-ARM100" },
87 },
88 "Trevato/SO-ARM100",
89 ),
90 ).toEqual(["mid/SO-ARM100", "TheRobotStudio/SO-ARM100"]);
91 expect(
92 lineageFrom(
93 {
94 parent: { full_name: "TheRobotStudio/SO-ARM100" },
95 source: { full_name: "TheRobotStudio/SO-ARM100" },
96 },
97 "Trevato/SO-ARM100",
98 ),
99 ).toEqual(["TheRobotStudio/SO-ARM100"]);
100 expect(lineageFrom({ parent: { full_name: "<script>/x" } }, "")).toEqual([]);
101 });
102
103 test("asked once, anonymously; any refusal is no chain at all", async () => {
104 const asked: string[] = [];
105 const host = Bun.serve({
106 port: 0,
107 fetch(request) {
108 const url = new URL(request.url);
109 asked.push(`${url.pathname} ${request.headers.get("authorization") ?? ""}`);
110 if (url.pathname === "/api/v1/repos/octo/arm")
111 return Response.json({
112 full_name: "octo/arm",
113 parent: { full_name: "TheRobotStudio/arm" },
114 });
115 return new Response("Not Found", { status: 404 });
116 },
117 });
118 try {
119 const at = `http://127.0.0.1:${host.port}`;
120 const options = { plain: true, allow: ["127.0.0.1"] };
121 expect(await lookupLineage(`${at}/octo/arm.git`, options)).toEqual([
122 "TheRobotStudio/arm",
123 ]);
124 expect(asked).toEqual(["/api/v1/repos/octo/arm "]);
125 expect(await lookupLineage(`${at}/octo/gone`, options)).toEqual([]);
126 // Without the test's leave, a loopback host is never asked.
127 const before = asked.length;
128 expect(await lookupLineage(`${at}/octo/arm`)).toEqual([]);
129 expect(asked.length).toBe(before);
130 } finally {
131 host.stop(true);
132 }
133 });
134 });
135
136 describe("the record and the line", () => {
137 let dir: string;
138 let app: App;
139 let owner: User;
140 let other: User;
141 let source: Repo;
142
143 beforeAll(async () => {
144 dir = await mkdtemp(join(tmpdir(), "forge-provenance-"));
145 app = createApp({ dataDir: dir, port: 0 });
146 const person = async (username: string): Promise<User> => {
147 const made = await createUser(app.db, {
148 origin: "seed",
149 trusted: true,
150 username,
151 email: `${username}@forge.test`,
152 adminIfFirst: true,
153 });
154 if (!made.ok) throw new Error(String(made.error));
155 promote(app.db, made.value);
156 return made.value;
157 };
158 owner = await person("trevato");
159 other = await person("someone");
160 const made = await createRepo(app, owner, { name: "arm", visibility: "public" });
161 if (!made.ok) throw new Error(made.error);
162 source = made.value;
163 });
164
165 afterAll(async () => {
166 app.close();
167 await rm(dir, { recursive: true, force: true });
168 });
169
170 test("a fork made here of a migrated repository reads as one line", () => {
171 recordProvenance(app.db, source.id, {
172 url: "https://github.com/Trevato/SO-ARM100.git",
173 parents: ["TheRobotStudio/SO-ARM100"],
174 });
175 const fork = { ...source, id: "not-recorded", ownerName: "someone" };
176 const line = lineageLine(app.db, fork, source)!.text.replace(/\s+/g, " ");
177 expect(line).toMatch(/forked from <a href="\/trevato\/arm" ?>trevato\/arm<\/a ?> · migrated/);
178 expect(line).toContain(
179 'migrated from <a href="https://github.com/Trevato/SO-ARM100" rel="noopener noreferrer">github.com/Trevato/SO-ARM100</a>',
180 );
181 expect(line).toContain(
182 'fork of <a href="https://github.com/TheRobotStudio/SO-ARM100" rel="noopener noreferrer">TheRobotStudio/SO-ARM100</a>',
183 );
184 // A repository with neither a parent nor a record draws nothing.
185 expect(lineageLine(app.db, fork, undefined)).toBeNull();
186 });
187
188 test("hidden is hidden everywhere it is drawn; the record stays", () => {
189 // Someone who can read it cannot hide it.
190 expect(showProvenance(app, other, source, false)).toEqual({
191 ok: false,
192 error: "not_found",
193 });
194 expect(getProvenance(app.db, source.id)?.shown).toBe(true);
195 // A plan changes nothing; the real thing does.
196 expect(showProvenance(app, owner, source, false, true)).toEqual({
197 ok: true,
198 value: { shown: false, was: true, dryRun: true },
199 });
200 expect(getProvenance(app.db, source.id)?.shown).toBe(true);
201 expect(showProvenance(app, owner, source, false)).toEqual({
202 ok: true,
203 value: { shown: false, was: true, dryRun: false },
204 });
205 expect(lineageLine(app.db, source, undefined)).toBeNull();
206 expect(getProvenance(app.db, source.id)?.parents).toEqual([
207 "TheRobotStudio/SO-ARM100",
208 ]);
209 // A second record keeps the owner's switch where it was.
210 recordProvenance(app.db, source.id, {
211 url: "https://github.com/Trevato/SO-ARM100",
212 parents: [],
213 });
214 expect(getProvenance(app.db, source.id)?.shown).toBe(false);
215 });
216
217 test("the backfill reads a migration's push event; an origin's bare name keeps nothing", async () => {
218 const made = async (name: string): Promise<Repo> => {
219 const r = await createRepo(app, owner, { name });
220 if (!r.ok) throw new Error(r.error);
221 return r.value;
222 };
223 const pasted = await made("pasted");
224 const picked = await made("picked");
225 const pushed = await made("pushed");
226 const event = app.db.query(
227 "INSERT INTO event (id, kind, repo_id, payload, created_at) VALUES (?1, 'push', ?2, ?3, '2026-10-01T00:00:00.000Z')",
228 );
229 event.run("e1", pasted.id, JSON.stringify({ via: "migrate", from: "https://github.com/Trevato/SO-ARM100.git" }));
230 event.run("e2", picked.id, JSON.stringify({ via: "migrate", from: "octo/arm" }));
231 event.run("e3", pushed.id, JSON.stringify({ updates: [], from: "https://x.example/a/b" }));
232 app.db.exec(backfill);
233 expect(getProvenance(app.db, pasted.id)).toMatchObject({
234 sourceUrl: "https://github.com/Trevato/SO-ARM100",
235 parents: [],
236 shown: true,
237 migratedAt: "2026-10-01T00:00:00.000Z",
238 });
239 expect(getProvenance(app.db, picked.id)).toBeUndefined();
240 expect(getProvenance(app.db, pushed.id)).toBeUndefined();
241 });
242 });
test/tui-provenance.test.ts added +124
@@ -0,0 +1,124 @@
1 /** The trail in the terminal (#3293): About shows it, an admin's `t` hides and shows it. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { showProvenance } from "../src/actions/provenance.ts";
9 import { createApp, type App as Forge } from "../src/app.ts";
10 import { getProvenance, recordProvenance } from "../src/core/provenance.ts";
11 import type { Repo } from "../src/core/repos.ts";
12 import type { User } from "../src/core/users.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import type { TuiIdentity } from "../src/tui/session.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { settleApp, settleUntil } from "./helpers/tui.ts";
18 import { seedAdmin } from "./helpers/admin.ts";
19
20 quietLogs();
21
22 describe("the trail in the terminal", () => {
23 let dataDir: string;
24 let forge: Forge;
25 let alice: User;
26 let repo: Repo;
27
28 function boot(identity: TuiIdentity) {
29 const terminal = headlessTerminal(120, 40);
30 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
31 app.start();
32 const press = async (...keys: string[]): Promise<void> => {
33 for (const key of keys)
34 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
35 await settleApp(app);
36 };
37 return { app, press, text: () => app.screen.toText() };
38 }
39
40 async function openAbout(h: ReturnType<typeof boot>): Promise<void> {
41 await settleApp(h.app);
42 await h.press("/");
43 for (const ch of "armature") await h.press(ch);
44 await h.press("enter");
45 await h.press("enter");
46 await settleUntil(h.app, "alice/armature");
47 await h.press("I");
48 await settleUntil(h.app, "clone (http)");
49 }
50
51 const guest: TuiIdentity = {
52 sshUsername: "git",
53 remoteAddress: "127.0.0.1",
54 fingerprint: "SHA256:unknown",
55 };
56
57 beforeAll(async () => {
58 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-provenance-"));
59 forge = createApp({ dataDir, port: 0 });
60 const a = await seedAdmin(forge, {
61 username: "alice",
62 email: "[email protected]",
63 password: "a-long-passphrase",
64 });
65 if (!a.ok) throw new Error("signup failed");
66 alice = a.value;
67 const made = await createRepo(forge, alice, { name: "armature", visibility: "public" });
68 if (!made.ok) throw new Error("repo failed");
69 repo = made.value;
70 recordProvenance(forge.db, repo.id, {
71 url: "https://github.com/Trevato/SO-ARM100",
72 parents: ["TheRobotStudio/SO-ARM100"],
73 });
74 });
75
76 afterAll(async () => {
77 forge.close();
78 await rm(dataDir, { recursive: true, force: true });
79 });
80
81 test("an admin sees the trail and hides and shows it with `t`", async () => {
82 const h = boot({
83 sshUsername: "alice",
84 remoteAddress: "127.0.0.1",
85 fingerprint: "SHA256:test",
86 user: alice,
87 });
88 try {
89 await openAbout(h);
90 expect(h.text()).toMatch(/migrated from +github\.com\/Trevato\/SO-ARM100/);
91 expect(h.text()).toMatch(/fork of +TheRobotStudio\/SO-ARM100/);
92 await h.press("t");
93 expect(getProvenance(forge.db, repo.id)?.shown).toBe(false);
94 expect(h.text()).not.toMatch(/migrated from +github/);
95 await h.press("t");
96 expect(getProvenance(forge.db, repo.id)?.shown).toBe(true);
97 expect(h.text()).toMatch(/migrated from +github\.com\/Trevato\/SO-ARM100/);
98 } finally {
99 h.app.stop();
100 }
101 });
102
103 test("anyone else sees it while shown, not once hidden, and `t` does nothing", async () => {
104 let h = boot(guest);
105 try {
106 await openAbout(h);
107 expect(h.text()).toMatch(/migrated from +github\.com\/Trevato\/SO-ARM100/);
108 expect(h.text()).not.toContain("where this came from");
109 await h.press("t");
110 expect(getProvenance(forge.db, repo.id)?.shown).toBe(true);
111 } finally {
112 h.app.stop();
113 }
114 expect(showProvenance(forge, alice, repo, false).ok).toBe(true);
115 h = boot(guest);
116 try {
117 await openAbout(h);
118 expect(h.text()).not.toContain("migrated from");
119 expect(h.text()).not.toContain("TheRobotStudio");
120 } finally {
121 h.app.stop();
122 }
123 });
124 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs