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

MCP `flow` answers why a repository, issue or pull is not moving, naming the gate #7701

Merged opened this

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

Merged

scout/forge:scout/7654-mcp-flow-answers-why-a-repository-issue → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/7654-mcp-flow-answers-why-a-repository-issue was merged into main as c99572a.

Review

Sign in to review this change.
6 files changed +575 −11
README.md +1
@@ -463,6 +463,7 @@ This page is the front door. Everything else is a file of its own under
463463 - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`.
464464 - Prompt source at the terminal: `i` on the Deploys screen opens the app's Instagram prompt source, as Settings › Prompts does, and is offered only to the repository's admin who is a person (an agent, a reader or a guest sees no `i` and it does nothing). It lists the account, when it last ran and what it said, and when the token is refreshed again. `a` connects one (the token typed masked, then the hour in UTC; it is never drawn or logged), `f` fetches now and says what came back, `d` removes it after a `y/n` confirm. A second `f` while a fetch is running is ignored. Refusals are the page's words (`invalid_token`, `invalid_at`, `no_such_source`, `not_running`, and `already_connected` only when an account was connected on the web while the screen was open: the screen offers `a` only when there is none).
465465 - Events at the terminal: `v` on the About card (`I`) lists the repository's events newest first, 50 at a time; `n` loads the next page, `esc` goes back; it reads once on opening and does not refresh while open. The audience is `GET …/events`'s and MCP `events`' (a webhook's URL and an app's variable names are the admins').
466 - Why it is not moving: MCP `flow` with `{ repo }`, `{ repo, issue }` or `{ repo, pull }` (a writer's) answers the first gate that holds it (`flow_only`, `ceiling`, `hot_file`, `blocked_by`, `later_gate`, `no_builder`, `no_headroom`, `red_check`, `waiting_review`) with the action that clears it, or `state: closed`/`merged` when nothing is left to move; a pull is read against the head git has now. The terminal's About card (`I`) carries the repository's answer in a **Moving** row for a writer (`src/flow/why.ts`).
466467 - [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
467468 - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [`docs/FLOW.md`](docs/FLOW.md).
468469 - Under it, the panel says the crew's build yield over 24 h (runs finished, pushed, landed; `src/flow/build_yield.ts`) and the median hours to first review (`src/flow/review_latency.ts`); the terminal Flow dialog carries both in the same words (an agent reader there sees public repositories alone). `/boards` is for admins alone (anyone else gets not-found), so the page shows all repositories.
docs/MCP.md +1 −1
@@ -86,7 +86,7 @@ for a tunnel and a forge that sleeps.
8686 | `flag_set` | `{ name, value?, clear? }`: set a declared flag's live value (`value` is `{ forge?, users?, roles?, percent? }`) or `clear` it to its default, with the Flags page's checks and refusals; a forge admin's (never an agent), an admin token; answers `{ name, value, says }` | yes |
8787 | `flag_winner` | `{ name, winner }`: make an A/B flag's winning variant (`on` or `off`, only the one the read-out offers) its default for everyone, opening the clean-up issue first; a forge admin with an admin token; answers `{ name, winner, issue, says }` | yes |
8888 | `flags` | no input: every declared flag with why it exists, its default, the issue that owns removing it and its live `value` (absent when unset), as `readFlags` answers; read-only, a forge admin's and never an agent's; setting is `flag_set` | |
89 | `flow` | the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing` | |
89 | `flow` | with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of flow_only, ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders | |
outdated

The | in { repo, issue? | pull? } breaks the table row. Use or.

