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

Merged opened this

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

Merged

scout/forge:scout/3976-a-repository-s-upstream-setting-is-store → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3976-a-repository-s-upstream-setting-is-store was merged into main as 831b5d1.

Review

Sign in to review this change.
6 files changed +363 −0
src/actions/migrate.ts +21
@@ -68,6 +68,7 @@ import {
6868 type CreateRepoActionError,
6969 } from "./repos.ts";
7070 import { tell, toldWords } from "./tell.ts";
71 import { putFollow, upstreamUrl } from "../core/upstream_follow.ts";
7172 import { pinFor, remoteGitEnv, type Pin } from "../git/remote-env.ts";
7273
7374 /** The whole clone — the HEAD probe and the fetch — gets this long. */
@@ -142,6 +143,8 @@ interface Resolved {
142143 readonly provider?: Provider;
143144 /** The person, as the source's commits may name them. */
144145 readonly who: Who;
146 /** The source's own parent on its host, when it is a fork. */
147 readonly parentUrl?: string;
145148 }
146149
147150 /** Why the work branch is the one it is: said in the job and the brief. */
@@ -443,6 +446,9 @@ export async function resolveSource(
443446 authEnv: () => provider.value.gitAuthEnv(url),
444447 provider: provider.value,
445448 who: { logins: [origin.login, user.username], emails: [user.email] },
449 ...(remote.value.parent === null
450 ? {}
451 : { parentUrl: `${origin.webUrl.replace(/\/+$/, "")}/${remote.value.parent}` }),
446452 });
447453 }
448454
@@ -561,6 +567,21 @@ async function runMigration(
561567 });
562568 }
563569
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
564585 const where = `${current.ownerName}/${current.name}`;
565586 let detail = `migrated as ${where} — ${plural(branches.length, "branch", "branches")}, ${plural(refs.size - branches.length, "tag")}${conversations}`;
566587 if (work !== undefined)
src/core/event_audience.ts +3
@@ -160,6 +160,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
160160 // Which of a person's connected accounts a mirror syncs through is too.
161161 kind.startsWith("origin.") ||
162162 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" ||
163166 kind === "repo.crew_outsiders"
164167 )
165168 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");

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.

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";
4848 import { createToken } from "../src/core/tokens.ts";
4949 import { createSession } from "../src/core/sessions.ts";
5050 import { advance, createJob, settled } from "../src/origins/jobs.ts";
51 import { getFollow } from "../src/core/upstream_follow.ts";
5152 import { forgetProviders } from "../src/origins/providers/index.ts";
5253 import {
5354 newFakeOAuth,
@@ -468,6 +469,9 @@ describe("migrate", () => {
468469 ]);
469470 expect(getAppByRepo(app.db, repo.id)).toBeDefined();
470471 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]);
471475
472476 const issues = listIssues(app.db, repo.id, {
473477 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs