Public: anyone can read this; the crew can change it.

GET /api/v1/repos/{owner}/{name}/vision answers the repository's vision and direction, as MCP `vision` does #7905

Merged opened this

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

Merged

scout/forge:scout/7901-get-api-v1-repos-owner-name-vision-answe → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/7901-get-api-v1-repos-owner-name-vision-answe was merged into main as 009b330.

Review

Sign in to review this change.
7 files changed +186 −26
src/contract/openapi/routes/getApiV1ReposOwnerNameVision.ts added +13
@@ -0,0 +1,13 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/vision`,
6 summary:
7 "A repository's vision from docs/VISION.md on the default branch, as MCP vision answers it: { purpose, order: [{ n, name, text }], rules, direction, directionStall, directionParts, preview, updatedAt }; without the document purpose is null and order empty; direction and directionParts only for those who may read the repository's settings, directionStall only for admins, else null; number (an open vision pull) adds preview { today, after } or { why } for those same people; a private repository needs a token",
8 auth: false,
9 query: { number: "integer" },
10 refusals: {
11 invalid_number: "number is not a whole number of 1 or more (422)",
12 },
13 };
src/contract/openapi/routes/index.ts +1
@@ -118,6 +118,7 @@ export { route as getApiV1ReposOwnerNameRunsIdLog } from "./getApiV1ReposOwnerNa
118118 export { route as getApiV1ReposOwnerNameTags } from "./getApiV1ReposOwnerNameTags.ts";
119119 export { route as getApiV1ReposOwnerNameUpstream } from "./getApiV1ReposOwnerNameUpstream.ts";
120120 export { route as getApiV1ReposOwnerNameUsage } from "./getApiV1ReposOwnerNameUsage.ts";
121 export { route as getApiV1ReposOwnerNameVision } from "./getApiV1ReposOwnerNameVision.ts";
121122 export { route as getApiV1ReposOwnerNameWaves } from "./getApiV1ReposOwnerNameWaves.ts";
122123 export { route as getApiV1ReposOwnerNameWavesVersion } from "./getApiV1ReposOwnerNameWavesVersion.ts";
123124 export { route as getApiV1Returns } from "./getApiV1Returns.ts";
src/crew/vision.ts +27 −1
@@ -8,7 +8,10 @@
88
99 import type { App } from "../app.ts";
1010 import { isVisionPull } from "../core/placements.ts";
11 import { parseVision, type Vision } from "../core/vision.ts";
11 import { NO_VISION, parseVision, type Vision } from "../core/vision.ts";
12 import { directionStall } from "./planner.ts";
13 import { runGit } from "../git/env.ts";
14 import { getPull } from "../core/pulls.ts";
1215 import { canAdmin, getRepoById, updateRepoSettings } from "../core/repos.ts";
1316 import { createLog } from "../kernel/log.ts";
1417 import { editFiles } from "../actions/contents.ts";
@@ -65,6 +68,29 @@ export async function readDefaultVision(reposRoot: string, repo: Repo): Promise<
6568 return blob.kind === "text" ? parseVision(new TextDecoder().decode(blob.body)) : null;
6669 }
6770
71 /**
72 * What MCP `vision` and `GET /api/v1/repos/:owner/:name/vision` both answer.
73 * `direction`, `directionStall`, `directionParts` and `preview` are admin-only
74 * (null otherwise); `number` names an open vision pull to preview.
75 */
76 export async function answerVision(app: App, user: User | undefined, repo: Repo, number?: number) {
77 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
78 const read = await readDefaultVision(app.reposRoot, repo);
79 const vision = read ?? NO_VISION;
80 const last = read !== null ? await runGit(["log", "-1", "--format=%cI", repo.defaultBranch, "--", VISION_PATH], { cwd: dir }) : undefined;
81 const admin = canAdmin(app.db, repo, user);
82 const pull = admin && number !== undefined ? getPull(app.db, repo.id, number) : undefined;
83 const shown = pull ? await previewVision(app, repo, pull) : null;
84 return {
85 ...vision,
86 direction: admin ? repo.autonomyDirection : null,
87 directionStall: admin ? directionStall(app, repo) : null,
88 directionParts: admin ? directionParts(repo.autonomyDirection) : null,
89 preview: shown === null ? null : shown.ok ? { today: shown.today, after: shown.after } : { why: shown.why },
90 updatedAt: last?.ok && last.value.trim() ? last.value.trim() : null,
91 };
92 }
93
6894 async function readVision(app: App, repo: Repo): Promise<string | null> {
6995 return readVisionAt(repoDir(app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch);
7096 }
src/http/api.ts +2
@@ -315,6 +315,7 @@ import { agentRoutes } from "./api/agents.ts";
315315 import { boardRoutes } from "./api/boards.ts";
316316 import { followingRoutes } from "./api/following.ts";
317317 import { labelsRoutes } from "./api/labels.ts";
318 import { visionRoutes } from "./api/vision.ts";
318319 import { tokenRoutes } from "./api/tokens.ts";
319320 import { accountAdmin, adminRoutes } from "./api/admin.ts";
320321 import { issueRoutes, labelsHandler } from "./api/issues.ts";
@@ -1854,6 +1855,7 @@ export function apiRoutes(router: Router<Handler>): void {
18541855 // ── labels ───────────────────────────────────────────────────────────
18551856
18561857 labelsRoutes(router);
1858 visionRoutes(router);
18571859 router.add(
18581860 "PUT",
18591861 "/api/v1/repos/:owner/:name/pulls/:number/labels",
src/http/api/vision.ts added +23
@@ -0,0 +1,23 @@
1 /** `GET /api/v1/repos/:owner/:name/vision`: what MCP `vision` answers, for the same reader (#7901). */
2
3 import { answerVision } from "../../crew/vision.ts";
4 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
5 import { loadRepo } from "../api.ts";
6 import type { Handler } from "../context.ts";
7 import { apiError, json, notFound } from "../respond.ts";
8 import type { Router } from "../router.ts";
9
10 export function visionRoutes(router: Router<Handler>): void {
11 router.add("GET", "/api/v1/repos/:owner/:name/vision", async (ctx) => {
12 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
13 const repo = loadRepo(ctx);
14 if (!repo) return notFound();
15 const raw = new URL(ctx.request.url).searchParams.get("number");
16 let number: number | undefined;
17 if (raw !== null) {
18 if (!/^[1-9][0-9]{0,9}$/.test(raw)) return apiError("invalid_number", 422, "number is a whole number of 1 or more");
19 number = Number(raw);
20 }
21 return json(await answerVision(ctx.app, ctx.auth.user, repo, number));
22 });
23 }
src/http/mcp/tools/vision.ts +2 −25
@@ -1,10 +1,4 @@
1 import { getPull } from "../../../core/pulls.ts";
2 import { directionParts, previewVision, readDefaultVision } from "../../../crew/vision.ts";
3 import { directionStall } from "../../../crew/planner.ts";
4 import { canAdmin } from "../../../core/repos.ts";
5 import { NO_VISION } from "../../../core/vision.ts";
6 import { runGit } from "../../../git/env.ts";
7 import { repoDir } from "../../../git/repos.ts";
1 import { answerVision } from "../../../crew/vision.ts";
82 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
93
104 export const tool: Tool = {
@@ -21,24 +15,7 @@ export const tool: Tool = {
2115 async run(ctx, user, args) {
2216 const repo = repoArg(ctx, user, args);
2317 if (isAnswer(repo)) return repo;
24 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
25 const read = await readDefaultVision(ctx.app.reposRoot, repo);
26 const found = read !== null;
27 const vision = read ?? NO_VISION;
28 const last = found
29 ? await runGit(["log", "-1", "--format=%cI", repo.defaultBranch, "--", "docs/VISION.md"], { cwd: dir })
30 : undefined;
31 const admin = canAdmin(ctx.app.db, repo, user);
3218 const n = args["number"];
33 const pull = admin && typeof n === "number" && Number.isInteger(n) && n > 0 ? getPull(ctx.app.db, repo.id, n) : undefined;
34 const shown = pull ? await previewVision(ctx.app, repo, pull) : null;
35 return good({
36 ...vision,
37 direction: admin ? repo.autonomyDirection : null,
38 directionStall: admin ? directionStall(ctx.app, repo) : null,
39 directionParts: admin ? directionParts(repo.autonomyDirection) : null,
40 preview: shown === null ? null : shown.ok ? { today: shown.today, after: shown.after } : { why: shown.why },
41 updatedAt: last?.ok && last.value.trim() ? last.value.trim() : null,
42 });
19 return good(await answerVision(ctx.app, user, repo, typeof n === "number" && Number.isInteger(n) && n > 0 ? n : undefined));
4320 },
4421 };
test/api-vision.test.ts added +118
@@ -0,0 +1,118 @@
1 /** `GET /api/v1/repos/:owner/:name/vision` answers what MCP `vision` answers, for the same reader. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, readFile, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { commitToBranch } from "../src/actions/repos.ts";
8 import { openPull } from "../src/actions/pulls.ts";
9 import { savePlacement } from "../src/core/placements.ts";
10 import { nextDirection } from "../src/crew/vision.ts";
11 import { createRepo, getRepo } from "../src/core/repos.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 const tok: Record<string, string> = {};
23
24 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
25 if (!r.ok) throw new Error(JSON.stringify(r.error));
26 return r.value;
27 };
28
29 const rest = (token: string | null, path: string) =>
30 fetch(`${servers.url}/api/v1/repos/ada/${path}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
31
32 async function mcp(token: string, repo: string): Promise<any> {
33 const res = await fetch(`${servers.url}/mcp`, {
34 method: "POST",
35 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
36 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "vision", arguments: { repo } } }),
37 });
38 return JSON.parse(((await res.json()) as any).result.content[0].text);
39 }
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-vision-api-"));
43 app = createApp({ dataDir, port: 0 });
44 servers = await serve(app);
45 const make = (username: string) =>
46 createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase" });
47 const owner = ok(await make("ada"));
48 const reader = ok(await make("rita"));
49 const text = await readFile(join(import.meta.dir, "..", "docs", "VISION.md"), "utf8");
50 const pub = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "with", visibility: "public" }));
51 ok(await commitToBranch(app, owner, pub, { message: "vision", files: [{ path: "docs/VISION.md", content: text }] }));
52 ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "secret", visibility: "private" }));
53 app.db.query("UPDATE repo SET autonomy_direction = 'ship the feed, quietly' WHERE id = ?1").run(pub.id);
54 tok["owner"] = createToken(app.db, owner.id, "rw", "rw").token;
55 tok["reader"] = createToken(app.db, reader.id, "rw", "rw").token;
56 }, 30_000);
57
58 afterAll(async () => {
59 servers?.stop();
60 app?.close();
61 await rm(dataDir, { recursive: true, force: true });
62 });
63
64 test("an admin gets the same JSON over REST as over MCP", async () => {
65 const res = await rest(tok["owner"]!, "with/vision");
66 expect(res.status).toBe(200);
67 const body = (await res.json()) as any;
68 expect(body).toEqual(await mcp(tok["owner"]!, "ada/with"));
69 expect(body.direction).toBe("ship the feed, quietly");
70 expect(body.directionParts).toEqual([{ kind: "owner", text: "ship the feed, quietly" }]);
71 expect(body.order.length).toBe(8);
72 });
73
74 test("a reader gets the same JSON, the gated fields null, the direction text nowhere", async () => {
75 const res = await rest(tok["reader"]!, "with/vision");
76 const text = await res.text();
77 expect(JSON.parse(text)).toEqual(await mcp(tok["reader"]!, "ada/with"));
78 const body = JSON.parse(text);
79 expect(body).toMatchObject({ direction: null, directionStall: null, directionParts: null, preview: null });
80 expect(body.purpose).toStartWith("The forge is a social place");
81 expect(text).not.toContain("quietly");
82 expect(((await (await rest(null, "with/vision")).json()) as any).direction).toBeNull();
83 });
84
85 test("a repository the reader cannot see is a 404", async () => {
86 expect((await rest(tok["reader"]!, "secret/vision")).status).toBe(404);
87 expect((await rest(null, "secret/vision")).status).toBe(404);
88 expect((await rest(tok["owner"]!, "nope/vision")).status).toBe(404);
outdated

Add a case with a real open vision pull: admin gets preview { today, after } (or { why }), reader with the same number gets preview null. Today nothing fails if the preview wiring is removed.

89 });
90
91 test("a bad number is a 422; a number with no vision pull previews nothing", async () => {
92 for (const bad of ["0", "-1", "x", "1.5", ""]) expect((await rest(tok["owner"]!, `with/vision?number=${bad}`)).status).toBe(422);
93 const res = await rest(tok["owner"]!, "with/vision?number=99");
94 expect(res.status).toBe(200);
95 expect(((await res.json()) as any).preview).toBeNull();
96 });
97
98 test("number: an admin previews an open vision pull over REST as over MCP; a reader gets none", async () => {
99 const owner = getUserByName(app.db, "ada")!;
100 const repo = getRepo(app.db, "ada", "with")!;
101 const md = (await readFile(join(import.meta.dir, "..", "docs", "VISION.md"), "utf8")).replace("Done means reachable", "Done means shown");
102 ok(await commitToBranch(app, owner, repo, { branch: "vision-r", message: "v", files: [{ path: "docs/VISION.md", content: md }] }));
103 const pull = ok(await openPull(app, owner, repo, { title: "Note", body: "", baseBranch: "main", headBranch: "vision-r" }));
104 const path = `with/vision?number=${pull.number}`;
105 expect(((await (await rest(tok["owner"]!, path)).json()) as any).preview).toBeNull();
106 savePlacement(app.db, {
107 thoughtId: "thapi", kind: "vision", reply: "", asked: false, decidedBy: "t", placedAt: new Date().toISOString(),
108 placed: [{ action: "proposed", repo: "ada/with", number: pull.number, pull: pull.number }],
109 });
110 const expected = nextDirection("ship the feed, quietly", md);
111 if (!expected.ok) throw new Error(expected.why);
112 const admin = (await (await rest(tok["owner"]!, path)).json()) as any;
113 expect(admin.preview).toEqual({ today: "ship the feed, quietly", after: expected.text });
114 const other = (await (await rest(tok["reader"]!, path)).json()) as any;
115 expect(other.preview).toBeNull();
116 expect(other.directionParts).toBeNull();
117 expect(((await (await rest(null, path)).json()) as any).preview).toBeNull();
118 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs