Public: anyone can read this; the crew can change it.
When an admin lands a vision pull, only the fenced vision block of the direction is rewritten, and the owner's own words are kept word for word #6230
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5834-when-an-admin-lands-a-vision-pull-only-t → main 1 commit fork 2 threads · 2 unresolved
6 files
changed +284
−0
src/actions/pulls.ts +3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { resolveOpen } from "../core/decisions.ts"; |
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | import { landVision } from "../crew/vision.ts"; | |
| 9 | 10 | import { isCrew } from "../core/agents.ts"; |
| 10 | 11 | import { |
| 11 | 12 | getIssueById, |
| @@ -435,6 +436,8 @@ export async function mergePull( | ||
| 435 | 436 | mergeOid: merged.value.mergeOid, |
| 436 | 437 | }, |
| 437 | 438 | }); |
| 439 | // A vision pull, landed by an admin, rewrites the direction's fenced block. | |
| 440 | await landVision(app, writer.value, repo, pull); | |
| 438 | 441 | // What the pull said it closes, closes: the issue is done when the change |
| 439 | 442 | // lands, as the person who landed it — which is what frees the map. Not |
| 440 | 443 | // the plan's own issues: no change finishes an umbrella or opens the |
src/core/event_audience.ts +1
| @@ -112,6 +112,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 112 | 112 | "board.saved", |
| 113 | 113 | "board.deleted", |
| 114 | 114 | "repo.created", |
| 115 | "repo.vision_kept", | |
| 115 | 116 | "repo.forked", |
| 116 | 117 | "repo.deleted", |
| 117 | 118 | "label.updated", |
src/core/placements.ts +8
| @@ -65,6 +65,14 @@ export function placementOf(db: Database, thoughtId: string): Placement | undefi | ||
| 65 | 65 | return row ? toPlacement(row) : undefined; |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | /** Whether a placement of kind `vision` proposed pull `number` of `repo` (owner/name): the one mark of a vision pull. */ | |
| 69 | export function isVisionPull(db: Database, repo: string, number: number): boolean { | |
| 70 | const rows = db | |
| 71 | .query<Row, [string]>("SELECT * FROM placement WHERE kind = 'vision' AND placed LIKE ?1") | |
| 72 | .all(`%"pull":${number}%`); | |
| 73 | return rows.some((r) => toPlacement(r).placed.some((p) => p.action === "proposed" && p.repo === repo && p.pull === number)); | |
| 74 | } | |
| 75 | ||
| 68 | 76 | export function placementsOf(db: Database, thoughtIds: readonly string[]): Map<string, Placement> { |
| 69 | 77 | const out = new Map<string, Placement>(); |
| 70 | 78 | for (const id of thoughtIds) { |
src/crew/vision.ts +91
| @@ -7,6 +7,10 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | import { isVisionPull } from "../core/placements.ts"; | |
| 11 | import { parseVision, type Vision } from "../core/vision.ts"; | |
| 12 | import { canAdmin, getRepoById, updateRepoSettings } from "../core/repos.ts"; | |
| 13 | import { createLog } from "../kernel/log.ts"; | |
| 10 | 14 | import { editFiles } from "../actions/contents.ts"; |
| 11 | 15 | import type { Pull } from "../core/pulls.ts"; |
| 12 | 16 | import type { Repo } from "../core/repos.ts"; |
| @@ -14,6 +18,8 @@ import type { User } from "../core/users.ts"; | ||
| 14 | 18 | import { listTree, readerFor } from "../git/read.ts"; |
| 15 | 19 | import { repoDir } from "../git/repos.ts"; |
| 16 | 20 | |
| 21 | const log = createLog("vision"); | |
| 22 | ||
| 17 | 23 | export const VISION_PATH = "docs/VISION.md"; |
| 18 | 24 | export const SECTION = "Changes of direction"; |
| 19 | 25 | const LINE_SHOWN = 240; |
| @@ -77,3 +83,88 @@ export async function proposeVision( | ||
| 77 | 83 | if (!edited.ok || !edited.value.pull) return { ok: false, why: "the edit could not be opened" }; |
| 78 | 84 | return { ok: true, pull: edited.value.pull }; |
| 79 | 85 | } |
| 86 | ||
| 87 | /** The fenced block's markers; the owner's words outside them are never touched. */ | |
| 88 | export const BLOCK_START = "<!-- forge:vision:start -->"; | |
| 89 | export const BLOCK_END = "<!-- forge:vision:end -->"; | |
| 90 | export const DIRECTION_LIMIT = 2000; | |
| 91 | ||
| 92 | const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s); | |
Share this limit with the settings validator instead of repeating 2000. |
||
| 93 | ||
| 94 | /** The block for a vision, markers included, no longer than `room` characters; null when not even its head fits. */ | |
| 95 | export function visionBlock(vision: Vision, room = DIRECTION_LIMIT): string | null { | |
| 96 | const flat = (s: string): string => s.replace(/\s+/g, " ").trim(); | |
| 97 | const parts: string[] = []; | |
| 98 | if (vision.purpose) parts.push(`Purpose: ${clip(flat(vision.purpose), 400)}`); | |
| 99 | if (vision.order.length > 0) parts.push(`Order of work: ${vision.order.map((o) => `${o.n}. ${o.name}`).join("; ")}.`); | |
| 100 | for (const rule of vision.rules) parts.push(`Rule: ${clip(flat(rule), 160)}`); | |
| 101 | const kept: string[] = []; | |
| 102 | for (const part of parts) { | |
| 103 | const next = `${BLOCK_START}\n${[...kept, part].join("\n")}\n${BLOCK_END}`; | |
| 104 | if (next.length > room) break; | |
| 105 | kept.push(part); | |
| 106 | } | |
| 107 | return kept.length === 0 ? null : `${BLOCK_START}\n${kept.join("\n")}\n${BLOCK_END}`; | |
| 108 | } | |
| 109 | ||
| 110 | const count = (s: string, needle: string): number => s.split(needle).length - 1; | |
| 111 | ||
| 112 | /** | |
| 113 | * `direction` with the fenced block regenerated, everything outside it kept | |
| 114 | * byte for byte. No block yet: it is appended after the owner's words. An | |
| 115 | * orphan, doubled or reversed marker changes nothing and says why. | |
| 116 | */ | |
| 117 | export function withVisionBlock( | |
| 118 | direction: string, | |
| 119 | vision: Vision, | |
| 120 | ): { ok: true; text: string } | { ok: false; why: string } { | |
| 121 | const starts = count(direction, BLOCK_START); | |
| 122 | const ends = count(direction, BLOCK_END); | |
| 123 | const from = direction.indexOf(BLOCK_START); | |
| 124 | const to = direction.indexOf(BLOCK_END); | |
| 125 | if (starts !== ends || starts > 1) return { ok: false, why: "the start and end markers do not pair up" }; | |
| 126 | if (starts === 1 && to < from) return { ok: false, why: "the end marker comes before the start marker" }; | |
| 127 | if (starts === 0) { | |
| 128 | const sep = direction === "" ? "" : direction.endsWith("\n\n") ? "" : direction.endsWith("\n") ? "\n" : "\n\n"; | |
| 129 | const block = visionBlock(vision, DIRECTION_LIMIT - direction.length - sep.length); | |
| 130 | return block === null ? { ok: false, why: "the owner's words leave no room for the block" } : { ok: true, text: `${direction}${sep}${block}` }; | |
| 131 | } | |
| 132 | const before = direction.slice(0, from); | |
| 133 | const after = direction.slice(to + BLOCK_END.length); | |
| 134 | const block = visionBlock(vision, DIRECTION_LIMIT - before.length - after.length); | |
| 135 | return block === null ? { ok: false, why: "the owner's words leave no room for the block" } : { ok: true, text: `${before}${block}${after}` }; | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * After a pull lands: when it is a vision pull (its placement says so, never | |
| 140 | * its branch name) and the merger administers the repository, regenerate the | |
| 141 | * fenced block of the direction from the landed VISION.md. Anyone else's merge | |
| 142 | * leaves the direction alone and writes a `repo` event the pull page shows. | |
| 143 | */ | |
| 144 | export async function landVision(app: App, merger: User, repo: Repo, pull: Pull): Promise<void> { | |
| 145 | if (!isVisionPull(app.db, `${repo.ownerName}/${repo.name}`, pull.number)) return; | |
| 146 | if (!canAdmin(app.db, repo, merger)) { | |
| 147 | app.bus.append({ | |
| 148 | kind: "repo.vision_kept", | |
| 149 | repoId: repo.id, | |
| 150 | payload: { pull: pull.number, why: "an admin's merge rewrites it" }, | |
| 151 | }); | |
| 152 | return; | |
| 153 | } | |
| 154 | const fresh = getRepoById(app.db, repo.id) ?? repo; | |
| 155 | const md = await readVision(app, fresh); | |
| 156 | if (md === null) return; | |
| 157 | const next = withVisionBlock(fresh.autonomyDirection, parseVision(md)); | |
| 158 | if (!next.ok) { | |
| 159 | log.warn("left the direction unchanged", { repo: `${repo.ownerName}/${repo.name}`, pull: pull.number, why: next.why }); | |
| 160 | return; | |
| 161 | } | |
| 162 | if (next.text === fresh.autonomyDirection) return; | |
| 163 | updateRepoSettings(app.db, fresh, { autonomyDirection: next.text }); | |
| 164 | app.bus.append({ | |
| 165 | kind: "repo.settings_updated", | |
| 166 | repoId: repo.id, | |
| 167 | actorId: merger.id, | |
| 168 | payload: { changed: ["autonomyDirection"], pull: pull.number }, | |
| 169 | }); | |
| 170 | } |
src/web/pages/thread.ts +15
| @@ -231,6 +231,20 @@ function interruptionEntries(ctx: Ctx, issue: Issue): Entry[] { | ||
| 231 | 231 | })); |
| 232 | 232 | } |
| 233 | 233 | |
| 234 | /** The forge's own line where a vision pull landed without an admin: the direction was kept as it was. */ | |
| 235 | function forgeEntries(ctx: Ctx, repo: Repo, issue: Issue): Entry[] { | |
| 236 | if (issue.kind !== "pull") return []; | |
| 237 | return listEvents(ctx.app.db, { repoId: repo.id, kind: "repo.vision_kept", limit: 50 }) | |
| 238 | .filter((e) => e.payload["pull"] === issue.number) | |
| 239 | .map((e) => ({ | |
| 240 | at: e.createdAt, | |
| 241 | node: tlEvent({ | |
| 242 | icon: "merge", | |
| 243 | body: html`<span>the forge kept the repository's direction as it was: only ${String(e.payload["why"] ?? "an admin's merge rewrites it")}</span> ${time(e.createdAt)}`, | |
| 244 | }), | |
| 245 | })); | |
| 246 | } | |
| 247 | ||
| 234 | 248 | export interface ConversationOptions { |
| 235 | 249 | /** The verb on the opening card ("opened this", "wants to merge …"). */ |
| 236 | 250 | openVerb: Html | string; |
| @@ -317,6 +331,7 @@ export function conversation( | ||
| 317 | 331 | })); |
| 318 | 332 | entries.push(...stateEvents(ctx, repo, issue)); |
| 319 | 333 | entries.push(...interruptionEntries(ctx, issue)); |
| 334 | entries.push(...forgeEntries(ctx, repo, issue)); | |
| 320 | 335 | entries.push(...(options.extra ?? [])); |
| 321 | 336 | entries.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)); |
| 322 | 337 | // The opening post and the newest THREAD_WINDOW comments with the state |
test/vision-land.test.ts added +166
| @@ -0,0 +1,166 @@ | ||
| 1 | /** | |
| 2 | * Landing a vision pull (#5834): an admin's merge regenerates the fenced | |
| 3 | * block of the direction and nothing else; anyone else's merge leaves it and | |
| 4 | * the pull page says so in the forge's own line. | |
| 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 { addTeamMember, createOrg, createTeam } from "../src/actions/orgs.ts"; | |
| 12 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { listEvents } from "../src/core/events.ts"; | |
| 15 | import { getOrg } from "../src/core/orgs.ts"; | |
| 16 | import { savePlacement } from "../src/core/placements.ts"; | |
| 17 | import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 18 | import type { User } from "../src/core/users.ts"; | |
| 19 | import { BLOCK_END, BLOCK_START, DIRECTION_LIMIT, withVisionBlock } from "../src/crew/vision.ts"; | |
| 20 | import { parseVision } from "../src/core/vision.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { promote } from "./helpers/members.ts"; | |
| 23 | import { signUpProven as signUp } from "./helpers/verified.ts"; | |
| 24 | ||
| 25 | const MD = `# Vision | |
| 26 | ||
| 27 | The forge is a social place for building. | |
| 28 | ||
| 29 | ## Order of work | |
| 30 | 0. **Trust, always first.** Agents are contained. | |
| 31 | 1. **One front door: the feed.** Home is a timeline. | |
| 32 | ||
| 33 | ## How we build | |
| 34 | - A deterministic tool with deliberate noise: actions are explicit. | |
| 35 | - Done means reachable. | |
| 36 | `; | |
| 37 | const vision = parseVision(MD); | |
| 38 | ||
| 39 | describe("withVisionBlock", () => { | |
| 40 | test("appends after the owner's words, then regenerates in place, byte for byte outside", () => { | |
| 41 | const first = withVisionBlock("Mine, first.", vision); | |
| 42 | if (!first.ok) throw new Error(first.why); | |
| 43 | expect(first.text.startsWith("Mine, first.\n\n" + BLOCK_START)).toBe(true); | |
| 44 | expect(first.text).toContain("Order of work: 0. Trust, always first; 1. One front door: the feed."); | |
| 45 | const wrapped = `before é\n${first.text.slice("Mine, first.\n\n".length)}\n after `; | |
| 46 | const again = withVisionBlock(wrapped, parseVision(MD.replace("Done means reachable.", "Done means seen."))); | |
| 47 | if (!again.ok) throw new Error(again.why); | |
| 48 | expect(again.text.startsWith("before é\n" + BLOCK_START)).toBe(true); | |
| 49 | expect(again.text.endsWith(BLOCK_END + "\n after ")).toBe(true); | |
| 50 | expect(again.text).toContain("Rule: Done means seen."); | |
| 51 | }); | |
| 52 | test("an orphan start marker changes nothing", () => { | |
| 53 | const r = withVisionBlock(`mine\n${BLOCK_START}\nhalf`, vision); | |
| 54 | expect(r.ok).toBe(false); | |
| 55 | }); | |
| 56 | test("an end marker before the start changes nothing", () => { | |
| 57 | expect(withVisionBlock(`${BLOCK_END} ${BLOCK_START}`, vision).ok).toBe(false); | |
| 58 | }); | |
| 59 | test("the whole direction stays under 2,000 characters", () => { | |
| 60 | const long = parseVision(`# V\n\n${"word ".repeat(1000)}\n\n## How we build\n${"- rule ".repeat(10).split(" - ").map((_, i) => `- ${"r".repeat(300)}${i}`).join("\n")}\n`); | |
| 61 | const r = withVisionBlock("x".repeat(1500), long); | |
| 62 | if (!r.ok) throw new Error(r.why); | |
| 63 | expect(r.text.length).toBeLessThanOrEqual(DIRECTION_LIMIT); | |
| 64 | expect(r.text.startsWith("x".repeat(1500))).toBe(true); | |
| 65 | expect(withVisionBlock("x".repeat(1990), long).ok).toBe(false); | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 69 | describe("landing a vision pull", () => { | |
| 70 | let dataDir: string; | |
| 71 | let app: App; | |
| 72 | let servers: Servers; | |
| 73 | let owner: User; | |
| 74 | let bob: User; | |
| 75 | let repo: Repo; | |
| 76 | let n = 0; | |
| 77 | ||
| 78 | beforeAll(async () => { | |
| 79 | dataDir = await mkdtemp(join(tmpdir(), "forge-vision-land-")); | |
| 80 | app = createApp({ dataDir, port: 0 }); | |
| 81 | const made = await Promise.all( | |
| 82 | ["alice", "bob"].map((username) => signUp(app, { username, email: `${username}@forge.test`, password: "a-long-passphrase" })), | |
| 83 | ); | |
| 84 | if (!made.every((m) => m.ok)) throw new Error("sign-up failed"); | |
| 85 | [owner, bob] = made.map((m) => (m.ok ? m.value : undefined!)) as [User, User]; | |
| 86 | promote(app.db, owner, bob); | |
| 87 | expect(createOrg(app, owner, "acme").ok).toBe(true); | |
| 88 | const acme = getOrg(app.db, "acme")!; | |
| 89 | const devs = createTeam(app, owner, acme, "devs", "write"); | |
| 90 | if (!devs.ok) throw new Error("team"); | |
| 91 | expect(addTeamMember(app, owner, acme, devs.value, "bob").ok).toBe(true); | |
| 92 | const r = await createRepo(app, owner, { owner: "acme", name: "site", visibility: "public" }); | |
| 93 | if (!r.ok) throw new Error(String(r.error)); | |
| 94 | repo = r.value; | |
| 95 | expect((await commitToBranch(app, owner, repo, { message: "seed", files: [{ path: "docs/VISION.md", content: MD }] })).ok).toBe(true); | |
| 96 | servers = await serve(app); | |
| 97 | }, 30_000); | |
| 98 | ||
| 99 | afterAll(async () => { | |
| 100 | servers.stop(); | |
| 101 | app.close(); | |
| 102 | await rm(dataDir, { recursive: true, force: true }); | |
| 103 | }); | |
| 104 | ||
| 105 | const direction = (): string => getRepoById(app.db, repo.id)!.autonomyDirection; | |
| 106 | const setDirection = (text: string): void => { | |
| 107 | updateRepoSettings(app.db, getRepoById(app.db, repo.id)!, { autonomyDirection: text }); | |
| 108 | }; | |
| 109 | ||
| 110 | /** A pull that changes VISION.md; marked as a vision pull by its placement unless `marked` is false. */ | |
| 111 | async function visionPull(marked: boolean, branch = `vision-${++n}`) { | |
| 112 | const md = MD.replace("Done means reachable.", `Done means reachable ${n}.`); | |
| 113 | expect((await commitToBranch(app, owner, repo, { branch, message: "v", files: [{ path: "docs/VISION.md", content: md }] })).ok).toBe(true); | |
| 114 | const pull = await openPull(app, owner, repo, { title: "Note a change of direction", body: "", baseBranch: "main", headBranch: branch }); | |
| 115 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 116 | if (marked) | |
| 117 | savePlacement(app.db, { | |
| 118 | thoughtId: `th${n}`, kind: "vision", reply: "", asked: false, decidedBy: "t", placedAt: new Date().toISOString(), | |
| 119 | placed: [{ action: "proposed", repo: "acme/site", number: pull.value.number, pull: pull.value.number }], | |
| 120 | }); | |
| 121 | return pull.value; | |
| 122 | } | |
| 123 | ||
| 124 | test("an admin's merge writes the block, keeps the owner's words either side, and says so in settings_updated", async () => { | |
| 125 | setDirection(`Ship small.\n\n${BLOCK_START}\nold\n${BLOCK_END}\n\nNever page me.`); | |
| 126 | const pull = await visionPull(true); | |
| 127 | expect((await mergePull(app, owner, repo, pull, "squash")).ok).toBe(true); | |
| 128 | const text = direction(); | |
| 129 | expect(text.startsWith(`Ship small.\n\n${BLOCK_START}\nPurpose: The forge is a social place`)).toBe(true); | |
| 130 | expect(text.endsWith(`${BLOCK_END}\n\nNever page me.`)).toBe(true); | |
| 131 | expect(text).toContain(`Done means reachable ${n}.`); | |
| 132 | expect(text.length).toBeLessThanOrEqual(DIRECTION_LIMIT); | |
| 133 | const e = listEvents(app.db, { repoId: repo.id, kind: "repo.settings_updated", limit: 1 })[0]!; | |
| 134 | expect(e.payload["changed"]).toEqual(["autonomyDirection"]); | |
| 135 | expect(e.actorId).toBe(owner.id); | |
| 136 | }); | |
| 137 | ||
| 138 | test("a branch named vision-… is not a vision pull", async () => { | |
| 139 | setDirection("mine"); | |
| 140 | const pull = await visionPull(false); | |
| 141 | expect((await mergePull(app, owner, repo, pull, "squash")).ok).toBe(true); | |
| 142 | expect(direction()).toBe("mine"); | |
| 143 | }); | |
| 144 | ||
| 145 | test("an orphan start marker leaves the direction unchanged", async () => { | |
| 146 | const orphan = `mine\n${BLOCK_START}\nunfinished`; | |
| 147 | setDirection(orphan); | |
| 148 | const pull = await visionPull(true); | |
| 149 | expect((await mergePull(app, owner, repo, pull, "squash")).ok).toBe(true); | |
| 150 | expect(direction()).toBe(orphan); | |
| 151 | }); | |
| 152 | ||
| 153 | test("a writer's merge leaves the direction, comments nobody, and the pull page shows the forge's line", async () => { | |
| 154 | setDirection("mine"); | |
| 155 | const pull = await visionPull(true); | |
| 156 | const comments = app.db.query<{ c: number }, []>("SELECT count(*) AS c FROM comment").get()!.c; | |
| 157 | expect((await mergePull(app, bob, repo, pull, "squash")).ok).toBe(true); | |
| 158 | expect(direction()).toBe("mine"); | |
| 159 | expect(app.db.query<{ c: number }, []>("SELECT count(*) AS c FROM comment").get()!.c).toBe(comments); | |
| 160 | const e = listEvents(app.db, { repoId: repo.id, kind: "repo.vision_kept", limit: 1 })[0]!; | |
| 161 | expect(e.payload["pull"]).toBe(pull.number); | |
| 162 | expect(e.actorId).toBeNull(); | |
| 163 | const html = await (await fetch(`${servers.url}/acme/site/pulls/${pull.number}`)).text(); | |
| 164 | expect(html).toContain("the forge kept the repository's direction as it was: only an admin's merge rewrites it"); | |
| 165 | }); | |
| 166 | }); |
new above
offline
LIKE over all placements on every merge; fine today, narrow it if the table grows.