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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/3293-a-migrated-repository-shows-its-trail-mi → main 11 commits fork 7 threads · 7 unresolved
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** | ||
| 661 | 661 | button for the person who ran it (kept for a day), after which the apps are back |
| 662 | 662 | 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). |
| 663 | 663 | |
| 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 | ||
| 664 | 694 | ## Search |
| 665 | 695 | |
| 666 | 696 | `/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. | ||
| 29 | 29 | | -------------- | ------------------------------------------------------------------------------------------------- | ------ | |
| 30 | 30 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 31 | 31 | | `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` | | | |
| 33 | 33 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | |
| 34 | 34 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| 35 | 35 | | `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. | ||
| 45 | 45 | | `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 | |
| 46 | 46 | | `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 | |
| 47 | 47 | | `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 | | |
| 48 | 49 | | `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 | |
| 49 | 50 | | `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 | | |
| 50 | 51 | | `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 @@ | ||
| 4 | 4 | * history — every branch, every tag — into a native repository that is |
| 5 | 5 | * yours from the first second: no `origin_id`, no write-through, nothing |
| 6 | 6 | * 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. | |
| 7 | 10 | * |
| 8 | 11 | * The source is one of two things. A repository on an origin you have |
| 9 | 12 | * connected is read with that origin's credential, and the credential |
| @@ -32,6 +35,7 @@ import { getAgentByName } from "../core/agents.ts"; | ||
| 32 | 35 | import { storageStale } from "../core/storage.ts"; |
| 33 | 36 | import { setMirrorTarget } from "../core/migrate_mirror.ts"; |
| 34 | 37 | import { getOriginFor } from "../core/origins.ts"; |
| 38 | import { recordProvenance } from "../core/provenance.ts"; | |
| 35 | 39 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 36 | 40 | import type { User } from "../core/users.ts"; |
| 37 | 41 | import { runGitCapture } from "../git/env.ts"; |
| @@ -44,6 +48,7 @@ import { carryConversations, plural } from "../origins/carry.ts"; | ||
| 44 | 48 | import { providerForOrigin } from "../origins/credential.ts"; |
| 45 | 49 | import { redact, safeRemoteUrl, secretsIn } from "../origins/credentials.ts"; |
| 46 | 50 | import { MIRROR_REFSPECS, snapshotRefs } from "../origins/git-mirror.ts"; |
| 51 | import { lookupLineage } from "../origins/lineage.ts"; | |
| 47 | 52 | import { |
| 48 | 53 | localTips, |
| 49 | 54 | pickWorkBranch, |
| @@ -136,6 +141,18 @@ interface Resolved { | ||
| 136 | 141 | readonly originId: string; |
| 137 | 142 | readonly name: string; |
| 138 | 143 | 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; | |
| 139 | 156 | readonly authEnv: () => Promise<Record<string, string>>; |
| 140 | 157 | /** The address `vetHost` passed, when the host was vetted: git must dial it, not look the name up again. */ |
| 141 | 158 | readonly pin?: Pin; |
| @@ -210,6 +227,7 @@ export async function migrateRepo( | ||
| 210 | 227 | crew: input.crew, |
| 211 | 228 | after: input.after ?? "leave", |
| 212 | 229 | jobId: job.id, |
| 230 | lookup: input.resolve, | |
| 213 | 231 | }); |
| 214 | 232 | return ok({ repo, job: getJob(job.id) ?? job }); |
| 215 | 233 | } |
| @@ -420,6 +438,10 @@ export async function resolveSource( | ||
| 420 | 438 | originId: "", |
| 421 | 439 | name: nameFromUrl(url), |
| 422 | 440 | description: "", |
| 441 | webUrl: url, | |
| 442 | parents: undefined, | |
| 443 | // Cloned without logging in, so anyone could already read it. | |
| 444 | private: false, | |
| 423 | 445 | authEnv: () => Promise.resolve({}), |
| 424 | 446 | who: { logins: [user.username], emails: [user.email] }, |
| 425 | 447 | }); |
| @@ -441,6 +463,12 @@ export async function resolveSource( | ||
| 441 | 463 | originId: origin.id, |
| 442 | 464 | name: remote.value.name, |
| 443 | 465 | 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, | |
| 444 | 472 | // Only the header: the origin's credential reaches this one git |
| 445 | 473 | // invocation scoped to its host, and the repository never learns it. |
| 446 | 474 | authEnv: () => provider.value.gitAuthEnv(url), |
| @@ -471,9 +499,11 @@ async function runMigration( | ||
| 471 | 499 | readonly crew: MigrateInput["crew"]; |
| 472 | 500 | readonly after: AfterMigration; |
| 473 | 501 | readonly jobId: string; |
| 502 | /** How a pasted address's host is looked up for its lineage (#3293). */ | |
| 503 | readonly lookup: Resolve | undefined; | |
| 474 | 504 | }, |
| 475 | 505 | ): Promise<void> { |
| 476 | const { crew, jobId } = asked; | |
| 506 | const { crew, jobId, lookup } = asked; | |
| 477 | 507 | const fail = async (error: string): Promise<void> => { |
| 478 | 508 | // An empty repository nobody asked for is clutter, and it holds the |
| 479 | 509 | // name a retry wants: a clone that did not happen leaves nothing. |
| @@ -567,6 +597,17 @@ async function runMigration( | ||
| 567 | 597 | }); |
| 568 | 598 | } |
| 569 | 599 | |
| 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 | ||
| 570 | 611 | // The source becomes the upstream this repository follows (#3442): on |
| 571 | 612 | // when the crew runs it on its own, off — but filled in — otherwise. |
| 572 | 613 | // 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[] = [ | ||
| 50 | 50 | refusals: APP_REFUSALS, |
| 51 | 51 | }, |
| 52 | 52 | { 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 | }, | |
| 54 | 60 | { |
| 55 | 61 | method: "post", |
| 56 | 62 | path: `${REPO}/reports`, |
| @@ -99,7 +105,19 @@ export const ROUTES: readonly Route[] = [ | ||
| 99 | 105 | body: { verdict: "string", body: "string" }, |
| 100 | 106 | }, |
| 101 | 107 | { 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 | }, | |
| 103 | 121 | { method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false }, |
| 104 | 122 | { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars", auth: false }, |
| 105 | 123 | { |
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 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { showProvenance } from "../actions/provenance.ts"; | |
| 12 | import { getProvenance, provenanceFacts } from "../core/provenance.ts"; | |
| 11 | 13 | import { goldenPath } from "../apps/golden_path.ts"; |
| 12 | 14 | import { upstreamPayload } from "../spawn/upstream.ts"; |
| 13 | 15 | import { setRole } from "../actions/roles.ts"; |
| @@ -603,6 +605,13 @@ function repoJson(ctx: Ctx, repo: Repo) { | ||
| 603 | 605 | pinned: |
| 604 | 606 | ctx.auth.user !== undefined && |
| 605 | 607 | 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 | ), | |
| 606 | 615 | }; |
| 607 | 616 | } |
| 608 | 617 | |
| @@ -2375,6 +2384,18 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2375 | 2384 | ); |
| 2376 | 2385 | if (input.plansNeedNod !== undefined && typeof input.plansNeedNod !== "boolean") |
| 2377 | 2386 | 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 | } | |
| 2378 | 2399 | const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, { |
| 2379 | 2400 | ...(input.visibility === "public" || input.visibility === "private" |
| 2380 | 2401 | ? { visibility: input.visibility } |
| @@ -2412,6 +2433,11 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2412 | 2433 | : {}), |
| 2413 | 2434 | }); |
| 2414 | 2435 | 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 | } | |
| 2415 | 2441 | return json(repoJson(ctx, updated.value)); |
| 2416 | 2442 | }); |
| 2417 | 2443 |
src/http/mcp.ts +71 −11
| @@ -17,6 +17,8 @@ | ||
| 17 | 17 | * no page. `ro` tokens read; `rw` tokens act. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { showProvenance } from "../actions/provenance.ts"; | |
| 21 | import { getProvenance, provenanceFacts, trailFacts } from "../core/provenance.ts"; | |
| 20 | 22 | import { upstreamPayload } from "../spawn/upstream.ts"; |
| 21 | 23 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 22 | 24 | import { voteVariant } from "../actions/variants.ts"; |
| @@ -130,6 +132,7 @@ import { askToLiveFor } from "../web/ask_to_live.ts"; | ||
| 130 | 132 | import { getAppByRepo } from "../core/apps.ts"; |
| 131 | 133 | import { |
| 132 | 134 | ARCHIVED_WHY, |
| 135 | canAdmin, | |
| 133 | 136 | canRead, |
| 134 | 137 | canWrite, |
| 135 | 138 | forkParentFor, |
| @@ -379,7 +382,7 @@ const APP_ARG = { | ||
| 379 | 382 | const REVEAL_ARG = { |
| 380 | 383 | type: "boolean", |
| 381 | 384 | 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.", | |
| 383 | 386 | } as const; |
| 384 | 387 | const LINK_KEPT = |
| 385 | 388 | "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[] = [ | ||
| 515 | 518 | { |
| 516 | 519 | name: "repo", |
| 517 | 520 | 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.", | |
| 519 | 522 | inputSchema: { |
| 520 | 523 | type: "object", |
| 521 | 524 | properties: { repo: REPO_ARG }, |
| @@ -531,6 +534,11 @@ const TOOLS: readonly Tool[] = [ | ||
| 531 | 534 | visibility: repo.visibility, |
| 532 | 535 | autonomy: repo.autonomy, |
| 533 | 536 | audience: audienceFor(ctx.app.db, repo, user), |
| 537 | provenance: provenanceFacts( | |
| 538 | ctx.app.db, | |
| 539 | repo, | |
| 540 | forkParentFor(ctx.app.db, repo, user), | |
| 541 | ), | |
| 534 | 542 | }); |
| 535 | 543 | }, |
| 536 | 544 | }, |
| @@ -564,7 +572,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 564 | 572 | { |
| 565 | 573 | name: "tell", |
| 566 | 574 | 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.", | |
| 568 | 576 | inputSchema: { |
| 569 | 577 | type: "object", |
| 570 | 578 | properties: { |
| @@ -702,7 +710,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 702 | 710 | { |
| 703 | 711 | name: "issue", |
| 704 | 712 | 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.", | |
| 706 | 714 | inputSchema: { |
| 707 | 715 | type: "object", |
| 708 | 716 | properties: { |
| @@ -816,7 +824,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 816 | 824 | { |
| 817 | 825 | name: "pulls", |
| 818 | 826 | 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).", | |
| 820 | 828 | inputSchema: { |
| 821 | 829 | type: "object", |
| 822 | 830 | properties: { |
| @@ -997,7 +1005,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 997 | 1005 | { |
| 998 | 1006 | name: "asks", |
| 999 | 1007 | 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.", | |
| 1001 | 1009 | inputSchema: { type: "object", properties: { ...CHIP_ARGS } }, |
| 1002 | 1010 | writes: false, |
| 1003 | 1011 | run(ctx, user, args) { |
| @@ -1190,7 +1198,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1190 | 1198 | { |
| 1191 | 1199 | name: "now", |
| 1192 | 1200 | 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.", | |
| 1194 | 1202 | inputSchema: { type: "object", properties: {} }, |
| 1195 | 1203 | writes: false, |
| 1196 | 1204 | run(ctx, user) { |
| @@ -1644,6 +1652,58 @@ const TOOLS: readonly Tool[] = [ | ||
| 1644 | 1652 | }); |
| 1645 | 1653 | }, |
| 1646 | 1654 | }, |
| 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 | }, | |
| 1647 | 1707 | { |
| 1648 | 1708 | name: "branches", |
| 1649 | 1709 | description: |
| @@ -1820,12 +1880,12 @@ const TOOLS: readonly Tool[] = [ | ||
| 1820 | 1880 | direction: { |
| 1821 | 1881 | type: "string", |
| 1822 | 1882 | 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.", | |
| 1824 | 1884 | }, |
| 1825 | 1885 | fresh: { |
| 1826 | 1886 | type: "string", |
| 1827 | 1887 | 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.", | |
| 1829 | 1889 | }, |
| 1830 | 1890 | followMode: { |
| 1831 | 1891 | type: "string", |
| @@ -2338,7 +2398,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2338 | 2398 | { |
| 2339 | 2399 | name: "make_it_yours", |
| 2340 | 2400 | 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.", | |
| 2342 | 2402 | inputSchema: { |
| 2343 | 2403 | type: "object", |
| 2344 | 2404 | properties: { |
| @@ -3166,7 +3226,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3166 | 3226 | { |
| 3167 | 3227 | name: "access", |
| 3168 | 3228 | 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.", | |
| 3170 | 3230 | inputSchema: { |
| 3171 | 3231 | type: "object", |
| 3172 | 3232 | properties: { |
src/http/server.ts +2
| @@ -381,6 +381,7 @@ import { | ||
| 381 | 381 | settingsUpdate, |
| 382 | 382 | waveCut, |
| 383 | 383 | archiveSubmit, |
| 384 | provenanceSubmit, | |
| 384 | 385 | mirrorStop, |
| 385 | 386 | webhookCreate, |
| 386 | 387 | webhookDelete, |
| @@ -1339,6 +1340,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1339 | 1340 | router.add("POST", "/:owner/:name/settings", settingsUpdate); |
| 1340 | 1341 | router.add("POST", "/:owner/:name/settings/wave", waveCut); |
| 1341 | 1342 | router.add("POST", "/:owner/:name/settings/archive", archiveSubmit); |
| 1343 | router.add("POST", "/:owner/:name/settings/provenance", provenanceSubmit); | |
| 1342 | 1344 | router.add("POST", "/:owner/:name/settings/mirror/stop", mirrorStop); |
| 1343 | 1345 | router.add("POST", "/:owner/:name/settings/proposal-tokens", proposalTokenMint); |
| 1344 | 1346 | 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 { | ||
| 722 | 722 | return owner === "" || name === "" ? "" : `${owner}/${name}`; |
| 723 | 723 | } |
| 724 | 724 | |
| 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 | ||
| 725 | 733 | function toRepo(raw: unknown): RemoteRepo { |
| 726 | 734 | const fullName = asString(field(raw, "full_name")); |
| 727 | 735 | const named = splitFullName(fullName); |
| @@ -734,6 +742,7 @@ function toRepo(raw: unknown): RemoteRepo { | ||
| 734 | 742 | private: asBool(field(raw, "private")), |
| 735 | 743 | fork: asBool(field(raw, "fork")), |
| 736 | 744 | parent: asString(dig(raw, "parent", "full_name")) || null, |
| 745 | ...rootOf(raw), | |
| 737 | 746 | defaultBranch: asString(field(raw, "default_branch")) || "main", |
| 738 | 747 | cloneUrl: asString(field(raw, "clone_url")), |
| 739 | 748 | sshUrl: asString(field(raw, "ssh_url")), |
src/origins/types.ts +5
| @@ -61,6 +61,11 @@ export interface RemoteRepo { | ||
| 61 | 61 | * keeps the value it already has rather than letting a listing clear it. |
| 62 | 62 | */ |
| 63 | 63 | 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; | |
| 64 | 69 | readonly defaultBranch: string; |
| 65 | 70 | readonly cloneUrl: string; |
| 66 | 71 | readonly sshUrl: string; |
src/tui/screens/about.ts +59 −1
| @@ -23,7 +23,18 @@ import { checkMemoryAt } from "../../apps/check-memory.ts"; | ||
| 23 | 23 | import { repoDir } from "../../git/repos.ts"; |
| 24 | 24 | import { listRefs } from "../../git/read.ts"; |
| 25 | 25 | 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"; | |
| 27 | 38 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 28 | 39 | import type { Ctx, Screen } from "../app/app.ts"; |
| 29 | 40 | import { binding } from "../app/keys.ts"; |
| @@ -54,9 +65,17 @@ export function aboutScreen( | ||
| 54 | 65 | const may = (): boolean => |
| 55 | 66 | repo !== undefined && f.user !== undefined && canAdmin(f.db, repo, f.user); |
| 56 | 67 | 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; | |
| 57 | 75 | const factRows = (): [string, string][] => [ |
| 58 | 76 | ...archivedRow(repo), |
| 59 | 77 | ...aboutFacts(f, src, facts, ref), |
| 78 | ...trailRows(f, repo), | |
| 60 | 79 | ...cell, |
| 61 | 80 | ]; |
| 62 | 81 | let rows = factRows(); |
| @@ -96,6 +115,16 @@ export function aboutScreen( | ||
| 96 | 115 | ), |
| 97 | 116 | ] |
| 98 | 117 | : []), |
| 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 | : []), | |
| 99 | 128 | // A site admin also reaches the repository's private egress grants. |
| 100 | 129 | ...(f.user?.isAdmin === true && repo !== undefined |
| 101 | 130 | ? [binding(["e"], "egress"), binding(["c"], "connections")] |
| @@ -205,6 +234,16 @@ export function aboutScreen( | ||
| 205 | 234 | }); |
| 206 | 235 | return true; |
| 207 | 236 | } |
| 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 | } | |
| 208 | 247 | if (e.key === "e" && repo !== undefined && f.user?.isAdmin === true) { |
| 209 | 248 | ctx.push(egressScreen(f, ctx, repo)); |
| 210 | 249 | return true; |
| @@ -222,6 +261,25 @@ export function aboutScreen( | ||
| 222 | 261 | }; |
| 223 | 262 | } |
| 224 | 263 | |
| 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 | ||
| 225 | 283 | /** One line when an admin has put the repository away. */ |
| 226 | 284 | function archivedRow(repo: Repo | undefined): [string, string][] { |
| 227 | 285 | if (repo === undefined || repo.archivedAt === null) return []; |
src/web/chrome.ts +41 −8
| @@ -22,6 +22,8 @@ import { | ||
| 22 | 22 | visibleForks, |
| 23 | 23 | } from "../core/repos.ts"; |
| 24 | 24 | import type { Repo } from "../core/repos.ts"; |
| 25 | import { parentUrl, shownTrail, sourceLabel } from "../core/provenance.ts"; | |
| 26 | import type { Database } from "bun:sqlite"; | |
| 25 | 27 | import type { Ctx } from "../http/context.ts"; |
| 26 | 28 | import { html, raw, when, type Html } from "./html.ts"; |
| 27 | 29 | import { icon } from "./icons.ts"; |
| @@ -322,14 +324,7 @@ export function scopeHeader( | ||
| 322 | 324 | !options.compact && facts.description !== "", |
| 323 | 325 | () => html`<p class="repo-desc">${facts.description}</p>`, |
| 324 | 326 | )} |
| 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)} | |
| 333 | 328 | ${when( |
| 334 | 329 | scope.upstream !== undefined && upstreamHref(scope) === undefined, |
| 335 | 330 | // The where strip carries "fork of octo/widgets" and links it, which |
| @@ -347,6 +342,44 @@ export function scopeHeader( | ||
| 347 | 342 | </header>`; |
| 348 | 343 | } |
| 349 | 344 | |
| 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 | ||
| 350 | 383 | /** |
| 351 | 384 | * Who can see this repository, in the one sentence `audienceOf` gives, beside |
| 352 | 385 | * 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 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| 12 | import { showProvenance } from "../../actions/provenance.ts"; | |
| 13 | import { getProvenance, parentUrl, sourceLabel } from "../../core/provenance.ts"; | |
| 12 | 14 | import { |
| 13 | 15 | deleteAppEnvVar, |
| 14 | 16 | disableApp, |
| @@ -351,6 +353,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 351 | 353 | { head: html`${icon("rocket")}<h2>Cut a wave</h2>` }, |
| 352 | 354 | ); |
| 353 | 355 | const proposals = proposalTokensSection(ctx, repo); |
| 356 | const trail = provenanceCard(ctx, repo); | |
| 354 | 357 | const archive = card( |
| 355 | 358 | html`<form method="post" action="${base}/settings/archive"> |
| 356 | 359 | <input type="hidden" name="archived" value="${repo.archivedAt === null ? "1" : "0"}" /> |
| @@ -388,7 +391,9 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 388 | 391 | repo, |
| 389 | 392 | "general", |
| 390 | 393 | "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 | |
| 392 | 397 | ? html`<div class="mt">${proposals}</div>` |
| 393 | 398 | : null}${secrets |
| 394 | 399 | ? html`<div class="mt">${secrets}</div>` |
| @@ -474,6 +479,62 @@ export async function archiveSubmit(ctx: Ctx): Promise<Response> { | ||
| 474 | 479 | ); |
| 475 | 480 | } |
| 476 | 481 | |
| 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. | |
| 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 | ||
| 477 | 538 | /** POST /:owner/:name/settings/mirror/stop — stop mirroring to the source. */ |
| 478 | 539 | export async function mirrorStop(ctx: Ctx): Promise<Response> { |
| 479 | 540 | const gate = await adminForm(ctx); |
test/fake-origin.ts +5
| @@ -41,6 +41,8 @@ export interface FakeRepo { | ||
| 41 | 41 | fork?: boolean; |
| 42 | 42 | /** `owner/name` this repo forks, as `getRepo` reports it. */ |
| 43 | 43 | parent?: string; |
| 44 | /** `owner/name` of the network's root: GitHub's `source`, not sent by Forgejo. */ | |
| 45 | root?: string; | |
| 44 | 46 | defaultBranch?: string; |
| 45 | 47 | cloneUrl: string; |
| 46 | 48 | htmlUrl?: string; |
| @@ -981,6 +983,9 @@ function repoJson(state: FakeState, repo: FakeRepo): Record<string, unknown> { | ||
| 981 | 983 | ...(repo.parent === undefined |
| 982 | 984 | ? {} |
| 983 | 985 | : { parent: { full_name: repo.parent } }), |
| 986 | ...(repo.root === undefined || state.kind !== "github" | |
| 987 | ? {} | |
| 988 | : { source: { full_name: repo.root } }), | |
| 984 | 989 | }; |
| 985 | 990 | return state.kind === "github" |
| 986 | 991 | ? { |
test/mcp.test.ts +1
| @@ -202,6 +202,7 @@ describe("the forge over MCP", () => { | ||
| 202 | 202 | "outsiders", |
| 203 | 203 | "decisions_by", |
| 204 | 204 | "archive", |
| 205 | "trail", | |
| 205 | 206 | "branches", |
| 206 | 207 | "commits", |
| 207 | 208 | "probe", |
test/migrate.test.ts +136
| @@ -41,6 +41,11 @@ import { | ||
| 41 | 41 | import { getAppByRepo } from "../src/core/apps.ts"; |
| 42 | 42 | import { listIssues } from "../src/core/issues.ts"; |
| 43 | 43 | import { listOrigins } from "../src/core/origins.ts"; |
| 44 | import { | |
| 45 | getProvenance, | |
| 46 | recordProvenance, | |
| 47 | setProvenanceShown, | |
| 48 | } from "../src/core/provenance.ts"; | |
| 44 | 49 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 45 | 50 | import { createUser, type User } from "../src/core/users.ts"; |
| 46 | 51 | import { repoDir } from "../src/git/repos.ts"; |
| @@ -272,6 +277,29 @@ describe("migrate", () => { | ||
| 272 | 277 | defaultBranch: "main", |
| 273 | 278 | private: true, |
| 274 | 279 | }); |
| 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 | }); | |
| 275 | 303 | forgejo = serveFakeForge(state); |
| 276 | 304 | forgetProviders(dataDir); |
| 277 | 305 | const connected = await browser(aliceCookie, "/settings/origins/token", { |
| @@ -309,6 +337,13 @@ describe("migrate", () => { | ||
| 309 | 337 | expect(job?.detail).toContain("3 branches, 2 tags"); |
| 310 | 338 | |
| 311 | 339 | 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 | }); | |
| 312 | 347 | const dir = repoDir(app.reposRoot, "bob", "fixture"); |
| 313 | 348 | // Every commit, every branch, every tag — the annotated one as itself. |
| 314 | 349 | expect(await allCommits(dir)).toEqual(await allCommits(sourceDir)); |
| @@ -539,6 +574,107 @@ describe("migrate", () => { | ||
| 539 | 574 | ); |
| 540 | 575 | }); |
| 541 | 576 | |
| 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 | ||
| 542 | 678 | test("one click from GitHub: the repository, 3 issues, 1 open pull request and a crew run on the app", async () => { |
| 543 | 679 | // Upstream: a private repository with a branch an open pull stands on. |
| 544 | 680 | 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 | }); |
new above
offline
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 usevariant: "outline"(orsecondary).