Public: anyone can read this; the crew can change it.
Following's refusals live in one module, read by the chain and following pages #5626
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5589-following-s-refusals-live-in-one-module → main 1 commit fork
4 files
changed +47
−7
src/actions/follow_words.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | /** | |
| 2 | * Following's refusals in words (#5589): said by the chain page and the | |
| 3 | * following page alike, so they are typed once and read from here. | |
| 4 | */ | |
| 5 | ||
| 6 | /** A repository with no parent to follow, read, take from or sever. */ | |
| 7 | export const FOLLOWS_NOTHING = "This repository follows nothing."; | |
| 8 | ||
| 9 | /** A reader asking to propose upstream without write access here. */ | |
| 10 | export const PROPOSE_NEEDS_WRITE = "You need write access to propose from here."; |
src/web/pages/chain.ts +4 −3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts"; |
| 9 | import { FOLLOWS_NOTHING, PROPOSE_NEEDS_WRITE } from "../../actions/follow_words.ts"; | |
| 9 | 10 | import { forgeProposals, proposeToParentForge, type ForgeProposal } from "../../actions/upstream.ts"; |
| 10 | 11 | import { canAdmin, canWrite } from "../../core/repos.ts"; |
| 11 | 12 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| @@ -196,7 +197,7 @@ export async function severSubmit(ctx: Ctx): Promise<Response> { | ||
| 196 | 197 | if (!cut.ok) |
| 197 | 198 | return flashOrJson(ctx, to, { |
| 198 | 199 | tone: "danger", |
| 199 | message: cut.error === "forbidden" ? "Only this forge's owner can sever it." : "This repository follows nothing.", | |
| 200 | message: cut.error === "forbidden" ? "Only this forge's owner can sever it." : FOLLOWS_NOTHING, | |
| 200 | 201 | }); |
| 201 | 202 | return flashOrJson(ctx, to, { message: `Severed: following nothing since ${cut.value.slice(0, 10)}. Nothing was deleted.` }); |
| 202 | 203 | } |
| @@ -228,7 +229,7 @@ export async function takeSubmit(ctx: Ctx): Promise<Response> { | ||
| 228 | 229 | if (!took.ok) |
| 229 | 230 | return flashOrJson(ctx, to, { |
| 230 | 231 | tone: "danger", |
| 231 | message: took.error === "not_following" ? "This repository follows nothing." : "Only this forge's owner can take updates.", | |
| 232 | message: took.error === "not_following" ? FOLLOWS_NOTHING : "Only this forge's owner can take updates.", | |
| 232 | 233 | }); |
| 233 | 234 | return flashOrJson(ctx, to, takeForgeWords(took.value)); |
| 234 | 235 | } |
| @@ -265,7 +266,7 @@ export async function proposeParentSubmit(ctx: Ctx): Promise<Response> { | ||
| 265 | 266 | : e === "nothing_to_propose" |
| 266 | 267 | ? "Nothing here is ahead of the parent forge." |
| 267 | 268 | : typeof e === "string" |
| 268 | ? "You need write access to propose from here." | |
| 269 | ? PROPOSE_NEEDS_WRITE | |
| 269 | 270 | : e.kind === "unreachable" |
| 270 | 271 | ? "Parent unreachable: nothing was proposed." |
| 271 | 272 | : `The parent forge did not take the proposal: ${e.reason}.`, |
src/web/pages/following.ts +5 −4
| @@ -38,6 +38,7 @@ import { | ||
| 38 | 38 | import { running } from "../../kernel/version.ts"; |
| 39 | 39 | import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 40 | 40 | import { convergeWords, takeOrTell } from "../../actions/follow.ts"; |
| 41 | import { FOLLOWS_NOTHING, PROPOSE_NEEDS_WRITE } from "../../actions/follow_words.ts"; | |
| 41 | 42 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 42 | 43 | import { field, readForm } from "../form.ts"; |
| 43 | 44 | import { html, when, type Html } from "../html.ts"; |
| @@ -378,7 +379,7 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> { | ||
| 378 | 379 | if (parent === undefined) |
| 379 | 380 | return flashOrJson(ctx, base, { |
| 380 | 381 | tone: "danger", |
| 381 | message: "This repository follows nothing.", | |
| 382 | message: FOLLOWS_NOTHING, | |
| 382 | 383 | }); |
| 383 | 384 | const parentName = `${parent.ownerName}/${parent.name}`; |
| 384 | 385 | |
| @@ -399,11 +400,11 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> { | ||
| 399 | 400 | tone: "danger", |
| 400 | 401 | message: |
| 401 | 402 | e === "not_a_fork" |
| 402 | ? "This repository follows nothing." | |
| 403 | ? FOLLOWS_NOTHING | |
| 403 | 404 | : e === "nothing_to_propose" |
| 404 | 405 | ? `Nothing here is ahead of ${parentName}.` |
| 405 | 406 | : typeof e === "string" |
| 406 | ? "You need write access to propose from here." | |
| 407 | ? PROPOSE_NEEDS_WRITE | |
| 407 | 408 | : `${parentName} did not take the proposal: ${e.reason}.`, |
| 408 | 409 | }); |
| 409 | 410 | } |
| @@ -427,7 +428,7 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> { | ||
| 427 | 428 | tone: "danger", |
| 428 | 429 | message: |
| 429 | 430 | e === "not_a_fork" |
| 430 | ? "This repository follows nothing." | |
| 431 | ? FOLLOWS_NOTHING | |
| 431 | 432 | : "You need write access to take updates here.", |
| 432 | 433 | }); |
| 433 | 434 | // A conflict is a tell for the crew (`actions/follow.ts`). |
test/follow-words.test.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | /** | |
| 2 | * Following's refusals are typed once, in `src/actions/follow_words.ts` | |
| 3 | * (#5589), and the chain and following pages read them from there. This | |
| 4 | * fails if a copy of a sentence is typed back into either page. | |
| 5 | */ | |
| 6 | ||
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { readFileSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { FOLLOWS_NOTHING, PROPOSE_NEEDS_WRITE } from "../src/actions/follow_words.ts"; | |
| 11 | ||
| 12 | const SENTENCES = { FOLLOWS_NOTHING, PROPOSE_NEEDS_WRITE }; | |
| 13 | const READERS = ["src/web/pages/chain.ts", "src/web/pages/following.ts"]; | |
| 14 | ||
| 15 | describe("following's words", () => { | |
| 16 | for (const [name, sentence] of Object.entries(SENTENCES)) { | |
| 17 | test(`${name} is a sentence`, () => { | |
| 18 | expect(sentence.trim().length).toBeGreaterThan(0); | |
| 19 | }); | |
| 20 | for (const file of READERS) { | |
| 21 | test(`${file} reads ${name} rather than typing it`, () => { | |
| 22 | const source = readFileSync(join(import.meta.dir, "..", file), "utf8"); | |
| 23 | expect(source).not.toContain(sentence); | |
| 24 | expect(source).toContain(name); | |
| 25 | }); | |
| 26 | } | |
| 27 | } | |
| 28 | }); |
new above
offline