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

With 'plans need a nod' on, POST …/plan stores one proposal and opens no issues #1874

Merged opened this

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

Merged

scout/forge:scout/1813-with-plans-need-a-nod-on-post-plan-store → main 8 commits fork 7 threads · 7 unresolved

  • Merged. scout/1813-with-plans-need-a-nod-on-post-plan-store was merged into main as d65ce74.

Review

Sign in to review this change.
12 files changed +283 −21
docs/PLANNING.md +11
@@ -565,3 +565,14 @@ not confirm its own plan. Split also moves the parent's blocked-by edges to
565565 the issues named in `into` (a child that would make a loop is skipped), and
566566 the parent waits for nothing. Reopening clears the reason. Closes that
567567 predate reasons count as done.
568
569 ## Plans need a nod
570
571 A repository setting, `plansNeedNod` (PATCH `/api/v1/repos/:owner/:name`,
572 admin only, never an agent), off by default: nothing reads a stored proposal
573 yet, so on would hide the planner's work. On, `POST …/plan` and the MCP
574 `plan` tool run every check as ever (`no_pillar`, loops, unknown keys) and
575 then store one proposal — title, why, the ordered issues with their
576 `blockedBy` — answering `proposed` with its id (HTTP 202). No issue is opened.
577 Off, the plan opens its linked issues all or none. A person's nod that opens
outdated

'not built yet' while the setting is on by default means plans go nowhere. Either the nod lands here or the default is off. Also document the settings-page line and the terminal key once they exist.

outdated

If the nod isn't built, the default can't be on. As written, plans disappear into a table nobody can see.

578 a stored proposal is not built yet.
src/actions/plan.ts +73 −2
@@ -25,6 +25,7 @@ import {
2525 import { canRead, canWrite, type Repo } from "../core/repos.ts";
2626 import type { User } from "../core/users.ts";
2727 import { transact } from "../kernel/db.ts";
28 import { id as newId } from "../kernel/id.ts";
2829 import { err, ok, type Result } from "../kernel/result.ts";
2930 import { isMirror } from "../origins/write-through.ts";
3031 import { followLink } from "./pillars.ts";
@@ -186,12 +187,22 @@ function loopAt(items: readonly PlanItem[]): string | undefined {
186187 return undefined;
187188 }
188189
189 export function openPlan(
190 /** What a good plan was checked against, for opening it. */
191 interface Vetted {
192 readonly actor: User;
193 readonly existing: Map<number, Issue>;
194 readonly serves: Map<string, Pillar>;
195 readonly gate: Issue | undefined;
196 readonly later: Issue | undefined;
197 }
198
199 /** Every refusal a plan can meet, before anything is written. */
200 function vet(
190201 app: App,
191202 actor: User | undefined,
192203 repo: Repo,
193204 items: readonly PlanItem[],
194 ): Result<Planned, PlanError> {
205 ): Result<Vetted, PlanError> {
195206 if (!actor || !canRead(app.db, repo, actor))
196207 return refuse("not_found", "no such app or repository (owner/name)");
197208 if (!canWrite(app.db, repo, actor) && !isCrew(app.db, repo.id, actor.id))
@@ -258,6 +269,66 @@ export function openPlan(
258269 `"${item.key}" names no pillar: give it ${namesOf(pillars)}, or later: true and why`,
259270 );
260271 }
272 return ok({ actor, existing, serves, gate, later });
273 }
274
275 export interface Proposed {
276 readonly status: "proposed";
277 readonly id: string;
278 }
279
280 /**
281 * "Plans need a nod": the same checks as `openPlan`, then the plan is kept
282 * as one proposal (title, why, the ordered issues with their blockedBy) and
283 * no issue is opened. A person's nod opens it.
284 */
285 export function proposePlan(

Nothing caps how many proposals a repo or an author can keep, and nothing ever closes one. Cap the open proposals per repo, or replace the author's previous one.

286 app: App,
287 actor: User | undefined,
288 repo: Repo,
289 items: readonly PlanItem[],
290 say: { title?: unknown; why?: unknown } = {},

Minor: title and why are checked before vet, so someone who can read but not write gets invalid instead of forbidden on a malformed title. Over HTTP loadRepo has already gated reads, so nothing leaks. If you touch this again, call vet first so refusals come in the same order as openPlan.

291 ): Result<Proposed, PlanError> {
292 const title = typeof say.title === "string" ? say.title.trim() : "";
293 if (say.title !== undefined && typeof say.title !== "string")
294 return refuse("invalid", "title must be text");
295 if (title.length > MAX_TITLE)
296 return refuse("title_too_long", `title is over ${MAX_TITLE} characters`);
297 if (say.why !== undefined && typeof say.why !== "string")
298 return refuse("invalid", "why must be text");
299 const why = ((say.why ?? "") as string).trim();
300 if (why.length > MAX_BODY)
301 return refuse("body_too_long", `why is over ${MAX_BODY} characters`);
302 const vetted = vet(app, actor, repo, items);
303 if (!vetted.ok) return vetted;
304 const id = newId();
305 app.db
306 .query(
307 `INSERT INTO plan_proposal (id, repo_id, author_id, title, why, issues, created_at)
308 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
309 )
310 .run(
311 id,
312 repo.id,
313 vetted.value.actor.id,
314 title === "" ? items[0]!.title : title,
315 why,
316 JSON.stringify(items),
317 new Date().toISOString(),
318 );
319 return ok({ status: "proposed", id });
320 }
321
322 export function openPlan(
323 app: App,
324 actor: User | undefined,
325 repo: Repo,
326 items: readonly PlanItem[],
327 ): Result<Planned, PlanError> {
328 const vetted = vet(app, actor, repo, items);
329 if (!vetted.ok) return vetted;
330 const { existing, serves, gate, later } = vetted.value;
331 actor = vetted.value.actor;
261332
262333 const opened: { key: string; number: number }[] = [];
263334 /** The links an item's purpose adds: [the issue that waits, what it waits for]. */
src/actions/repos.ts +3 −1
@@ -249,7 +249,8 @@ export async function updateRepoSettings(
249249 // one on a team that administers the repository (`crew/direct.ts`).
250250 if (
251251 (settings.crewOutsiders !== undefined ||
252 settings.decisionsBy !== undefined) &&
252 settings.decisionsBy !== undefined ||
253 settings.plansNeedNod !== undefined) &&

This new refusal of an agent has no test. Add one where an agent's PATCH of plansNeedNod gets not_found and the setting stays unchanged.

253254 admin.value.kind === "agent"
254255 )
255256 return err("not_found");
@@ -276,6 +277,7 @@ export async function updateRepoSettings(
276277 "requireChecks",
277278 "crewOutsiders",
278279 "decisionsBy",
280 "plansNeedNod",
279281 ] as const
280282 ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]);
281283 app.bus.append({
src/core/migrations/20261015000100-plans-need-a-nod.ts added +16
@@ -0,0 +1,16 @@
1 // plans need a nod (#1813): with the setting on, a plan is stored as
2 // one proposal (its ordered issues as JSON) and opens nothing until a
3 // person nods. Off is the default until a person can read and nod a stored proposal.
4 export default `
5 ALTER TABLE repo ADD COLUMN plans_need_nod INTEGER NOT NULL DEFAULT 0;
outdated

High: DEFAULT 1 turns this on for every existing repository, and no nod exists yet. Every plan from then on is stored where nothing reads it. Use DEFAULT 0 until a proposal can be seen and opened, or build the nod in this pull.

outdated

High: DEFAULT 1 turns this on for every existing repository, and nothing can read or nod a proposal yet. Either make proposals visible (a GET route, an MCP read and a line on the map) in this pull, or default to 0 until the nod exists.

6 CREATE TABLE plan_proposal (
7 id TEXT PRIMARY KEY,
8 repo_id TEXT NOT NULL,
9 author_id TEXT NOT NULL,
10 title TEXT NOT NULL,
11 why TEXT NOT NULL,
12 issues TEXT NOT NULL,
13 created_at TEXT NOT NULL
14 );
15 CREATE INDEX plan_proposal_repo ON plan_proposal(repo_id, created_at);
16 `;
src/core/repos.ts +8 −1
@@ -118,6 +118,8 @@ export interface Repo {
118118 readonly crewOutsiders: boolean;
119119 /** The standing rule for decisions here: `me`, `decider`, or `decider_tell`. */
120120 readonly decisionsBy: DecisionsBy;
121 /** "Plans need a nod": a plan is stored as a proposal, not opened. */
122 readonly plansNeedNod: boolean;
121123 /** The issue the planner is given, when the map has run dry. */
122124 readonly directionIssueId: string | null;
123125 /** The repo this was forked from, or null. */
@@ -163,6 +165,7 @@ interface RepoRow {
163165 autonomy_by: string | null;
164166 crew_outsiders: number;
165167 decisions_by: string;
168 plans_need_nod: number;
166169 direction_issue_id: string | null;
167170 fork_parent_id: string | null;
168171 sealed_for: string | null;
@@ -201,6 +204,7 @@ function toRepo(row: RepoRow): Repo {
201204 autonomyBy: row.autonomy_by,
202205 crewOutsiders: row.crew_outsiders === 1,
203206 decisionsBy: isDecisionsBy(row.decisions_by) ? row.decisions_by : "me",
207 plansNeedNod: row.plans_need_nod === 1,
204208 directionIssueId: row.direction_issue_id,
205209 forkParentId: row.fork_parent_id,
206210 sealedFor: row.sealed_for,
@@ -1036,6 +1040,7 @@ export interface RepoSettings {
10361040 /** Whether someone who cannot write here may start or steer a run. */
10371041 crewOutsiders?: boolean;
10381042 decisionsBy?: DecisionsBy;
1043 plansNeedNod?: boolean;
10391044 }
10401045
10411046 /** Update the mutable repo fields, leaving unspecified ones untouched. */
@@ -1048,7 +1053,8 @@ export function updateRepoSettings(
10481053 `UPDATE repo SET visibility = ?1, description = ?2, protect_default = ?3,
10491054 default_branch = ?4, require_review = ?5, require_checks = ?7,
10501055 autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10,
1051 autonomy_slots = ?11, autonomy_by = ?12, crew_outsiders = ?13, decisions_by = ?14
1056 autonomy_slots = ?11, autonomy_by = ?12, crew_outsiders = ?13, decisions_by = ?14,
1057 plans_need_nod = ?15
10521058 WHERE id = ?6`,
10531059 ).run(
10541060 settings.visibility ?? repo.visibility,
@@ -1065,6 +1071,7 @@ export function updateRepoSettings(
10651071 settings.autonomyBy === undefined ? repo.autonomyBy : settings.autonomyBy,
10661072 (settings.crewOutsiders ?? repo.crewOutsiders) ? 1 : 0,
10671073 settings.decisionsBy ?? repo.decisionsBy,
1074 (settings.plansNeedNod ?? repo.plansNeedNod) ? 1 : 0,
10681075 );
10691076 return getRepoById(db, repo.id)!;
10701077 }
src/http/api.ts +21 −8
@@ -277,7 +277,7 @@ import {
277277 unlinkIssue,
278278 confirmIssue,
279279 } from "../actions/dependencies.ts";
280 import { openPlan, parsePlan } from "../actions/plan.ts";
280 import { openPlan, parsePlan, proposePlan } from "../actions/plan.ts";
281281 import { dependenciesOf, repoGraph } from "../core/dependencies.ts";
282282 import { pillarView } from "../core/pillars.ts";
283283 import { askToLiveFor } from "../web/ask_to_live.ts";
@@ -1820,6 +1820,8 @@ export function apiRoutes(router: Router<Handler>): void {
18201820 422,
18211821 "decisionsBy is me, decider or decider_tell",
18221822 );
1823 if (input.plansNeedNod !== undefined && typeof input.plansNeedNod !== "boolean")
1824 return apiError("bad_plans_need_nod", 422, "plansNeedNod is true or false");
18231825 const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, {
18241826 ...(input.visibility === "public" || input.visibility === "private"
18251827 ? { visibility: input.visibility }
@@ -1847,6 +1849,10 @@ export function apiRoutes(router: Router<Handler>): void {
18471849 ...(isDecisionsBy(input.decisionsBy)
18481850 ? { decisionsBy: input.decisionsBy }
18491851 : {}),
outdated

A non-boolean value is dropped silently here. Refuse it with a 422, the way decisionsBy is refused above. repoJson should also report plansNeedNod, so a client can see the setting it just changed.

1852 // Whether a plan waits for a nod: an admin's, never an agent's.
1853 ...(typeof input.plansNeedNod === "boolean"
1854 ? { plansNeedNod: input.plansNeedNod }
1855 : {}),
18501856 });
18511857 if (!updated.ok) return respondAccess(updated.error, 422);
18521858 return json(repoJson(ctx, updated.value));
@@ -2611,15 +2617,22 @@ export function apiRoutes(router: Router<Handler>): void {
26112617 const items = parsePlan(input.issues);
26122618 if (!items.ok)
26132619 return apiError(items.error.error, 422, items.error.message);
2614 const planned = openPlan(ctx.app, ctx.auth.user, repo, items.value);
2615 if (!planned.ok) {
2616 const { error, message } = planned.error;
2617 return apiError(
2618 error,
2619 error === "not_found" ? 404 : error === "forbidden" ? 403 : 422,
2620 message,
2620 const failed = (e: { error: string; message: string }) =>
2621 apiError(
2622 e.error,
2623 e.error === "not_found" ? 404 : e.error === "forbidden" ? 403 : 422,
2624 e.message,
26212625 );
2626 // With "plans need a nod" the plan is stored, not opened.
2627 if (repo.plansNeedNod) {
2628 const proposed = proposePlan(ctx.app, ctx.auth.user, repo, items.value, {
2629 title: input.title,
2630 why: input.why,
2631 });
2632 return proposed.ok ? json(proposed.value, 202) : failed(proposed.error);
26222633 }
2634 const planned = openPlan(ctx.app, ctx.auth.user, repo, items.value);
2635 if (!planned.ok) return failed(planned.error);
26232636 return json(planned.value, 201);
26242637 });
26252638 router.add(
src/http/mcp.ts +16 −2
@@ -39,7 +39,7 @@ import {
3939 unlinkIssue,
4040 } from "../actions/dependencies.ts";
4141 import { holdPull, releasePull } from "../actions/holds.ts";
42 import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts";
42 import { openPlan, parsePlan, PLAN_MAX, proposePlan } from "../actions/plan.ts";
4343 import { cancelRepoRun } from "../actions/runs.ts";
4444 import { familyView } from "../spawn/family.ts";
4545 import { clientRows } from "../spawn/clients.ts";
@@ -1897,11 +1897,13 @@ const TOOLS: readonly Tool[] = [
18971897 },
18981898 {
18991899 name: "plan",
1900 description: `Open several small issues and link them in one call: all of them or none. Each item has a key, a title, a body and optionally blockedBy, a list of keys of this plan or numbers of issues already open. Where the app has pillars (umbrella issues titled like "P0 Trust: …"), each item also says what it is for: pillar, the one it serves, or later: true with why, one sentence, which puts it behind the Later gate instead of on the ready list; an item that says neither is refused. A bad link (loop, unknown key) refuses the whole plan and names the item. At most ${PLAN_MAX} issues. Answers with the numbers opened, by key, what is ready now, and what waits for later.`,
1900 description: `Open several small issues and link them in one call: all of them or none. Each item has a key, a title, a body and optionally blockedBy, a list of keys of this plan or numbers of issues already open. Where the app has pillars (umbrella issues titled like "P0 Trust: …"), each item also says what it is for: pillar, the one it serves, or later: true with why, one sentence, which puts it behind the Later gate instead of on the ready list; an item that says neither is refused. A bad link (loop, unknown key) refuses the whole plan and names the item. At most ${PLAN_MAX} issues. Answers with the numbers opened, by key, what is ready now, and what waits for later. Where the app has "plans need a nod" on, the same checks run and one proposal is stored (optional title and why for the whole plan); nothing is opened, and it answers status proposed with the proposal id.`,
19011901 inputSchema: {
19021902 type: "object",
19031903 properties: {
19041904 app: APP_ARG,
1905 title: { type: "string", maxLength: 256 },
1906 why: { type: "string", maxLength: 65536 },
19051907 issues: {
19061908 type: "array",
19071909 minItems: 1,
@@ -1948,6 +1950,18 @@ const TOOLS: readonly Tool[] = [
19481950 if (isAnswer(repo)) return repo;
19491951 const items = parsePlan(args["issues"]);
19501952 if (!items.ok) return bad(items.error.error, items.error.message);
1953 if (repo.plansNeedNod) {
1954 const proposed = proposePlan(ctx.app, user, repo, items.value, {
1955 title: args["title"],
1956 why: args["why"],
1957 });
1958 if (!proposed.ok)
1959 return bad(proposed.error.error, proposed.error.message);
1960 return good({
1961 says: `Proposed plan ${proposed.value.id}: nothing is opened until a person nods.`,
1962 ...proposed.value,
1963 });
1964 }
19511965 const planned = openPlan(ctx.app, user, repo, items.value);
19521966 if (!planned.ok) return bad(planned.error.error, planned.error.message);
19531967 const { opened, ready, later, gate } = planned.value;
test/crew-direction.test.ts +19
@@ -145,6 +145,25 @@ afterAll(async () => {
145145 await rm(dataDir, { recursive: true, force: true });
146146 });
147147
148 describe("plans need a nod setting", () => {
149 test("an agent's PATCH of it is refused and the setting stays; a person's works", async () => {
150 const own = await createRepoRow(app.db, app.reposRoot, {
151 ownerId: bot.user.id,
152 name: "botplans",
153 visibility: "public",
154 });
155 if (!own.ok) throw new Error(String(own.error));
156 const refused = await updateRepoSettings(app, bot.user, own.value, { plansNeedNod: true });
157 expect(refused.ok).toBe(false);
158 if (!refused.ok) expect(refused.error).toBe("not_found");
159 expect(getRepoById(app.db, own.value.id)!.plansNeedNod).toBe(false);
160 const mine = await updateRepoSettings(app, alice, fresh(), { plansNeedNod: true });
161 expect(mine.ok).toBe(true);
162 expect(fresh().plansNeedNod).toBe(true);
163 await updateRepoSettings(app, alice, fresh(), { plansNeedNod: false });
164 });
165 });
166
148167 describe("the rule", () => {
149168 test("a writer and the crew's reviewer direct; a reader, a builder agent and nobody do not", () => {
150169 expect(mayDirectCrew(app.db, repo, alice)).toBe(true);
test/mcp.test.ts +34 −1
@@ -14,7 +14,7 @@ import { openPull } from "../src/actions/pulls.ts";
1414 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
1515 import { createApp as insertApp } from "../src/core/apps.ts";
1616 import { listIssues } from "../src/core/issues.ts";
17 import { getRepoById, type Repo } from "../src/core/repos.ts";
17 import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts";
1818 import { createRun, listRuns, setRunBase } from "../src/core/runs.ts";
1919 import { createToken } from "../src/core/tokens.ts";
2020 import { createUser, type User } from "../src/core/users.ts";
@@ -552,7 +552,40 @@ describe("the forge over MCP", () => {
552552 expect(after.ready.map((i: any) => i.number)).toContain(b);
553553 });
554554
555 test("with plans need a nod on, the plan tool stores one proposal and opens nothing", async () => {
556 updateRepoSettings(app.db, getRepoById(app.db, game.id)!, { plansNeedNod: true });
557 const count = async () =>
558 (await tool(ro, "map", { app: "alice/game" })).value.issues.length;
559 const before = await count();
560 const issues = [
561 { key: "a", title: "First" },
562 { key: "b", title: "Second", blockedBy: ["a"] },
563 ];
564 const done = await tool(rw, "plan", {
565 app: "alice/game",
566 title: "Two steps",
567 why: "worth a look",
568 issues,
569 });
570 expect(done.value.status).toBe("proposed");
571 const row = app.db
572 .query<{ title: string; why: string; issues: string }, [string]>(
573 "SELECT title, why, issues FROM plan_proposal WHERE id = ?1",
574 )
575 .get(done.value.id)!;
576 expect(row.title).toBe("Two steps");
577 expect(JSON.parse(row.issues).map((i: any) => i.key)).toEqual(["a", "b"]);
578 expect(await count()).toBe(before);
579 // The checks run as ever: a loop is refused and stores nothing.
580 const loop = await tool(rw, "plan", {
581 app: "alice/game",
582 issues: [{ key: "x", title: "X", blockedBy: ["x"] }],
583 });
584 expect(loop.error).toContain("self");
585 });
586
555587 test("plan opens linked issues all or none", async () => {
588 updateRepoSettings(app.db, getRepoById(app.db, game.id)!, { plansNeedNod: false });
556589 const issues = [
557590 { key: "a", title: "First" },
558591 { key: "b", title: "Second", body: "two" },
test/pillars.test.ts +3 −2
@@ -40,7 +40,7 @@ import {
4040 } from "../src/core/issues.ts";
4141 import { pillarsOf, pillarView } from "../src/core/pillars.ts";
4242 import { askToLiveFor } from "../src/web/ask_to_live.ts";
43 import { getRepoById, type Repo } from "../src/core/repos.ts";
43 import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts";
4444 import { createToken } from "../src/core/tokens.ts";
4545 import { createUser, getUserByName, type User } from "../src/core/users.ts";
4646 import { serve, type Servers } from "../src/http/server.ts";
@@ -169,7 +169,8 @@ describe("pillars", () => {
169169 visibility: "public",
170170 });
171171 if (!made.ok) throw new Error(String(made.error));
172 repo = made.value;
172 // These plans open issues: plans need a nod is off (test/plan-route.test.ts covers on).
173 repo = updateRepoSettings(app.db, made.value, { plansNeedNod: false });
173174 const seeded = await commitToBranch(app, alice, repo, {
174175 message: "Start",
175176 files: [{ path: "README.md", content: "# engine\n" }],
test/plan-pillars.test.ts +4 −3
@@ -21,7 +21,7 @@ import {
2121 type Issue,
2222 } from "../src/core/issues.ts";
2323 import { findPillar, pillarsOf } from "../src/core/pillars.ts";
24 import type { Repo } from "../src/core/repos.ts";
24 import { updateRepoSettings, type Repo } from "../src/core/repos.ts";
2525 import { createToken } from "../src/core/tokens.ts";
2626 import { createUser, type User } from "../src/core/users.ts";
2727 import { systemPrompt } from "../src/crew/brief.ts";
@@ -81,7 +81,8 @@ describe("a plan says what each issue is for", () => {
8181 const repo = async (name: string): Promise<Repo> => {
8282 const made = await createRepo(app, alice, { name, visibility: "private" });
8383 if (!made.ok) throw new Error(String(made.error));
84 return made.value;
84 // These plans open issues: plans need a nod is off (test/plan-route.test.ts covers on).
85 return updateRepoSettings(app.db, made.value, { plansNeedNod: false });
8586 };
8687
8788 beforeAll(async () => {
@@ -404,7 +405,7 @@ describe("whose issue stands for a pillar", () => {
404405 visibility: "public",
405406 });
406407 if (!made.ok) throw new Error(String(made.error));
407 open = made.value;
408 open = updateRepoSettings(app.db, made.value, { plansNeedNod: false });
408409 }, 30_000);
409410
410411 afterAll(async () => {
test/plan-route.test.ts +75 −1
@@ -8,11 +8,12 @@ import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
11 import { proposePlan } from "../src/actions/plan.ts";
1112 import { openIssue } from "../src/actions/issues.ts";
1213 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1314 import { listIssues } from "../src/core/issues.ts";
1415 import { pillarsOf } from "../src/core/pillars.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts";
1617 import { createToken } from "../src/core/tokens.ts";
1718 import { createUser, type User } from "../src/core/users.ts";
1819 import { serve, type Servers } from "../src/http/server.ts";
@@ -68,7 +69,79 @@ describe("POST …/plan", () => {
6869 await rm(dataDir, { recursive: true, force: true });
6970 });
7071
72 const proposals = () =>
73 app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM plan_proposal").get()!.n;
74 const nod = (on: boolean) =>
75 updateRepoSettings(app.db, getRepoById(app.db, repo.id)!, { plansNeedNod: on });
76
77 test("plans need a nod, on: one proposal is stored and no issue is opened", async () => {
78 expect(getRepoById(app.db, repo.id)!.plansNeedNod).toBe(false);
79 nod(true);
80 const before = titles().length;
81 const stored = proposals();
82 const res = await post(rw, [
83 { key: "a", title: "Prune the roses", pillar: "P0" },
84 { key: "b", title: "Mulch", pillar: "P0", blockedBy: ["a"] },
85 ]);
86 expect(res.status).toBe(202);
87 const body = (await res.json()) as { status: string; id: string };
88 expect(body.status).toBe("proposed");
89 expect(titles().length).toBe(before);
90 expect(proposals()).toBe(stored + 1);
91 const row = app.db
92 .query<{ issues: string }, [string]>("SELECT issues FROM plan_proposal WHERE id = ?1")
93 .get(body.id)!;
94 expect(JSON.parse(row.issues).map((i: { key: string }) => i.key)).toEqual(["a", "b"]);
95 });
96
97 test("a title or why that is not text or is too long is refused and stores nothing", () => {
98 nod(true);
99 const r = getRepoById(app.db, repo.id)!;
100 const stored = proposals();
101 const items = [{ key: "a", title: "Prune", body: "", pillar: "P0", blockedBy: [] }];
102 const refuse = (say: { title?: unknown; why?: unknown }) => {
103 const out = proposePlan(app, alice, r, items, say);
104 expect(out.ok).toBe(false);
105 return out.ok ? "" : out.error.error;
106 };
107 expect(refuse({ title: 5 })).toBe("invalid");
108 expect(refuse({ why: ["x"] })).toBe("invalid");
109 expect(refuse({ title: "x".repeat(10_000) })).toBe("title_too_long");
110 expect(refuse({ why: "x".repeat(1_000_000) })).toBe("body_too_long");
111 expect(proposals()).toBe(stored);
112 });
113
114 test("a non-boolean plansNeedNod in the PATCH is a 422 and the setting stays", async () => {
115 nod(false);
116 const res = await fetch(`${servers.url}/api/v1/repos/alice/garden`, {
117 method: "PATCH",
118 headers: { authorization: `Bearer ${createToken(app.db, alice.id, "adm", "admin").token}`, "content-type": "application/json" },
119 body: JSON.stringify({ plansNeedNod: "yes" }),
120 });
121 expect(res.status).toBe(422);
122 expect(getRepoById(app.db, repo.id)!.plansNeedNod).toBe(false);
123 });
124
125 test("on, a pillarless issue is still refused with no_pillar and nothing is stored", async () => {
126 const stored = proposals();
127 const res = await post(rw, [{ key: "a", title: "Prune" }]);
128 expect(res.status).toBe(422);
129 expect(((await res.json()) as { error: string }).error).toBe("no_pillar");
130 expect(proposals()).toBe(stored);
131 });
132
133 test("off, the plan opens its issues as before and stores no proposal", async () => {
134 nod(false);
135 const stored = proposals();
136 const res = await post(rw, [{ key: "a", title: "Sweep the path", pillar: "P0" }]);
137 expect(res.status).toBe(201);
138 expect(titles()).toContain("Sweep the path");
139 expect(proposals()).toBe(stored);
140 nod(true);
141 });
142
71143 test("a plan with a pillarless issue is refused with no_pillar and opens nothing; with the pillar named it opens the issues under the umbrella", async () => {
144 nod(false);
72145 const before = titles().length;
73146 const first = await post(rw, [{ key: "a", title: "Water the beds" }]);
74147 expect(((await first.json()) as { error: string }).error).toBe("no_pillar");
@@ -90,6 +163,7 @@ describe("POST …/plan", () => {
90163 });
91164
92165 test("a person's token plans the same way, all or nothing, and a read token is refused", async () => {
166 nod(false);
93167 const before = titles().length;
94168 const refused = await post(rw, [
95169 { key: "x", title: "Fine", pillar: "P0" },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs