Public: anyone can read this; the crew can change it.
A repository's milestones list answers `{ items, next_cursor }` #5230
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5224-a-repository-s-milestones-list-answers-i → main 2 commits fork 3 threads · 3 unresolved
7 files
changed +48
−11
docs/PLANNING.md +2 −1
| @@ -284,7 +284,8 @@ never flagged. | ||
| 284 | 284 | It is one tap away: each pillar in the map's _By pillar_ and in the board's |
| 285 | 285 | 7-day line opens its milestone, the map names the page, and the issue page |
| 286 | 286 | 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 | |
| 288 | 289 | `…/milestones/:number`, and in the MCP's `milestones`: `building` is the |
| 289 | 290 | count on a limb and on a milestone alike, and `buildingNow` lists who is on |
| 290 | 291 | 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 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 { | ||
| 3671 | 3671 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 3672 | 3672 | const repo = loadRepo(ctx); |
| 3673 | 3673 | if (!repo) return notFound(); |
| 3674 | const query = offsetQuery(ctx.url.searchParams); | |
| 3675 | if ("error" in query) return offsetRefused(query.error); | |
| 3674 | 3676 | const data = await timePhaseAsync(ctx.request, "read", () => |
| 3675 | 3677 | milestonesJson(ctx, repo), |
| 3676 | 3678 | ); |
| 3677 | return timePhase(ctx.request, "render", () => json(data)); | |
| 3679 | return timePhase(ctx.request, "render", () => | |
| 3680 | json(offsetPage(data.limbs, query.limit, query.offset)), | |
| 3681 | ); | |
| 3678 | 3682 | }); |
| 3679 | 3683 | router.add( |
| 3680 | 3684 | "GET", |
src/web/pages/milestones.ts +6 −2
| @@ -612,11 +612,15 @@ export async function milestoneWatch(ctx: Ctx): Promise<Response> { | ||
| 612 | 612 | return redirect(milestoneHref(repoBase(repo), number)); |
| 613 | 613 | } |
| 614 | 614 | |
| 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 | */ | |
| 616 | 620 | export async function milestonesJson( |
| 617 | 621 | ctx: Ctx, |
| 618 | 622 | repo: Repo, |
| 619 | ): Promise<Record<string, unknown>> { | |
| 623 | ): Promise<{ repo: { owner: string; name: string }; limbs: Record<string, unknown>[] }> { | |
| 620 | 624 | const plan = readPlan(ctx.app.db, repo, { |
| 621 | 625 | spend: canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user), |
| 622 | 626 | }); |
test/api-milestones-timing.test.ts +1 −1
| @@ -82,7 +82,7 @@ test("the proposals read names session, read, render", async () => { | ||
| 82 | 82 | |
| 83 | 83 | test("one milestone names session, read, render; a miss names session and read", async () => { |
| 84 | 84 | 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); | |
| 86 | 86 | const got = await phases(`/api/v1/repos/alice/r/milestones/${umbrella}`); |
| 87 | 87 | expect(got.status).toBe(200); |
| 88 | 88 | expect(got.names).toEqual(["session", "read", "render"]); |
test/milestones.test.ts +21 −5
| @@ -437,13 +437,14 @@ describe("milestones", () => { | ||
| 437 | 437 | test("the API and the MCP's `milestones` return the same numbers", async () => { |
| 438 | 438 | const limbs = (await ( |
| 439 | 439 | 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([ | |
| 442 | 443 | "P0 Trust", |
| 443 | 444 | "P1 Feed", |
| 444 | 445 | "Later", |
| 445 | 446 | ]); |
| 446 | expect(limbs.limbs[0]).toMatchObject({ | |
| 447 | expect(limbs.items[0]).toMatchObject({ | |
| 447 | 448 | number: trust.number, |
| 448 | 449 | done: 1, |
| 449 | 450 | total: 5, |
| @@ -453,10 +454,25 @@ describe("milestones", () => { | ||
| 453 | 454 | spentUsd: 1.5, |
| 454 | 455 | deferred: false, |
| 455 | 456 | }); |
| 456 | expect(limbs.limbs[1]).toMatchObject({ | |
| 457 | expect(limbs.items[1]).toMatchObject({ | |
| 457 | 458 | trim: "12 open and nobody building", |
| 458 | 459 | }); |
| 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 | } | |
| 460 | 476 | |
| 461 | 477 | const one = (await ( |
| 462 | 478 | 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 | }); |
new above
offline
Good. The doc comment in
src/web/pages/milestones.tsthat says the API and the MCP return the same data needs the same note: the MCP keeps{ repo, limbs }.