Public: anyone can read this; the crew can change it.
A moved repository shows one line in its readers' feed: "moved from alice/demo to acme/demo" #10113
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10100-a-moved-repository-shows-one-line-in-its → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +69
−0
docs/features/repo-transfer.md +4
| @@ -21,6 +21,10 @@ Who moved what from where to where is kept twice: an audit row in | ||
| 21 | 21 | `repo_transfer`, written in the move's own transaction and never rewritten, |
| 22 | 22 | and a `repo.transferred` event for the repository's readers. |
| 23 | 23 | |
| 24 | The event appears in their feed (and the dashboard, which shares it) as one | |
| 25 | line, "moved from alice/demo to acme/demo", linking to the new path. Someone | |
| 26 | who cannot read the repository never sees it. | |
| 27 | ||
| 24 | 28 | Old paths keep working: a web or API request under the old `owner/name` |
| 25 | 29 | answers `308` to the same path under the new owner, for reads and writes |
| 26 | 30 | alike (a write is never applied silently; the client re-sends it). Someone |
src/feed/items.ts +8
| @@ -725,6 +725,14 @@ function fromEvent( | ||
| 725 | 725 | link: `${base(repo)}/map`, |
| 726 | 726 | what: clip(typeof p["why"] === "string" ? p["why"] : "the crew's slots changed"), |
| 727 | 727 | }; |
| 728 | // A move is news to the repository's readers; the link is the new path. | |
| 729 | case "repo.transferred": { | |
| 730 | const from = typeof p["from"] === "string" ? p["from"] : ""; | |
| 731 | const to = typeof p["to"] === "string" ? p["to"] : ""; | |
| 732 | if (from === "" || to === "") return undefined; | |
| 733 | const what = `moved from ${clip(from, 60)} to ${clip(to, 60)}`; | |
| 734 | return { ...common, kind: "map", link: base(repo), what, said: what }; | |
| 735 | } | |
| 728 | 736 | default: |
| 729 | 737 | return undefined; |
| 730 | 738 | } |
test/feed-transferred.test.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** A moved repository is one line in its readers' feed, and not in a stranger's (#10100). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { transferRepository } from "../src/actions/transfer.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { readFeed } from "../src/feed/feed.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | let root = ""; | |
| 16 | let app: App; | |
| 17 | let alice: User; | |
| 18 | let bob: User; | |
| 19 | let carol: User; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | root = mkdtempSync(join(tmpdir(), "forge-feed-moved-")); | |
| 23 | app = createApp({ dataDir: root, port: 0 }); | |
| 24 | const make = async (username: string, admin: boolean): Promise<User> => { | |
| 25 | const u = await createUser(app.db, { | |
| 26 | origin: "seed", | |
| 27 | trusted: true, | |
| 28 | username, | |
| 29 | email: `${username}@forge.local`, | |
| 30 | password: "a-good-passphrase", | |
| 31 | adminIfFirst: admin, | |
| 32 | }); | |
| 33 | if (!u.ok) throw new Error(u.error); | |
| 34 | return u.value; | |
| 35 | }; | |
| 36 | alice = await make("alice", true); | |
| 37 | bob = await make("bob", false); | |
| 38 | carol = await make("carol", false); | |
| 39 | }); | |
| 40 | afterAll(() => { | |
| 41 | app.close(); | |
| 42 | rmSync(root, { recursive: true, force: true }); | |
| 43 | }); | |
| 44 | ||
| 45 | test("the move reads as one line for the new owner, and a stranger never sees it", async () => { | |
| 46 | const made = await createRepo(app, alice, { name: "demo", visibility: "private" }); | |
| 47 | if (!made.ok) throw new Error(String(made.error)); | |
| 48 | const moved = await transferRepository(app, alice, made.value, "bob"); | |
| 49 | if (!moved.ok) throw new Error(String(moved.error)); | |
| 50 | const lines = (u: User) => | |
| 51 | readFeed(app.db, u, { limit: 50 }).items.filter((i) => i.what.includes("moved from")); | |
| 52 | const mine = lines(bob); | |
| 53 | expect(mine).toHaveLength(1); | |
| 54 | expect(mine[0]!.what).toBe("moved from alice/demo to bob/demo"); | |
| 55 | expect(mine[0]!.link).toBe("/bob/demo"); | |
| 56 | expect(lines(carol)).toHaveLength(0); | |
| 57 | }); |
Fine as is. The feed and dashboard share this item, so both doors show it; the how-to in docs/features/repo-transfer.md needs a line saying so.