Public: anyone can read this; the crew can change it.
Origin, app and report refusals live in one module, read by the API and MCP #5690
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5647-origin-app-and-report-refusals-live-in-o → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +78
−9
src/actions/origin_app_words.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | /** | |
| 2 | * The refusals an origin, an app and a report share across every door: the | |
| 3 | * API's error body and the MCP tool's answer say each from here, so the | |
| 4 | * wording is one edit away from changing everywhere (#5647). | |
| 5 | */ | |
| 6 | ||
| 7 | /** An origin (a git host to mirror from) answered nothing usable. */ | |
| 8 | export const ORIGIN_UNREACHABLE = "the origin could not be reached"; | |
| 9 | ||
| 10 | /** Making an app yours when it already is. */ | |
| 11 | export const APP_ALREADY_YOURS = "this app is already yours"; | |
| 12 | ||
| 13 | /** Deciding a report that was already decided. */ | |
| 14 | export const REPORT_ALREADY_DECIDED = "this report was already decided"; |
src/http/api.ts +11 −6
| @@ -16,6 +16,11 @@ import { | ||
| 16 | 16 | } from "../actions/upstream_follow.ts"; |
| 17 | 17 | import { getFollow } from "../core/upstream_follow.ts"; |
| 18 | 18 | import { showProvenance } from "../actions/provenance.ts"; |
| 19 | import { | |
| 20 | APP_ALREADY_YOURS, | |
| 21 | ORIGIN_UNREACHABLE, | |
| 22 | REPORT_ALREADY_DECIDED, | |
| 23 | } from "../actions/origin_app_words.ts"; | |
| 19 | 24 | import { getProvenance, provenanceFacts } from "../core/provenance.ts"; |
| 20 | 25 | import { goldenPath } from "../apps/golden_path.ts"; |
| 21 | 26 | import { upstreamPayload } from "../spawn/upstream.ts"; |
| @@ -772,7 +777,7 @@ function sourceFailure(error: SourceError): Response { | ||
| 772 | 777 | return apiError( |
| 773 | 778 | "origin_unreachable", |
| 774 | 779 | 502, |
| 775 | "the origin could not be reached", | |
| 780 | ORIGIN_UNREACHABLE, | |
| 776 | 781 | ); |
| 777 | 782 | } |
| 778 | 783 | } |
| @@ -806,7 +811,7 @@ function originAware(error: string, status = 422): Response { | ||
| 806 | 811 | return apiError( |
| 807 | 812 | "origin_unreachable", |
| 808 | 813 | 502, |
| 809 | "the origin could not be reached; nothing was changed", | |
| 814 | `${ORIGIN_UNREACHABLE}; nothing was changed`, | |
| 810 | 815 | ); |
| 811 | 816 | if (error === "origin_refused") |
| 812 | 817 | return apiError( |
| @@ -3350,7 +3355,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3350 | 3355 | ); |
| 3351 | 3356 | if (!accepted.ok) |
| 3352 | 3357 | return accepted.error === "decided" |
| 3353 | ? apiError("decided", 409, "this report was already decided") | |
| 3358 | ? apiError("decided", 409, REPORT_ALREADY_DECIDED) | |
| 3354 | 3359 | : originAware(accepted.error); |
| 3355 | 3360 | return json(withLabels(ctx, accepted.value), 201); |
| 3356 | 3361 | }, |
| @@ -3372,7 +3377,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3372 | 3377 | ); |
| 3373 | 3378 | if (!dismissed.ok) |
| 3374 | 3379 | return dismissed.error === "decided" |
| 3375 | ? apiError("decided", 409, "this report was already decided") | |
| 3380 | ? apiError("decided", 409, REPORT_ALREADY_DECIDED) | |
| 3376 | 3381 | : respondAccess(dismissed.error, 403); |
| 3377 | 3382 | return json({ id: ctx.params.id, state: "dismissed" }); |
| 3378 | 3383 | }, |
| @@ -4803,7 +4808,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4803 | 4808 | ); |
| 4804 | 4809 | if (!listed.ok) |
| 4805 | 4810 | return listed.error === "unreachable" |
| 4806 | ? apiError("origin_unreachable", 502, "the origin could not be reached") | |
| 4811 | ? apiError("origin_unreachable", 502, ORIGIN_UNREACHABLE) | |
| 4807 | 4812 | : respondAccess(listed.error, 404); |
| 4808 | 4813 | return timePhase(ctx.request, "render", () => |
| 4809 | 4814 | json(listed.value.map((row) => remoteRepoJson(ctx, row))), |
| @@ -5319,7 +5324,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5319 | 5324 | : made.error.kind === "invalid" |
| 5320 | 5325 | ? apiError("invalid", 422, made.error.message) |
| 5321 | 5326 | : made.error.kind === "own" |
| 5322 | ? apiError("own", 409, "this app is already yours") | |
| 5327 | ? apiError("own", 409, APP_ALREADY_YOURS) | |
| 5323 | 5328 | : made.error.kind === "unverified" |
| 5324 | 5329 | ? apiError("unverified", 403, "prove your email first") |
| 5325 | 5330 | : made.error.kind === "unauthorized" |
src/http/mcp.ts +8 −3
| @@ -20,6 +20,11 @@ | ||
| 20 | 20 | import { listRemoteRepos, listUserOrigins, syncOriginNow } from "../actions/origins.ts"; |
| 21 | 21 | import { originJson, remoteRepoJson } from "./api.ts"; |
| 22 | 22 | import { showProvenance } from "../actions/provenance.ts"; |
| 23 | import { | |
| 24 | APP_ALREADY_YOURS, | |
| 25 | ORIGIN_UNREACHABLE, | |
| 26 | REPORT_ALREADY_DECIDED, | |
| 27 | } from "../actions/origin_app_words.ts"; | |
| 23 | 28 | import { migrateMessage, migrateRepo, migrationJobFor } from "../actions/migrate.ts"; |
| 24 | 29 | import type { MirrorJob } from "../origins/jobs.ts"; |
| 25 | 30 | import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts"; |
| @@ -1891,7 +1896,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1891 | 1896 | const listed = await listRemoteRepos(ctx.app, user, origin); |
| 1892 | 1897 | if (!listed.ok) |
| 1893 | 1898 | return listed.error === "unreachable" |
| 1894 | ? bad("origin_unreachable", "the origin could not be reached") | |
| 1899 | ? bad("origin_unreachable", ORIGIN_UNREACHABLE) | |
| 1895 | 1900 | : bad("not_found", "no such origin"); |
| 1896 | 1901 | return good(listed.value.map((row) => remoteRepoJson(ctx, row))); |
| 1897 | 1902 | }, |
| @@ -2141,7 +2146,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2141 | 2146 | return bad("invalid", "give both `id` and `action`, or neither to list"); |
| 2142 | 2147 | const denied = (error: string): Answer => |
| 2143 | 2148 | error === "decided" |
| 2144 | ? bad("decided", "this report was already decided") | |
| 2149 | ? bad("decided", REPORT_ALREADY_DECIDED) | |
| 2145 | 2150 | : error === "not_found" |
| 2146 | 2151 | ? bad("not_found", "no such report here") |
| 2147 | 2152 | : bad( |
| @@ -3055,7 +3060,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3055 | 3060 | : made.error.kind === "invalid" |
| 3056 | 3061 | ? bad("invalid", made.error.message) |
| 3057 | 3062 | : made.error.kind === "own" |
| 3058 | ? bad("own", "this app is already yours") | |
| 3063 | ? bad("own", APP_ALREADY_YOURS) | |
| 3059 | 3064 | : made.error.kind === "not_member" |
| 3060 | 3065 | ? bad("not_member", NOT_A_MEMBER) |
| 3061 | 3066 | : made.error.kind === "unverified" |
test/origin-app-words.test.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * An unreachable origin, an app already yours and a report already decided | |
| 3 | * are said from one module, src/actions/origin_app_words.ts (#5647). The API | |
| 4 | * and the MCP import the sentences; a copy typed back into either fails here. | |
| 5 | */ | |
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { readFileSync } from "node:fs"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { | |
| 10 | APP_ALREADY_YOURS, | |
| 11 | ORIGIN_UNREACHABLE, | |
| 12 | REPORT_ALREADY_DECIDED, | |
| 13 | } from "../src/actions/origin_app_words.ts"; | |
| 14 | ||
| 15 | const root = join(import.meta.dir, ".."); | |
| 16 | const read = (path: string) => readFileSync(join(root, path), "utf8"); | |
| 17 | ||
| 18 | const SENTENCES = { | |
| 19 | ORIGIN_UNREACHABLE: "the origin could not be reached", | |
| 20 | APP_ALREADY_YOURS: "this app is already yours", | |
| 21 | REPORT_ALREADY_DECIDED: "this report was already decided", | |
| 22 | } as const; | |
| 23 | ||
| 24 | const DOORS = ["src/http/api.ts", "src/http/mcp.ts"] as const; | |
| 25 | ||
| 26 | describe("origin, app and report refusals live in one module", () => { | |
| 27 | test("the module says each sentence, word for word", () => { | |
| 28 | expect({ | |
| 29 | ORIGIN_UNREACHABLE, | |
| 30 | APP_ALREADY_YOURS, | |
| 31 | REPORT_ALREADY_DECIDED, | |
| 32 | }).toEqual(SENTENCES); | |
| 33 | }); | |
| 34 | ||
| 35 | for (const door of DOORS) { | |
| 36 | test(`${door} imports the sentences and types none of them out`, () => { | |
| 37 | const text = read(door); | |
| 38 | expect(text).toContain('from "../actions/origin_app_words.ts"'); | |
| 39 | for (const [name, sentence] of Object.entries(SENTENCES)) { | |
| 40 | expect(text).toContain(name); | |
| 41 | expect(text).not.toContain(sentence); | |
| 42 | } | |
| 43 | }); | |
| 44 | } | |
| 45 | }); |
new above
offline
Same bytes as before. Note that the terminal (src/tui/screens/common.ts:883) says "…; nothing changed" for this same refusal. That's left to the follow-up, because aligning it changes wording.