Public: anyone can read this; the crew can change it.
A repository's upstream setting is stored: https or http only, git:// refused, seen only by admins #3994
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3976-a-repository-s-upstream-setting-is-store → main 1 commit fork 1 thread · 1 unresolved
6 files
changed +363
−0
src/actions/migrate.ts +21
| @@ -68,6 +68,7 @@ import { | ||
| 68 | 68 | type CreateRepoActionError, |
| 69 | 69 | } from "./repos.ts"; |
| 70 | 70 | import { tell, toldWords } from "./tell.ts"; |
| 71 | import { putFollow, upstreamUrl } from "../core/upstream_follow.ts"; | |
| 71 | 72 | import { pinFor, remoteGitEnv, type Pin } from "../git/remote-env.ts"; |
| 72 | 73 | |
| 73 | 74 | /** The whole clone — the HEAD probe and the fetch — gets this long. */ |
| @@ -142,6 +143,8 @@ interface Resolved { | ||
| 142 | 143 | readonly provider?: Provider; |
| 143 | 144 | /** The person, as the source's commits may name them. */ |
| 144 | 145 | readonly who: Who; |
| 146 | /** The source's own parent on its host, when it is a fork. */ | |
| 147 | readonly parentUrl?: string; | |
| 145 | 148 | } |
| 146 | 149 | |
| 147 | 150 | /** Why the work branch is the one it is: said in the job and the brief. */ |
| @@ -443,6 +446,9 @@ export async function resolveSource( | ||
| 443 | 446 | authEnv: () => provider.value.gitAuthEnv(url), |
| 444 | 447 | provider: provider.value, |
| 445 | 448 | who: { logins: [origin.login, user.username], emails: [user.email] }, |
| 449 | ...(remote.value.parent === null | |
| 450 | ? {} | |
| 451 | : { parentUrl: `${origin.webUrl.replace(/\/+$/, "")}/${remote.value.parent}` }), | |
| 446 | 452 | }); |
| 447 | 453 | } |
| 448 | 454 | |
| @@ -561,6 +567,21 @@ async function runMigration( | ||
| 561 | 567 | }); |
| 562 | 568 | } |
| 563 | 569 | |
| 570 | // The source becomes the upstream this repository follows (#3442): on | |
| 571 | // when the crew runs it on its own, off — but filled in — otherwise. | |
| 572 | // Only an https or http source can be followed. | |
| 573 | const followUrl = upstreamUrl(source.url); | |
| 574 | if (followUrl !== undefined) | |
| 575 | putFollow(app.db, { | |
| 576 | repoId: current.id, | |
| 577 | url: followUrl, | |
| 578 | branch: cloned.value.head ?? work?.name ?? current.defaultBranch, | |
| 579 | enabled: crew?.autonomous === true, | |
| 580 | byId: user.id, | |
| 581 | originId: source.originId, | |
| 582 | ...(source.parentUrl === undefined ? {} : { parentUrl: source.parentUrl }), | |
| 583 | }); | |
| 584 | ||
| 564 | 585 | const where = `${current.ownerName}/${current.name}`; |
| 565 | 586 | let detail = `migrated as ${where} — ${plural(branches.length, "branch", "branches")}, ${plural(refs.size - branches.length, "tag")}${conversations}`; |
| 566 | 587 | if (work !== undefined) |
src/core/event_audience.ts +3
| @@ -160,6 +160,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 160 | 160 | // Which of a person's connected accounts a mirror syncs through is too. |
| 161 | 161 | kind.startsWith("origin.") || |
| 162 | 162 | kind === "repo.settings_updated" || |
| 163 | // Where a repository follows is its admins' to read, off or on: the | |
| 164 | // address is often a private repository's. | |
| 165 | kind === "repo.upstream_follow" || | |
| 163 | 166 | kind === "repo.crew_outsiders" |
| 164 | 167 | ) |
| 165 | 168 | return "admins"; |
src/core/migrations/20261022100000-repo-upstream.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | // A repository follows an outside upstream (#3442): the https or http URL and | |
| 2 | // branch it takes new commits from, whether it is on, who it acts as, the | |
| 3 | // origin whose credential opens a private one, and what the last check read. | |
| 4 | // `checking` is the claim one check holds while it runs. Additive. | |
| 5 | export default ` | |
| 6 | CREATE TABLE IF NOT EXISTS repo_upstream ( | |
| 7 | repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE, | |
| 8 | url TEXT NOT NULL, | |
| 9 | branch TEXT NOT NULL, | |
| 10 | enabled INTEGER NOT NULL DEFAULT 0, | |
| 11 | by_id TEXT NOT NULL, | |
| 12 | origin_id TEXT NOT NULL DEFAULT '', | |
| 13 | parent_url TEXT NOT NULL DEFAULT '', | |
| 14 | checked_at TEXT, | |
| 15 | tip TEXT NOT NULL DEFAULT '', | |
| 16 | behind INTEGER NOT NULL DEFAULT 0, | |
| 17 | error TEXT NOT NULL DEFAULT '', | |
| 18 | pending_id TEXT, | |
| 19 | opened INTEGER NOT NULL DEFAULT 0, | |
| 20 | checking TEXT, | |
| 21 | updated_at TEXT NOT NULL | |
| 22 | ); | |
| 23 | `; |
src/core/upstream_follow.ts added +182
| @@ -0,0 +1,182 @@ | ||
| 1 | /** | |
| 2 | * A repository following an outside upstream (#3442): the git URL and | |
| 3 | * branch new commits come from, whether it is on, who the forge acts as | |
| 4 | * when it proposes them, and what the last check read. The commits | |
| 5 | * themselves are git's (`refs/upstream-follow/<branch>` in the repository); this | |
| 6 | * row is the setting and the last reading, rebuilt by the next check. | |
| 7 | * | |
| 8 | * Pure data, no authz, no events. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | ||
| 13 | export interface UpstreamFollow { | |
| 14 | readonly repoId: string; | |
| 15 | /** A git URL git may be handed (`migrateUrl`). */ | |
| 16 | readonly url: string; | |
| 17 | readonly branch: string; | |
| 18 | readonly enabled: boolean; | |
| 19 | /** The person the forge acts as: who turned it on. */ | |
| 20 | readonly byId: string; | |
| 21 | /** The connected origin whose credential opens a private upstream; "" for none. */ | |
| 22 | readonly originId: string; | |
| 23 | /** The upstream's own parent, when its host said it is a fork: the second choice. */ | |
| 24 | readonly parentUrl: string; | |
| 25 | readonly checkedAt: string | null; | |
| 26 | /** The upstream tip the last check read; "" before the first. */ | |
| 27 | readonly tip: string; | |
| 28 | /** Commits on the upstream branch the default branch does not hold. */ | |
| 29 | readonly behind: number; | |
| 30 | /** Why the last check could not finish; "" when it did. */ | |
| 31 | readonly error: string; | |
| 32 | /** The pull or issue carrying the last proposal, while it is open. */ | |
| 33 | readonly pendingId: string | null; | |
| 34 | /** How many proposals the forge has opened for this repository. */ | |
| 35 | readonly opened: number; | |
| 36 | /** When a check took the claim, or null when none is running. */ | |
| 37 | readonly checking: string | null; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * An address a follow may hold: `https:` or `http:` only, no credentials. | |
| 42 | * `git:` resolves the name itself, past the pin; `ssh:`, `file:` and the | |
| 43 | * rest are not fetches the forge makes for a stranger. Returns the | |
| 44 | * normalised URL, or `undefined`. | |
| 45 | */ | |
| 46 | export function upstreamUrl(raw: string): string | undefined { | |
| 47 | let parsed: URL; | |
| 48 | try { | |
| 49 | parsed = new URL(raw.trim()); | |
| 50 | } catch { | |
| 51 | return undefined; | |
| 52 | } | |
| 53 | if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined; | |
| 54 | if (parsed.hostname === "" || parsed.username !== "" || parsed.password !== "") | |
| 55 | return undefined; | |
| 56 | return parsed.toString(); | |
| 57 | } | |
| 58 | ||
| 59 | interface Row { | |
| 60 | repo_id: string; | |
| 61 | url: string; | |
| 62 | branch: string; | |
| 63 | enabled: number; | |
| 64 | by_id: string; | |
| 65 | origin_id: string; | |
| 66 | parent_url: string; | |
| 67 | checked_at: string | null; | |
| 68 | tip: string; | |
| 69 | behind: number; | |
| 70 | error: string; | |
| 71 | pending_id: string | null; | |
| 72 | opened: number; | |
| 73 | checking: string | null; | |
| 74 | } | |
| 75 | ||
| 76 | const fromRow = (r: Row): UpstreamFollow => ({ | |
| 77 | repoId: r.repo_id, | |
| 78 | url: r.url, | |
| 79 | branch: r.branch, | |
| 80 | enabled: r.enabled === 1, | |
| 81 | byId: r.by_id, | |
| 82 | originId: r.origin_id, | |
| 83 | parentUrl: r.parent_url, | |
| 84 | checkedAt: r.checked_at, | |
| 85 | tip: r.tip, | |
| 86 | behind: r.behind, | |
| 87 | error: r.error, | |
| 88 | pendingId: r.pending_id, | |
| 89 | opened: r.opened, | |
| 90 | checking: r.checking, | |
| 91 | }); | |
| 92 | ||
| 93 | export function getFollow(db: Database, repoId: string): UpstreamFollow | undefined { | |
| 94 | const row = db | |
| 95 | .query<Row, [string]>("SELECT * FROM repo_upstream WHERE repo_id = ?1") | |
| 96 | .get(repoId); | |
| 97 | return row ? fromRow(row) : undefined; | |
| 98 | } | |
| 99 | ||
| 100 | /** Every repository whose following is on. */ | |
| 101 | export function listFollowing(db: Database): UpstreamFollow[] { | |
| 102 | return db | |
| 103 | .query<Row, []>("SELECT * FROM repo_upstream WHERE enabled = 1 ORDER BY repo_id") | |
| 104 | .all() | |
| 105 | .map(fromRow); | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * Set where a repository follows and whether it does. A new URL or branch | |
| 110 | * forgets the last reading — it was of another upstream. | |
| 111 | */ | |
| 112 | export function putFollow( | |
| 113 | db: Database, | |
| 114 | input: { | |
| 115 | readonly repoId: string; | |
| 116 | readonly url: string; | |
| 117 | readonly branch: string; | |
| 118 | readonly enabled: boolean; | |
| 119 | readonly byId: string; | |
| 120 | readonly originId: string; | |
| 121 | readonly parentUrl?: string; | |
| 122 | }, | |
| 123 | ): UpstreamFollow { | |
| 124 | if (upstreamUrl(input.url) === undefined) | |
| 125 | throw new Error("an upstream is an https or http address with no credentials"); | |
| 126 | const now = new Date().toISOString(); | |
| 127 | const was = getFollow(db, input.repoId); | |
| 128 | const moved = was !== undefined && (was.url !== input.url || was.branch !== input.branch); | |
| 129 | db.query( | |
| 130 | `INSERT INTO repo_upstream (repo_id, url, branch, enabled, by_id, origin_id, parent_url, updated_at) | |
| 131 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) | |
| 132 | ON CONFLICT(repo_id) DO UPDATE SET url = ?2, branch = ?3, enabled = ?4, | |
| 133 | by_id = ?5, origin_id = ?6, parent_url = CASE WHEN ?7 = '' THEN parent_url ELSE ?7 END, | |
| 134 | updated_at = ?8`, | |
| 135 | ).run( | |
| 136 | input.repoId, | |
| 137 | input.url, | |
| 138 | input.branch, | |
| 139 | input.enabled ? 1 : 0, | |
| 140 | input.byId, | |
| 141 | input.originId, | |
| 142 | input.parentUrl ?? "", | |
| 143 | now, | |
| 144 | ); | |
| 145 | if (moved) | |
| 146 | db.query( | |
| 147 | "UPDATE repo_upstream SET checked_at = NULL, tip = '', behind = 0, error = '' WHERE repo_id = ?1", | |
| 148 | ).run(input.repoId); | |
| 149 | return getFollow(db, input.repoId)!; | |
| 150 | } | |
| 151 | ||
| 152 | /** What one check read. `pendingId` is left alone when absent. */ | |
| 153 | export function noteCheck( | |
| 154 | db: Database, | |
| 155 | repoId: string, | |
| 156 | reading: { | |
| 157 | readonly at: string; | |
| 158 | readonly tip?: string; | |
| 159 | readonly behind?: number; | |
| 160 | readonly error: string; | |
| 161 | readonly pendingId?: string | null; | |
| 162 | /** A proposal was opened by this check. */ | |
| 163 | readonly opened?: boolean; | |
| 164 | }, | |
| 165 | ): void { | |
| 166 | db.query( | |
| 167 | `UPDATE repo_upstream SET checked_at = ?2, | |
| 168 | tip = COALESCE(?3, tip), behind = COALESCE(?4, behind), error = ?5, | |
| 169 | pending_id = CASE WHEN ?6 = 1 THEN ?7 ELSE pending_id END, | |
| 170 | opened = opened + ?8 | |
| 171 | WHERE repo_id = ?1`, | |
| 172 | ).run( | |
| 173 | repoId, | |
| 174 | reading.at, | |
| 175 | reading.tip ?? null, | |
| 176 | reading.behind ?? null, | |
| 177 | reading.error, | |
| 178 | reading.pendingId === undefined ? 0 : 1, | |
| 179 | reading.pendingId ?? null, | |
| 180 | reading.opened === true ? 1 : 0, | |
| 181 | ); | |
| 182 | } |
test/migrate.test.ts +4
| @@ -48,6 +48,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 48 | 48 | import { createToken } from "../src/core/tokens.ts"; |
| 49 | 49 | import { createSession } from "../src/core/sessions.ts"; |
| 50 | 50 | import { advance, createJob, settled } from "../src/origins/jobs.ts"; |
| 51 | import { getFollow } from "../src/core/upstream_follow.ts"; | |
| 51 | 52 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 52 | 53 | import { |
| 53 | 54 | newFakeOAuth, |
| @@ -468,6 +469,9 @@ describe("migrate", () => { | ||
| 468 | 469 | ]); |
| 469 | 470 | expect(getAppByRepo(app.db, repo.id)).toBeDefined(); |
| 470 | 471 | expect(repo.autonomy).toBe(autonomous ? "autonomous" : "off"); |
| 472 | // The source is the upstream it follows: on when autonomous (#3442). | |
| 473 | const follow = getFollow(app.db, repo.id)!; | |
| 474 | expect([follow.url, follow.branch, follow.enabled]).toEqual([fixtureUrl, "trunk", autonomous]); | |
| 471 | 475 | |
| 472 | 476 | const issues = listIssues(app.db, repo.id, { |
| 473 | 477 | kind: "issue", |
test/upstream_follow.test.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** | |
| 2 | * A repository's upstream setting (#3442, layer 1): stored as a row, https | |
| 3 | * or http only, and an admin's to read. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { audienceFilter } from "../src/core/event_audience.ts"; | |
| 13 | import { listEvents } from "../src/core/events.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { | |
| 17 | getFollow, | |
| 18 | listFollowing, | |
| 19 | noteCheck, | |
| 20 | putFollow, | |
| 21 | upstreamUrl, | |
| 22 | } from "../src/core/upstream_follow.ts"; | |
| 23 | ||
| 24 | let dataDir = ""; | |
| 25 | let app: App; | |
| 26 | let alice: User; | |
| 27 | let guest: User; | |
| 28 | let repo: Repo; | |
| 29 | ||
| 30 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 31 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 32 | return r.value; | |
| 33 | }; | |
| 34 | ||
| 35 | beforeAll(async () => { | |
| 36 | dataDir = await mkdtemp(join(tmpdir(), "forge-upstream-follow-")); | |
| 37 | app = createApp({ dataDir, port: 0 }); | |
| 38 | const user = (username: string, extra: object = {}) => | |
| 39 | createUser(app.db, { | |
| 40 | origin: "seed", | |
| 41 | trusted: true, | |
| 42 | username, | |
| 43 | email: `${username}@forge.local`, | |
| 44 | password: "a-good-passphrase", | |
| 45 | ...extra, | |
| 46 | }); | |
| 47 | alice = ok(await user("alice", { adminIfFirst: true })); | |
| 48 | guest = ok(await user("guest")); | |
| 49 | repo = ok(await createRepo(app, alice, { name: "square", visibility: "public" })); | |
| 50 | }); | |
| 51 | ||
| 52 | afterAll(async () => { | |
| 53 | app?.close(); | |
| 54 | await rm(dataDir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | describe("upstreamUrl", () => { | |
| 58 | test("https and http are accepted", () => { | |
| 59 | expect(upstreamUrl("https://example.com/a/b.git")).toBe("https://example.com/a/b.git"); | |
| 60 | expect(upstreamUrl("http://example.com/a/b")).toBe("http://example.com/a/b"); | |
| 61 | }); | |
| 62 | ||
| 63 | for (const bad of [ | |
| 64 | "git://example.com/a/b.git", | |
| 65 | "ssh://[email protected]/a/b.git", | |
| 66 | "file:///etc/passwd", | |
| 67 | "https://user:[email protected]/a/b", | |
| 68 | "https://[email protected]/a/b", | |
| 69 | "ext::sh -c id", | |
| 70 | "example.com/a/b", | |
| 71 | "", | |
| 72 | ]) | |
| 73 | test(`refuses ${JSON.stringify(bad)}`, () => { | |
| 74 | expect(upstreamUrl(bad)).toBeUndefined(); | |
| 75 | }); | |
| 76 | ||
| 77 | test("putFollow will not store one either", () => { | |
| 78 | expect(() => | |
| 79 | putFollow(app.db, { | |
| 80 | repoId: repo.id, | |
| 81 | url: "git://example.com/a/b.git", | |
| 82 | branch: "main", | |
| 83 | enabled: true, | |
| 84 | byId: alice.id, | |
| 85 | originId: "", | |
| 86 | }), | |
| 87 | ).toThrow(); | |
| 88 | expect(getFollow(app.db, repo.id)).toBeUndefined(); | |
| 89 | }); | |
| 90 | }); | |
| 91 | ||
| 92 | describe("the follow row", () => { | |
| 93 | test("written, read, listed when on, and a new address forgets the last reading", () => { | |
| 94 | const put = (url: string, enabled: boolean) => | |
| 95 | putFollow(app.db, { repoId: repo.id, url, branch: "main", enabled, byId: alice.id, originId: "" }); | |
| 96 | const first = put("https://example.com/a/b.git", false); | |
| 97 | expect([first.url, first.enabled, first.checking, first.checkedAt]).toEqual([ | |
| 98 | "https://example.com/a/b.git", | |
| 99 | false, | |
| 100 | null, | |
| 101 | null, | |
| 102 | ]); | |
| 103 | expect(listFollowing(app.db)).toEqual([]); | |
| 104 | put("https://example.com/a/b.git", true); | |
| 105 | noteCheck(app.db, repo.id, { at: "2026-10-21T00:00:00.000Z", tip: "abc", behind: 3, error: "" }); | |
| 106 | expect(getFollow(app.db, repo.id)).toMatchObject({ enabled: true, tip: "abc", behind: 3 }); | |
| 107 | expect(listFollowing(app.db).map((f) => f.repoId)).toEqual([repo.id]); | |
| 108 | expect(put("https://example.com/c/d.git", true)).toMatchObject({ | |
| 109 | checkedAt: null, | |
| 110 | tip: "", | |
| 111 | behind: 0, | |
| 112 | }); | |
| 113 | }); | |
| 114 | }); | |
| 115 | ||
| 116 | describe("who hears of it", () => { | |
| 117 | test("a guest's events do not carry the URL; an admin's do", () => { | |
| 118 | const secret = "https://private.example.com/secret/repo.git"; | |
| 119 | app.bus.append({ | |
| 120 | kind: "repo.upstream_follow", | |
| 121 | repoId: repo.id, | |
| 122 | actorId: alice.id, | |
| 123 | payload: { url: secret, enabled: true }, | |
| 124 | }); | |
| 125 | const seen = (viewer: User) => | |
| 126 | JSON.stringify(listEvents(app.db, { repoId: repo.id }).filter(audienceFilter(app.db, viewer))); | |
| 127 | expect(seen(alice)).toContain(secret); | |
| 128 | expect(seen(guest)).not.toContain(secret); | |
| 129 | }); | |
| 130 | }); |
new above
offline
A re-migrate calls putFollow with enabled from the crew and overwrites an admin's choice to turn following on or off. Once a settings door exists, keep the existing enabled value when a row is already there.