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

A repository's milestones list answers `{ items, next_cursor }` #5230

Merged opened this

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

Merged

scout/forge:scout/5224-a-repository-s-milestones-list-answers-i → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/5224-a-repository-s-milestones-list-answers-i was merged into main as 05030f0.

Review

Sign in to review this change.
7 files changed +48 −11
docs/PLANNING.md +2 −1
@@ -284,7 +284,8 @@ never flagged.
284284 It is one tap away: each pillar in the map's _By pillar_ and in the board's
outdated

Good. The doc comment in src/web/pages/milestones.ts that says the API and the MCP return the same data needs the same note: the MCP keeps { repo, limbs }.

285285 7-day line opens its milestone, the map names the page, and the issue page
286286 of anything that waits for others says _see how far it is_. The same
287 numbers are data at `GET /api/v1/repos/:owner/:name/milestones` and
287 numbers are data at `GET /api/v1/repos/:owner/:name/milestones` (the limbs
288 as `{ items, next_cursor }`, with `limit` and `cursor`) and
288289 `…/milestones/:number`, and in the MCP's `milestones`: `building` is the
289290 count on a limb and on a milestone alike, and `buildingNow` lists who is on
290291 what. An agent's name and the spend are the owner's to see: a reader gets
src/contract/openapi/routes/getApiV1ReposOwnerNameMilestones.ts +1 −1
@@ -1,3 +1,3 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: `${REPO}/milestones`, summary: "Read the plan: { repo: { owner, name }, limbs }, one limb per milestone with its weights (and askToLive for a pillar's umbrella); a private repository needs a token", auth: false };
3 export const route: Route = { method: "get", path: `${REPO}/milestones`, summary: "Read the plan: { items, next_cursor }, one limb per milestone with its weights (and askToLive for a pillar's umbrella); limit 1–200 (default 50), cursor is the next_cursor of the page before; a private repository needs a token", auth: false };
src/http/api.ts +5 −1
@@ -3671,10 +3671,14 @@ export function apiRoutes(router: Router<Handler>): void {
36713671 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
36723672 const repo = loadRepo(ctx);
36733673 if (!repo) return notFound();
3674 const query = offsetQuery(ctx.url.searchParams);
3675 if ("error" in query) return offsetRefused(query.error);
36743676 const data = await timePhaseAsync(ctx.request, "read", () =>
36753677 milestonesJson(ctx, repo),
36763678 );
3677 return timePhase(ctx.request, "render", () => json(data));
3679 return timePhase(ctx.request, "render", () =>
3680 json(offsetPage(data.limbs, query.limit, query.offset)),
3681 );
36783682 });
36793683 router.add(
36803684 "GET",
src/web/pages/milestones.ts +6 −2
@@ -612,11 +612,15 @@ export async function milestoneWatch(ctx: Ctx): Promise<Response> {
612612 return redirect(milestoneHref(repoBase(repo), number));
613613 }
614614
615 /** The plan's limbs as data: `GET /api/v1/repos/:owner/:name/milestones`, and the MCP's `milestones`. */
615 /**
616 * The plan's limbs as data: the MCP's `milestones` answers it whole as
617 * `{ repo, limbs }`; `GET /api/v1/repos/:owner/:name/milestones` pages `limbs`
618 * as `{ items, next_cursor }`.
619 */
616620 export async function milestonesJson(
617621 ctx: Ctx,
618622 repo: Repo,
619 ): Promise<Record<string, unknown>> {
623 ): Promise<{ repo: { owner: string; name: string }; limbs: Record<string, unknown>[] }> {
620624 const plan = readPlan(ctx.app.db, repo, {
621625 spend: canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user),
622626 });
test/api-milestones-timing.test.ts +1 −1
@@ -82,7 +82,7 @@ test("the proposals read names session, read, render", async () => {
8282
8383 test("one milestone names session, read, render; a miss names session and read", async () => {
8484 const list = JSON.parse((await phases("/api/v1/repos/alice/r/milestones")).body);
85 expect(list.limbs.length).toBeGreaterThan(0);
85 expect(list.items.length).toBeGreaterThan(0);
8686 const got = await phases(`/api/v1/repos/alice/r/milestones/${umbrella}`);
8787 expect(got.status).toBe(200);
8888 expect(got.names).toEqual(["session", "read", "render"]);
test/milestones.test.ts +21 −5
@@ -437,13 +437,14 @@ describe("milestones", () => {
437437 test("the API and the MCP's `milestones` return the same numbers", async () => {
438438 const limbs = (await (
439439 await api("/api/v1/repos/alice/engine/milestones")
440 ).json()) as { limbs: Record<string, unknown>[] };
441 expect(limbs.limbs.map((l) => l["name"])).toEqual([
440 ).json()) as { items: Record<string, unknown>[]; next_cursor: string | null };
441 expect(limbs.next_cursor).toBeNull();
442 expect(limbs.items.map((l) => l["name"])).toEqual([
442443 "P0 Trust",
443444 "P1 Feed",
444445 "Later",
445446 ]);
446 expect(limbs.limbs[0]).toMatchObject({
447 expect(limbs.items[0]).toMatchObject({
447448 number: trust.number,
448449 done: 1,
449450 total: 5,
@@ -453,10 +454,25 @@ describe("milestones", () => {
453454 spentUsd: 1.5,
454455 deferred: false,
455456 });
456 expect(limbs.limbs[1]).toMatchObject({
457 expect(limbs.items[1]).toMatchObject({
457458 trim: "12 open and nobody building",
458459 });
459 expect((await tool({ app: "alice/engine" })).value).toEqual(limbs);
460 expect(((await tool({ app: "alice/engine" })).value as any).limbs).toEqual(limbs.items);
461
462 const first = (await (
463 await api("/api/v1/repos/alice/engine/milestones?limit=2")
464 ).json()) as { items: unknown[]; next_cursor: string | null };
465 expect(first.items).toEqual(limbs.items.slice(0, 2));
466 expect(first.next_cursor).toBe("2");
467 const last = (await (
468 await api(`/api/v1/repos/alice/engine/milestones?limit=2&cursor=${first.next_cursor}`)
469 ).json()) as { items: unknown[]; next_cursor: string | null };
470 expect(last.items).toEqual(limbs.items.slice(2));
471 expect(last.next_cursor).toBeNull();
472 for (const bad of ["limit=0", "limit=201", "limit=x", "cursor=abc"]) {
473 const res = await api(`/api/v1/repos/alice/engine/milestones?${bad}`);
474 expect(res.status).toBe(422);
475 }
460476
461477 const one = (await (
462478 await api(`/api/v1/repos/alice/engine/milestones/${ship.number}`)
test/openapi-milestones-list.test.ts added +12
@@ -0,0 +1,12 @@
1 /** The OpenAPI document describes the milestones list as a page: { items, next_cursor }. */
2 import { expect, test } from "bun:test";
3 import { openapi } from "../src/contract/openapi.ts";
4
5 test("GET milestones summary names the page shape, not { repo, limbs }", () => {
6 const doc = openapi("https://f.test", "1") as any;
7 const op = doc.paths["/api/v1/repos/{owner}/{name}/milestones"]?.get;
8 expect(op).toBeDefined();
9 expect(op.summary).toContain("{ items, next_cursor }");
10 expect(op.summary).toContain("limit 1–200");
11 expect(op.summary).not.toContain("limbs }");
12 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs