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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7654-mcp-flow-answers-why-a-repository-issue → main 2 commits fork 3 threads · 3 unresolved
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 | ||
| 463 | 463 | - 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`. |
| 464 | 464 | - 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). |
| 465 | 465 | - 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`). | |
| 466 | 467 | - [](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. |
| 467 | 468 | - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [](docs/FLOW.md). |
| 468 | 469 | - 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. | ||
| 86 | 86 | | `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 | |
| 87 | 87 | | `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 | |
| 88 | 88 | | `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 | | | |
| 90 | 90 | | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes | |
| 91 | 91 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 92 | 92 | | `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 @@ | ||
| 1 | 1 | 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"; | |
| 2 | 7 | 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 | }); | |
| 4 | 21 | |
| 5 | 22 | export const tool: Tool = { |
| 6 | 23 | 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", | |
| 8 | 25 | 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 | }, | |
| 11 | 35 | 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 |
||
| 64 | if (pull.state !== "open" || pull.mergedAt !== null) | |
| 65 | return said(`${slug}!${pullN}`, whyPull(ctx.app, repo, pull, { headOid: pull.headOid, conflicts: 0 })); | |
outdated
|
||
| 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)); | |
| 18 | 71 | }, |
| 19 | 72 | }; |
src/tui/screens/about.ts +10
| @@ -33,6 +33,7 @@ import { | ||
| 33 | 33 | import { upstreamOffer, type UpstreamOffer } from "../../actions/offer_upstream.ts"; |
| 34 | 34 | import { listRefs } from "../../git/read.ts"; |
| 35 | 35 | import { directionNote } from "../../crew/direct.ts"; |
| 36 | import { whyRepo } from "../../flow/why.ts"; | |
| 36 | 37 | import { setCrewOutsiders, updateRepoSettings } from "../../actions/repos.ts"; |
| 37 | 38 | import { showProvenance } from "../../actions/provenance.ts"; |
| 38 | 39 | import { appPace } from "../../actions/apps.ts"; |
| @@ -55,6 +56,7 @@ import { | ||
| 55 | 56 | } from "../../core/provenance.ts"; |
| 56 | 57 | import { |
| 57 | 58 | canAdmin, |
| 59 | canWrite, | |
| 58 | 60 | forkParentFor, |
| 59 | 61 | getRepoById, |
| 60 | 62 | type Repo, |
| @@ -117,6 +119,7 @@ export function aboutScreen( | ||
| 117 | 119 | const factRows = (): [string, string][] => [ |
| 118 | 120 | ...archivedRow(repo), |
| 119 | 121 | ...flowRow(repo), |
| 122 | ...movingRow(f, repo), | |
| 120 | 123 | ...outsidersRow(repo), |
| 121 | 124 | ...aboutFacts(f, src, facts, ref), |
| 122 | 125 | ...paceRows(f, repo), |
| @@ -579,6 +582,13 @@ export function flowRow(repo: Repo | undefined): [string, string][] { | ||
| 579 | 582 | return [["Flow only", repo.flowOnly ? (own ? "on, off after 24 h clear" : "on") : "off"]]; |
| 580 | 583 | } |
| 581 | 584 | |
| 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 | ||
| 582 | 592 | /** Who may direct the crew, in `OUTSIDERS_NOTE`'s words; every reader sees it. */ |
| 583 | 593 | export function outsidersRow(repo: Repo | undefined): [string, string][] { |
| 584 | 594 | 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 | }); |
new above
offline
The
|in{ repo, issue? | pull? }breaks the table row. Useor.