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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5645-board-vote-and-milestone-watch-refusals → main 1 commit fork
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"; | ||
| 29 | 29 | import { readFlow } from "../flow/read.ts"; |
| 30 | 30 | import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel, type PinError } from "../actions/board-pins.ts"; |
| 31 | 31 | 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"; | |
| 32 | 33 | import { panelData, parseWindow, rowsCsv } from "../boards/panel_data.ts"; |
| 33 | 34 | import { runQuery } from "../boards/query.ts"; |
| 34 | 35 | import { recordDrill } from "../core/domain_drills.ts"; |
| @@ -1560,7 +1561,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1560 | 1561 | const boardFailed = (e: BoardError): Response => { |
| 1561 | 1562 | if (e === "not_found") return notFound(); |
| 1562 | 1563 | 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); | |
| 1564 | 1565 | }; |
| 1565 | 1566 | |
| 1566 | 1567 | router.add("GET", "/api/v1/boards", async (ctx) => { |
| @@ -3690,7 +3691,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3690 | 3691 | ? notFound() |
| 3691 | 3692 | : set.error === "unauthorized" |
| 3692 | 3693 | ? unauthorized() |
| 3693 | : apiError("forbidden", 403, "only a person watches a milestone"); | |
| 3694 | : apiError("forbidden", 403, MILESTONE_WATCH_PERSON_ONLY); | |
| 3694 | 3695 | }, |
| 3695 | 3696 | ); |
| 3696 | 3697 | // One action on a whole limb (#733): defer, resume, or close as not |
| @@ -4484,7 +4485,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4484 | 4485 | if (!voted.ok) { |
| 4485 | 4486 | if (voted.error === "forbidden" || voted.error === "unauthorized") |
| 4486 | 4487 | 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); | |
| 4488 | 4489 | } |
| 4489 | 4490 | return json({ answer, closed: voted.value.closed }, 201); |
| 4490 | 4491 | }, |
src/http/mcp.ts +4 −3
| @@ -258,6 +258,7 @@ import { manifestFor } from "../actions/here.ts"; | ||
| 258 | 258 | import { clientBucket } from "./peer.ts"; |
| 259 | 259 | import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel } from "../actions/board-pins.ts"; |
| 260 | 260 | 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"; | |
| 261 | 262 | import { panelData, parseWindow } from "../boards/panel_data.ts"; |
| 262 | 263 | import { acceptReport, dismissReport, fileReport, waitingReports } from "../actions/reports.ts"; |
| 263 | 264 | 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" | ||
| 467 | 468 | function boardFailed(e: BoardError): Answer { |
| 468 | 469 | if (e === "not_found") return bad("not_found", "no such board"); |
| 469 | 470 | 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); | |
| 471 | 472 | } |
| 472 | 473 | |
| 473 | 474 | const TOOLS: readonly Tool[] = [ |
| @@ -990,7 +991,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 990 | 991 | ); |
| 991 | 992 | return voted.ok |
| 992 | 993 | ? good({ answer, closed: voted.value.closed }) |
| 993 | : bad(voted.error, "that vote could not be recorded"); | |
| 994 | : bad(voted.error, VOTE_NOT_RECORDED); | |
| 994 | 995 | }, |
| 995 | 996 | }, |
| 996 | 997 | { |
| @@ -3271,7 +3272,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3271 | 3272 | ); |
| 3272 | 3273 | return set.ok |
| 3273 | 3274 | ? good(set.value) |
| 3274 | : bad("forbidden", "only a person watches a milestone"); | |
| 3275 | : bad("forbidden", MILESTONE_WATCH_PERSON_ONLY); | |
| 3275 | 3276 | }, |
| 3276 | 3277 | }, |
| 3277 | 3278 | { |
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 | }); |
new above
offline