9090 | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes |
9191 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
9292 | `forge_proposals` | `{ repo }`: what this forge proposed to its parent, newest first; `reachable` is false if the parent did not answer | |
src/flow/why.ts added +211
@@ -0,0 +1,211 @@
1 /**
2 * Why a repository, issue or pull is not moving (#7654): one ordered list of
3 * causes, each with a sentence and the action that clears it. `firstCause` is
4 * plain data in, plain data out, so the throughput keeper can ask the same
5 * list; `whyOf` reads the facts for one subject and the MCP `flow` answers
6 * from it. The first cause that holds is the answer: later ones are behind it.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import type { App } from "../app.ts";
11 import { listCrewRoles } from "../core/agents.ts";
12 import { repoGraph } from "../core/dependencies.ts";
13 import type { Issue } from "../core/issues.ts";
14 import type { Pull } from "../core/pulls.ts";
15 import type { Repo } from "../core/repos.ts";
16 import { refTip } from "../git/read.ts";
17 import { resolveOid } from "../git/merge.ts";
18 import { checkSummary } from "../core/runs.ts";
19 import { standings } from "../core/standing.ts";
20 import { ceilingHitAt, forgeCeiling } from "./ceiling.ts";
21 import { flowOnlyHolds } from "../crew/flow_only.ts";
22 import { heldNow, mapHolds } from "../crew/held.ts";
23 import { nextOfPull, type Actor } from "./next_actor.ts";
24
25 export const CAUSES = [
26 "flow_only",
27 "ceiling",
28 "hot_file",
29 "blocked_by",
30 "later_gate",
31 "no_builder",
32 "no_headroom",
33 "red_check",
34 "waiting_review",
35 ] as const;
36
37 export type CauseName = (typeof CAUSES)[number];
38
39 /** What is known about the subject; a missing fact is one that does not hold. */
40 export interface WhyFacts {
41 /** Who must act for it to move: an agent (builder, reviewer) is held by the ceiling and headroom. */
42 readonly actor: Actor | null;
43 readonly flowOnly?: boolean;
44 readonly ceilingUsd?: number;
45 readonly hotFile?: string;
46 readonly blockedBy?: readonly number[];
47 readonly laterGate?: string;
48 readonly builders?: number;
49 readonly noHeadroom?: boolean;
50 readonly redCheck?: boolean;
51 readonly waitingReview?: boolean;
52 }
53
54 export interface Cause {
55 readonly name: CauseName;
56 readonly says: string;
57 /** The action that clears it. */
58 readonly clears: string;
59 }
60
61 const agentActs = (f: WhyFacts): boolean => f.actor === "builder" || f.actor === "reviewer";
62
63 const RULES: Readonly<Record<CauseName, (f: WhyFacts) => Omit<Cause, "name"> | null>> = {
64 flow_only: (f) =>
65 f.flowOnly === true
66 ? {
67 says: "FLOW ONLY is on: a builder starts only issues labelled `flow`.",
68 clears: "label it `flow`, or switch FLOW ONLY off with `repo_settings`.",
69 }
70 : null,
71 ceiling: (f) =>
72 f.ceilingUsd !== undefined && agentActs(f)
73 ? {
74 says: `The crew's daily ceiling ($${f.ceilingUsd}) is reached, so no agent starts today.`,
75 clears: "raise the ceiling in the governor, or wait for the UTC day to turn.",
76 }
77 : null,
78 hot_file: (f) =>
79 f.hotFile !== undefined
80 ? {
81 says: `A file it names is hot: ${f.hotFile}.`,
82 clears: "land or close the open pulls on that file, or release an admin's hold on it.",
83 }
84 : null,
85 blocked_by: (f) =>
86 f.blockedBy !== undefined && f.blockedBy.length > 0
87 ? {
88 says: `It waits for ${f.blockedBy.map((n) => `#${n}`).join(", ")}.`,
89 clears: "finish those, or remove the dependency.",
90 }
91 : null,
92 later_gate: (f) =>
93 f.laterGate !== undefined
94 ? { says: `It is deferred: ${f.laterGate}`, clears: "close the Later gate, or move it out from behind it." }
95 : null,
96 no_builder: (f) =>
97 f.actor === "builder" && f.builders === 0
98 ? { says: "No builder is on this repository's crew.", clears: "add a builder to the crew." }
99 : null,
100 no_headroom: (f) =>
101 f.noHeadroom === true && agentActs(f)
102 ? {
103 says: "Every subscription's window is spent down to the share kept for people.",
104 clears: "wait for the window to reset, lower the headroom, or add a subscription.",
105 }
106 : null,
107 red_check: (f) =>
108 f.redCheck === true
109 ? { says: "Its check is red.", clears: "a builder fixes it and pushes; or ask for a check again." }
110 : null,
111 waiting_review: (f) =>
112 f.waitingReview === true
113 ? { says: "It waits for a review.", clears: "a reviewer reviews it on its current head." }
114 : null,
115 };
116
117 /** The first cause that holds the subject, or null when none does. */
118 export function firstCause(f: WhyFacts): Cause | null {
119 for (const name of CAUSES) {
120 const hit = RULES[name](f);
121 if (hit !== null) return { name, ...hit };
122 }
123 return null;
124 }
125
126 export interface Why {
127 readonly cause: Cause | null;
128 /** Set when nothing is left to move: the subject is closed, or the pull merged. */
129 readonly settled?: "closed" | "merged";
130 /** Who acts next, when the subject is a pull. */
131 readonly next?: { readonly who: Actor; readonly why: string };
132 }
133
134 /** True when every subscription has used what the crew may spend; false with none. */
135 function headroomSpent(db: Database, now: Date): boolean {
136 const ids = db.query<{ id: string }, []>("SELECT id FROM subscription").all().map((r) => r.id);
137 if (ids.length === 0) return false;
138 const read = standings(db, ids, now);
139 return ids.every((id) =>
140 (read.get(id)?.windows ?? []).some((w) => w.crewRemaining !== null && w.crewRemaining.value <= 0),
141 );
142 }
143
144 function sharedFacts(app: App, repo: Repo, now: Date): Pick<WhyFacts, "ceilingUsd" | "builders" | "noHeadroom"> {
145 const cap = forgeCeiling(app.config);
146 return {
147 ...(cap !== null && ceilingHitAt(app.db, now, cap) !== null ? { ceilingUsd: cap } : {}),
148 builders: listCrewRoles(app.db, repo.id).filter((c) => c.role === "builder").length,
149 noHeadroom: headroomSpent(app.db, now),
150 };
151 }
152
153 /** Why the repository's builders are held, whatever the issue. */
154 export function whyRepo(app: App, repo: Repo, now = new Date()): Why {
155 return { cause: firstCause({ actor: "builder", flowOnly: repo.flowOnly, ...sharedFacts(app, repo, now) }) };
156 }
157
158 /** Why an open issue is not moving. */
159 export function whyIssue(app: App, repo: Repo, issue: Issue, now = new Date()): Why {
160 if (issue.state !== "open") return { cause: null, settled: "closed" };
161 const graph = repoGraph(app.db, repo.id, { sealed: true });
162 const byId = new Map(graph.issues.map((i) => [i.id, i]));
163 const openBlockers = graph.edges
164 .filter((e) => e.issueId === issue.id)
165 .map((e) => byId.get(e.blockedById))
166 .filter((b): b is Issue => b !== undefined && b.state === "open")
167 .map((b) => b.number)
168 .sort((x, y) => x - y);
169 const held = heldNow(app, repo);
170 const holdOf = mapHolds(repo, held).holds.get(issue.number);
171 const hot = holdOf?.by === "hot_file" ? holdOf.detail : undefined;
172 const later = graph.deferred.get(issue.id);
173 return {
174 cause: firstCause({
175 actor: "builder",
176 flowOnly: flowOnlyHolds(app.db, repo, issue),
177 ...(hot !== undefined ? { hotFile: hot } : {}),
178 blockedBy: openBlockers,
179 ...(later !== undefined ? { laterGate: later } : {}),
180 ...sharedFacts(app, repo, now),
181 }),
182 };
183 }
184
185 /** The head git has now (ref file, then git), the stored one only when git cannot say. */
186 export async function liveHead(dir: string, pull: Pull): Promise<string> {
187 const ref = pull.crossRepo ? `refs/pull/${pull.number}/head` : `refs/heads/${pull.headBranch}`;
188 return (await refTip(dir, ref)) ?? (await resolveOid(dir, ref)) ?? pull.headOid;
189 }
190
191 /** Why an open pull is not moving; `live` is the head and conflicts the caller read from git (`liveHead` below reads the head). */
192 export function whyPull(
193 app: App,
194 repo: Repo,
195 pull: Pull,
196 live: { readonly headOid: string; readonly conflicts: number },
197 now = new Date(),
198 ): Why {
199 if (pull.mergedAt !== null) return { cause: null, settled: "merged" };
200 if (pull.state !== "open") return { cause: null, settled: "closed" };
201 const next = nextOfPull(app.db, pull, live);
202 if (next === null) return { cause: null };
203 const check = checkSummary(app.db, pull.id, live.headOid).state;
204 const cause = firstCause({
205 actor: next.who,
206 redCheck: next.who === "builder" && check === "failed",
207 waitingReview: next.who === "reviewer",
208 ...sharedFacts(app, repo, now),
209 });
210 return { cause, next };
211 }
src/http/mcp/tools/flow.ts +63 −10
@@ -1,19 +1,72 @@
11 import { noteRead, telemetryReader } from "../../../actions/telemetry.ts";
2 import { getIssue } from "../../../core/issues.ts";
3 import { getPull, pullDir, pullMergeState } from "../../../core/pulls.ts";
4 import { canWrite } from "../../../core/repos.ts";
5 import { mayView } from "../../../core/sealed.ts";
6 import { whyIssue, whyPull, liveHead, whyRepo, type Why } from "../../../flow/why.ts";
27 import { flowFor } from "../../../system/telemetry.ts";
3 import { good, bad, type Tool } from "../shared.ts";
8 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
9
10 const number = (v: unknown): number | undefined =>
11 typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
12
13 const said = (subject: string, why: Why) =>
14 good({
15 subject,
16 moving: why.cause === null && why.settled === undefined,
17 ...(why.settled === undefined ? {} : { state: why.settled }),
18 ...(why.cause === null ? {} : { cause: why.cause.name, says: why.cause.says, clears: why.cause.clears }),
19 ...(why.next === undefined ? {} : { next: why.next }),
20 });
421
522 export const tool: Tool = {
623 name: "flow",
7 doc: "the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`",
24 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of flow_only, ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
825 description:
9 "Pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, `queue`.",
10 inputSchema: { type: "object", properties: {} },
26 "Pipeline (admin); with `repo` and `issue` or `pull`: why it is not moving.",
27 inputSchema: {
28 type: "object",
29 properties: {
30 repo: REPO_ARG,
31 issue: { type: "integer", minimum: 1 },
32 pull: { type: "integer", minimum: 1 },
33 },
34 },
1135 writes: false,
12 run(ctx, user) {
13 const reader = telemetryReader(ctx.app, user);
14 if (!reader)
15 return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its pipeline");
16 noteRead(ctx.app, user, reader, "flow");
17 return good(flowFor(ctx.app, reader));
36 async run(ctx, user, args) {
37 if (args["repo"] === undefined) {
38 const reader = telemetryReader(ctx.app, user);
39 if (!reader)
40 return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its pipeline");
41 noteRead(ctx.app, user, reader, "flow");
42 return good(flowFor(ctx.app, reader));
43 }
44 const repo = repoArg(ctx, user, args);
45 if (isAnswer(repo)) return repo;
46 const db = ctx.app.db;
47 if (!canWrite(db, repo, user) && !telemetryReader(ctx.app, user))
48 return bad("forbidden", "only someone who can write here reads why it is not moving");
49 const slug = `${repo.ownerName}/${repo.name}`;
50 const issueN = number(args["issue"]);
51 const pullN = number(args["pull"]);
52 if ((args["issue"] !== undefined && issueN === undefined) || (args["pull"] !== undefined && pullN === undefined))
53 return bad("invalid", "issue and pull are numbers");
54 if (issueN !== undefined && pullN !== undefined) return bad("invalid", "ask about an issue or a pull, not both");
55 if (issueN !== undefined) {
56 const issue = getIssue(db, repo.id, issueN);
57 if (issue === undefined || issue.kind !== "issue" || !mayView(db, repo, issue, user))
58 return bad("not_found", `no issue #${issueN}`);
59 return said(`${slug}#${issueN}`, whyIssue(ctx.app, repo, issue));
60 }
61 if (pullN !== undefined) {
62 const pull = getPull(db, repo.id, pullN);
63 if (pull === undefined || !mayView(db, repo, pull, user)) return bad("no_such_pull", `no pull #${pullN}`);
outdated

A closed issue or a merged pull comes back as moving: true. That is wrong: say it is closed or merged. whyIssue has the same problem.

64 if (pull.state !== "open" || pull.mergedAt !== null)
65 return said(`${slug}!${pullN}`, whyPull(ctx.app, repo, pull, { headOid: pull.headOid, conflicts: 0 }));
outdated

pull.headOid is the stored head, not what git has now, so red_check can be read against a stale head. Read it from git, as whyPull's doc says the caller does.

66 const dir = await pullDir(db, ctx.app.reposRoot, repo, pull);
67 const [state, headOid] = await Promise.all([pullMergeState(dir, pull), liveHead(dir, pull)]);
68 return said(`${slug}!${pullN}`, whyPull(ctx.app, repo, pull, { headOid, conflicts: state?.conflicts?.length ?? 0 }));
69 }
70 return said(slug, whyRepo(ctx.app, repo));
1871 },
1972 };
src/tui/screens/about.ts +10
@@ -33,6 +33,7 @@ import {
3333 import { upstreamOffer, type UpstreamOffer } from "../../actions/offer_upstream.ts";
3434 import { listRefs } from "../../git/read.ts";
3535 import { directionNote } from "../../crew/direct.ts";
36 import { whyRepo } from "../../flow/why.ts";
3637 import { setCrewOutsiders, updateRepoSettings } from "../../actions/repos.ts";
3738 import { showProvenance } from "../../actions/provenance.ts";
3839 import { appPace } from "../../actions/apps.ts";
@@ -55,6 +56,7 @@ import {
5556 } from "../../core/provenance.ts";
5657 import {
5758 canAdmin,
59 canWrite,
5860 forkParentFor,
5961 getRepoById,
6062 type Repo,
@@ -117,6 +119,7 @@ export function aboutScreen(
117119 const factRows = (): [string, string][] => [
118120 ...archivedRow(repo),
119121 ...flowRow(repo),
122 ...movingRow(f, repo),
120123 ...outsidersRow(repo),
121124 ...aboutFacts(f, src, facts, ref),
122125 ...paceRows(f, repo),
@@ -579,6 +582,13 @@ export function flowRow(repo: Repo | undefined): [string, string][] {
579582 return [["Flow only", repo.flowOnly ? (own ? "on, off after 24 h clear" : "on") : "off"]];
580583 }
581584
585 /** Why the repository's builders are held, first gate only (`flow/why.ts`, as MCP `flow`); a writer's. */
586 export function movingRow(f: Session, repo: Repo | undefined): [string, string][] {
587 if (repo === undefined || f.user === undefined || !canWrite(f.app.db, repo, f.user)) return [];
588 const { cause } = whyRepo(f.app, repo);
589 return [["Moving", cause === null ? "yes" : `no: ${cause.says} ${cause.clears}`]];
590 }
591
582592 /** Who may direct the crew, in `OUTSIDERS_NOTE`'s words; every reader sees it. */
583593 export function outsidersRow(repo: Repo | undefined): [string, string][] {
584594 if (repo === undefined) return [];
test/mcp-flow-why.test.ts added +289
@@ -0,0 +1,289 @@
1 /** `flow` with a repository, issue or pull answers the first gate that holds it, from the one list in flow/why.ts. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, 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 { addToCrew, createAgent } from "../src/actions/crew.ts";
8 import { openPull } from "../src/actions/pulls.ts";
9 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
10 import { holdFile } from "../src/crew/file_holds.ts";
11 import { addDependency } from "../src/core/dependencies.ts";
12 import { getPull } from "../src/core/pulls.ts";
13 import { setIssueState, createIssue, type Issue } from "../src/core/issues.ts";
14 import { getRepo, updateRepoSettings, type Repo } from "../src/core/repos.ts";
15 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createUser, getUserByName, type User } from "../src/core/users.ts";
18 import { movingRow } from "../src/tui/screens/about.ts";
19 import { CAUSES, firstCause, type WhyFacts } from "../src/flow/why.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { promote } from "./helpers/members.ts";
22
23 describe("the list of causes", () => {
24 const cases: [string, WhyFacts][] = [
25 ["flow_only", { actor: "builder", flowOnly: true }],
26 ["ceiling", { actor: "builder", ceilingUsd: 20 }],
27 ["hot_file", { actor: "builder", hotFile: "src/a.ts is in 3 open pulls" }],
28 ["blocked_by", { actor: "builder", blockedBy: [4, 9] }],
29 ["later_gate", { actor: "builder", laterGate: "behind the Later gate" }],
30 ["no_builder", { actor: "builder", builders: 0 }],
31 ["no_headroom", { actor: "builder", noHeadroom: true }],
32 ["red_check", { actor: "builder", redCheck: true }],
33 ["waiting_review", { actor: "reviewer", waitingReview: true }],
34 ];
35 for (const [name, facts] of cases)
36 test(`${name} answers its gate, with a sentence and the action that clears it`, () => {
37 const cause = firstCause(facts);
38 expect(cause?.name).toBe(name as never);
39 expect(cause?.says.length).toBeGreaterThan(10);
40 expect(cause?.clears.length).toBeGreaterThan(10);
41 });
42
43 test("every cause in the list has a case, and the first in order wins", () => {
44 expect(cases.map(([n]) => n)).toEqual([...CAUSES]);
45 expect(firstCause({ actor: "builder", flowOnly: true, noHeadroom: true, redCheck: true })?.name).toBe("flow_only");
46 expect(firstCause({ actor: "queue" })).toBeNull();
47 // A ceiling does not hold a pull that only waits for the queue.
48 expect(firstCause({ actor: "queue", ceilingUsd: 5 })).toBeNull();
49 });
50 });
51
52 describe("MCP flow with a subject", () => {
53 let dataDir: string;
54 let app: App;
55 let servers: Servers;
56 let ownerTok: string;
57 let readerTok: string;
58 let plain: Issue;
59 let blocked: Issue;
60 let alice: User;
61 let carol: User;
62 let repo: Repo;
63 let id = 1;
64 const issueOf = (title: string) => {
65 const i = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title });
66 if (!i.ok) throw new Error(i.error);
67 return i.value;
68 };
69
70 const call = async (token: string, args: unknown) => {
71 const res = await fetch(`${servers.url}/mcp`, {
72 method: "POST",
73 headers: {
74 "content-type": "application/json",
75 accept: "application/json, text/event-stream",
76 authorization: `Bearer ${token}`,
77 },
78 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "flow", arguments: args } }),
79 });
80 const body: any = await res.json();
81 const text: string = body.result.content[0].text;
82 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
83 };
84
85 beforeAll(async () => {
86 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-flow-why-"));
87 app = createApp({ dataDir, port: 0 });
88 servers = await serve(app);
89 const mk = async (username: string, first = false) => {
90 const u = await createUser(app.db, {
91 origin: "seed",
92 trusted: true,
93 username,
94 email: `${username}@forge.local`,
95 password: "a-good-passphrase",
96 adminIfFirst: first,
97 });
98 if (!u.ok) throw new Error(u.error);
99 promote(app.db, u.value);
100 return u.value;
101 };
102 alice = await mk("alice", true);
103 carol = await mk("carol");
104 ownerTok = createToken(app.db, alice.id, "rw", "rw").token;
105 readerTok = createToken(app.db, carol.id, "rw", "rw").token;
106 const made = await createRepo(app, alice, { name: "one", visibility: "public" });
107 if (!made.ok) throw new Error(String(made.error));
108 repo = made.value;
109 const mkIssue = issueOf;
110 plain = mkIssue("plain");
111 const blocker = mkIssue("blocker");
112 blocked = mkIssue("blocked");
113 addDependency(app.db, blocked, blocker, alice.id);
114 const bot = await createAgent(app, alice, { username: "bot" });
115 if (!bot.ok) throw new Error(String(bot.error));
116 }, 30_000);
117
118 afterAll(async () => {
119 servers.stop();
120 app.close();
121 await rm(dataDir, { recursive: true, force: true });
122 });
123
124 test("with no arguments it is the pipeline, for an admin only", async () => {
125 expect((await call(ownerTok, {})).value.stages).toBeDefined();
126 expect((await call(readerTok, {})).error).toContain("only an admin");
127 });
128
129 test("a repository with no builder says so", async () => {
130 const r = await call(ownerTok, { repo: "alice/one" });
131 expect(r.value).toMatchObject({ subject: "alice/one", moving: false, cause: "no_builder" });
132 });
133
134 test("an issue behind another names it; a free one moves once a builder is on the crew", async () => {
135 const r = await call(ownerTok, { repo: "alice/one", issue: blocked.number });
136 expect(r.value).toMatchObject({ moving: false, cause: "blocked_by" });
137 expect(r.value.says).toContain("#2");
138 const repo = getRepo(app.db, "alice", "one")!;
139 const alice = getUserByName(app.db, "alice")!;
140 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
141 const free = await call(ownerTok, { repo: "alice/one", issue: plain.number });
142 expect(free.value).toMatchObject({ moving: true });
143 });
144
145 test("FLOW ONLY holds an issue not labelled flow, and is the first gate", async () => {
146 const repo = getRepo(app.db, "alice", "one")!;
147 updateRepoSettings(app.db, repo, { flowOnly: true });
148 const r = await call(ownerTok, { repo: "alice/one", issue: blocked.number });
149 expect(r.value.cause).toBe("flow_only");
150 updateRepoSettings(app.db, repo, { flowOnly: false });
151 });
152
153 test("a reader is refused, and bad numbers are invalid", async () => {
154 expect((await call(readerTok, { repo: "alice/one", issue: 1 })).error).toContain("can write");
155 expect((await call(ownerTok, { repo: "alice/one", issue: "x" })).error).toContain("numbers");
156 expect((await call(ownerTok, { repo: "alice/one", issue: 1, pull: 2 })).error).toContain("not both");
157 expect((await call(ownerTok, { repo: "alice/one", issue: 99 })).error).toContain("no issue");
158 });
159
160 test("a closed issue is closed, not moving", async () => {
161 const done = issueOf("done");
162 expect(setIssueState(app.db, done.id, "closed")).toBeDefined();
163 const r = await call(ownerTok, { repo: "alice/one", issue: done.number });
164 expect(r.value).toMatchObject({ moving: false, state: "closed" });
165 expect(r.value.cause).toBeUndefined();
166 });
167
168 test("an issue naming a file an admin holds is held by hot_file", async () => {
169 const pinned = issueOf("Change src/pinned.ts");
170 expect(holdFile(app.db, repo.id, "src/pinned.ts", "alice").ok).toBe(true);
171 const r = await call(ownerTok, { repo: "alice/one", issue: pinned.number });
172 expect(r.value).toMatchObject({ moving: false, cause: "hot_file" });
173 expect(r.value.says).toContain("src/pinned.ts");
174 });
175
176 test("an issue filed under Later is held by later_gate, not blocked_by", async () => {
177 const gate = issueOf("Later gate: not yet");
178 const umbrella = issueOf("Later: someday");
179 const filed = issueOf("Someday thing");
180 void gate;
181 addDependency(app.db, umbrella, filed, alice.id);
182 const r = await call(ownerTok, { repo: "alice/one", issue: filed.number });
183 expect(r.value).toMatchObject({ moving: false, cause: "later_gate" });
184 });
185
186 test("the terminal's About card carries the same first gate for a writer, and none for a reader", () => {
187 expect(movingRow({ app, user: alice } as never, getRepo(app.db, "alice", "one"))).toEqual([["Moving", "yes"]]);
188 updateRepoSettings(app.db, getRepo(app.db, "alice", "one")!, { flowOnly: true });
189 const row = movingRow({ app, user: alice } as never, getRepo(app.db, "alice", "one"));
190 updateRepoSettings(app.db, getRepo(app.db, "alice", "one")!, { flowOnly: false });
191 expect(row[0]![1]).toContain("FLOW ONLY");
192 expect(movingRow({ app, user: carol } as never, getRepo(app.db, "alice", "one"))).toEqual([]);
193 });
194 });
195
196 describe("MCP flow about a pull", () => {
197 let dataDir: string;
198 let app: App;
199 let servers: Servers;
200 let tok: string;
201 let alice: User;
202 let repo: Repo;
203 let id = 1;
204
205 const call = async (args: unknown) => {
206 const res = await fetch(`${servers.url}/mcp`, {
207 method: "POST",
208 headers: {
209 "content-type": "application/json",
210 accept: "application/json, text/event-stream",
211 authorization: `Bearer ${tok}`,
212 },
213 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "flow", arguments: args } }),
214 });
215 const body: any = await res.json();
216 return JSON.parse(body.result.content[0].text);
217 };
218 const put = async (branch: string, content: string) => {
219 const done = await commitToBranch(app, alice, repo, {
220 branch,
221 message: `edit ${branch}`,
222 files: [{ path: "code.txt", content }],
223 });
224 if (!done.ok) throw new Error(JSON.stringify(done.error));
225 };
226 const pullOn = async (branch: string) => {
227 await put(branch, `top\nbase\n${branch}\n`);
228 const opened = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch });
229 if (!opened.ok) throw new Error(String(opened.error));
230 return opened.value.number;
231 };
232
233 beforeAll(async () => {
234 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-flow-pull-"));
235 app = createApp({ dataDir, port: 0, host: "forge.test" });
236 servers = await serve(app);
237 const u = await createUser(app.db, {
238 origin: "seed",
239 trusted: true,
240 username: "alice",
241 email: "[email protected]",
242 password: "a-good-passphrase",
243 adminIfFirst: true,
244 });
245 if (!u.ok) throw new Error(u.error);
246 alice = u.value;
247 tok = createToken(app.db, alice.id, "rw", "rw").token;
248 const made = await createRepo(app, alice, { name: "lib", visibility: "public" });
249 if (!made.ok) throw new Error(String(made.error));
250 repo = made.value;
251 await put("main", "top\nbase\n");
252 const bot = await createAgent(app, alice, { username: "bot" });
253 if (!bot.ok) throw new Error(String(bot.error));
254 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
255 }, 60_000);
256
257 afterAll(async () => {
258 servers.stop();
259 app.close();
260 await rm(dataDir, { recursive: true, force: true });
261 });
262
263 test("an unreviewed pull waits for a review and says who acts next", async () => {
264 const n = await pullOn("wait");
265 const r = await call({ repo: "alice/lib", pull: n });
266 expect(r).toMatchObject({ subject: `alice/lib!${n}`, moving: false, cause: "waiting_review", next: { who: "reviewer" } });
267 });
268
269 test("a red check on the head git has now is red_check, whatever head is stored", async () => {
270 const n = await pullOn("red");
271 const live = getPull(app.db, repo.id, n)!.headOid;
272 const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: getPull(app.db, repo.id, n)!.id, env: `pull-${n}`, commitOid: live });
273 claimRun(app.db, run.id);
274 finishRun(app.db, run.id, { state: "failed", summary: "boom" });
275 // The stored head falls behind the branch: the answer reads the branch.
276 app.db.query("UPDATE pull SET head_oid = ?2 WHERE issue_id = ?1").run(getPull(app.db, repo.id, n)!.id, "0".repeat(40));
277 const r = await call({ repo: "alice/lib", pull: n });
278 expect(r).toMatchObject({ moving: false, cause: "red_check", next: { who: "builder" } });
279 });
280
281 test("a merged pull is merged and a closed one is closed, neither moving", async () => {
282 const m = getPull(app.db, repo.id, await pullOn("gone"))!;
283 app.db.query("UPDATE pull SET merged_at = ?2 WHERE issue_id = ?1").run(m.id, new Date().toISOString());
284 expect(await call({ repo: "alice/lib", pull: m.number })).toMatchObject({ moving: false, state: "merged" });
285 const c = getPull(app.db, repo.id, await pullOn("shut"))!;
286 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(c.id);
287 expect(await call({ repo: "alice/lib", pull: c.number })).toMatchObject({ moving: false, state: "closed" });
288 });
289 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs