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

Board, vote and milestone-watch refusals live in one module, read by the API and MCP #5688

Merged opened this

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

Merged

mason/forge:mason/5645-board-vote-and-milestone-watch-refusals → main 1 commit fork

  • Merged. mason/5645-board-vote-and-milestone-watch-refusals was merged into main as 4a639f2.

Review

Sign in to review this change.
4 files changed +64 −6
src/actions/board_words.ts added +14
@@ -0,0 +1,14 @@
1 /**
2 * The refusals a board save, a vote and a milestone watch answer with
3 * (#5645): one sentence each, said the same by the API and the MCP because
4 * both read it from here. Plain strings, no imports.
5 */
6
7 /** A board write refused for a reason its error code names. */
8 export const BOARD_NOT_SAVED = "that board could not be saved";
9
10 /** A vote refused for a reason its error code names. */
11 export const VOTE_NOT_RECORDED = "that vote could not be recorded";
12
13 /** An agent asked to watch a milestone; watching is a person's. */
14 export const MILESTONE_WATCH_PERSON_ONLY = "only a person watches a milestone";
src/http/api.ts +4 −3
@@ -29,6 +29,7 @@ import { parentForgeFor } from "../core/parent_releases.ts";
2929 import { readFlow } from "../flow/read.ts";
3030 import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel, type PinError } from "../actions/board-pins.ts";
3131 import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts";
32 import { BOARD_NOT_SAVED, MILESTONE_WATCH_PERSON_ONLY, VOTE_NOT_RECORDED } from "../actions/board_words.ts";
3233 import { panelData, parseWindow, rowsCsv } from "../boards/panel_data.ts";
3334 import { runQuery } from "../boards/query.ts";
3435 import { recordDrill } from "../core/domain_drills.ts";
@@ -1560,7 +1561,7 @@ export function apiRoutes(router: Router<Handler>): void {
15601561 const boardFailed = (e: BoardError): Response => {
15611562 if (e === "not_found") return notFound();
15621563 if (typeof e === "object") return apiError("refused", 422, e.refused);
1563 return apiError(e, e === "exists" || e === "branch_moved" ? 409 : 422, "that board could not be saved");
1564 return apiError(e, e === "exists" || e === "branch_moved" ? 409 : 422, BOARD_NOT_SAVED);
15641565 };
15651566
15661567 router.add("GET", "/api/v1/boards", async (ctx) => {
@@ -3690,7 +3691,7 @@ export function apiRoutes(router: Router<Handler>): void {
36903691 ? notFound()
36913692 : set.error === "unauthorized"
36923693 ? unauthorized()
3693 : apiError("forbidden", 403, "only a person watches a milestone");
3694 : apiError("forbidden", 403, MILESTONE_WATCH_PERSON_ONLY);
36943695 },
36953696 );
36963697 // One action on a whole limb (#733): defer, resume, or close as not
@@ -4484,7 +4485,7 @@ export function apiRoutes(router: Router<Handler>): void {
44844485 if (!voted.ok) {
44854486 if (voted.error === "forbidden" || voted.error === "unauthorized")
44864487 return respondAccess(voted.error, 422);
4487 return apiError(voted.error, 422, "that vote could not be recorded");
4488 return apiError(voted.error, 422, VOTE_NOT_RECORDED);
44884489 }
44894490 return json({ answer, closed: voted.value.closed }, 201);
44904491 },
src/http/mcp.ts +4 −3
@@ -258,6 +258,7 @@ import { manifestFor } from "../actions/here.ts";
258258 import { clientBucket } from "./peer.ts";
259259 import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel } from "../actions/board-pins.ts";
260260 import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts";
261 import { BOARD_NOT_SAVED, MILESTONE_WATCH_PERSON_ONLY, VOTE_NOT_RECORDED } from "../actions/board_words.ts";
261262 import { panelData, parseWindow } from "../boards/panel_data.ts";
262263 import { acceptReport, dismissReport, fileReport, waitingReports } from "../actions/reports.ts";
263264 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
@@ -467,7 +468,7 @@ const mayBoards = (user: User): boolean => user.isAdmin && user.kind !== "agent"
467468 function boardFailed(e: BoardError): Answer {
468469 if (e === "not_found") return bad("not_found", "no such board");
469470 if (typeof e === "object") return bad("refused", e.refused);
470 return bad(e, "that board could not be saved");
471 return bad(e, BOARD_NOT_SAVED);
471472 }
472473
473474 const TOOLS: readonly Tool[] = [
@@ -990,7 +991,7 @@ const TOOLS: readonly Tool[] = [
990991 );
991992 return voted.ok
992993 ? good({ answer, closed: voted.value.closed })
993 : bad(voted.error, "that vote could not be recorded");
994 : bad(voted.error, VOTE_NOT_RECORDED);
994995 },
995996 },
996997 {
@@ -3271,7 +3272,7 @@ const TOOLS: readonly Tool[] = [
32713272 );
32723273 return set.ok
32733274 ? good(set.value)
3274 : bad("forbidden", "only a person watches a milestone");
3275 : bad("forbidden", MILESTONE_WATCH_PERSON_ONLY);
32753276 },
32763277 },
32773278 {
test/board-words.test.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * A refused board save, vote or milestone watch is said from one place
3 * (#5645): src/actions/board_words.ts. The API and the MCP import the
4 * sentences; neither types one back.
5 */
6
7 import { describe, expect, test } from "bun:test";
8 import { readFileSync } from "node:fs";
9 import { join } from "node:path";
10 import {
11 BOARD_NOT_SAVED,
12 MILESTONE_WATCH_PERSON_ONLY,
13 VOTE_NOT_RECORDED,
14 } from "../src/actions/board_words.ts";
15
16 const ROOT = join(import.meta.dir, "..");
17 const read = (path: string): string => readFileSync(join(ROOT, path), "utf8");
18
19 const SENTENCES = {
20 BOARD_NOT_SAVED: "that board could not be saved",
21 VOTE_NOT_RECORDED: "that vote could not be recorded",
22 MILESTONE_WATCH_PERSON_ONLY: "only a person watches a milestone",
23 } as const;
24
25 const FILES = ["src/http/api.ts", "src/http/mcp.ts"];
26
27 describe("board, vote and milestone-watch refusals", () => {
28 test("the module says each sentence, word for word", () => {
29 expect({ BOARD_NOT_SAVED, VOTE_NOT_RECORDED, MILESTONE_WATCH_PERSON_ONLY }).toEqual({ ...SENTENCES });
30 });
31
32 for (const file of FILES) {
33 test(`${file} imports the sentences and types none of them`, () => {
34 const text = read(file);
35 expect(text).toContain('from "../actions/board_words.ts"');
36 for (const [name, sentence] of Object.entries(SENTENCES)) {
37 expect(text).not.toContain(sentence);
38 expect(text).toContain(name);
39 }
40 });
41 }
42 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs