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

Label and plan refusals on a thread live in one module, and the page and MCP read them from there #5578

Merged opened this

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

Merged

mason/forge:mason/5564-label-and-plan-refusals-on-a-thread-live → main 1 commit fork

  • Merged. mason/5564-label-and-plan-refusals-on-a-thread-live was merged into main as d94a72a.

Review

Sign in to review this change.
4 files changed +203 −6
src/actions/thread_words.ts added +9
@@ -0,0 +1,9 @@
1 /**
2 * The refusals a thread gives when someone changes its labels or plans it,
3 * written once so the page and the MCP tools say them the same way.
4 */
5 export const THREAD_SAYS = {
6 labelsForbidden: "You need write access to change labels.",
7 unknownLabel: "One of those labels no longer exists.",
8 planForbidden: "You need write access, or a place on the crew, to plan here.",
9 } as const;
src/http/mcp.ts +4 −3
@@ -31,6 +31,7 @@ import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
3131 import { listAccess } from "../actions/access_log.ts";
3232 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
3333 import { changeLimit, listLimits } from "../actions/limits.ts";
34 import { THREAD_SAYS } from "../actions/thread_words.ts";
3435 import { ISSUE_TEAMS_SAY, nameTeams } from "../actions/issue_teams.ts";
3536 import { teamsOfIssue } from "../core/issue_teams.ts";
3637 import { addTeamMember, createTeam, deleteTeam } from "../actions/orgs.ts";
@@ -3698,8 +3699,8 @@ const TOOLS: readonly Tool[] = [
36983699 applied.error,
36993700 originError(applied.error) ??
37003701 (applied.error === "unknown_label"
3701 ? "One of those labels no longer exists."
3702 : "You need write access to change labels."),
3702 ? THREAD_SAYS.unknownLabel
3703 : THREAD_SAYS.labelsForbidden),
37033704 );
37043705 const names = applied.value.map((l) => l.name);
37053706 return good({
@@ -3799,7 +3800,7 @@ const TOOLS: readonly Tool[] = [
37993800 return bad(
38003801 done.error,
38013802 dependencyWords[done.error] ??
3802 "You need write access, or a place on the crew, to plan here.",
3803 THREAD_SAYS.planForbidden,
38033804 );
38043805 return good({
38053806 says: `#${issue.number} ${stop ? "no longer waits" : "waits"} for #${blocker.number}.`,
src/web/pages/thread.ts +4 −3
@@ -9,6 +9,7 @@
99 */
1010
1111 import { mayView } from "../../core/sealed.ts";
12 import { THREAD_SAYS } from "../../actions/thread_words.ts";
1213 import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts";
1314 import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts";
1415 import { listTeams } from "../../core/teams.ts";
@@ -891,8 +892,8 @@ export async function labelsSubmit(
891892 message:
892893 originError(applied.error) ??
893894 (applied.error === "unknown_label"
894 ? "One of those labels no longer exists."
895 : "You need write access to change labels."),
895 ? THREAD_SAYS.unknownLabel
896 : THREAD_SAYS.labelsForbidden),
896897 });
897898 }
898899 return flashTo(ctx, dest, {
@@ -1095,7 +1096,7 @@ export async function dependencySubmit(ctx: Ctx): Promise<Response> {
10951096 cycle: `#${other.number} already waits for this one, directly or through others; linking would make a loop.`,
10961097 cross_repo: "Dependencies stay inside one repository.",
10971098 not_an_issue: "Only issues can depend on issues.",
1098 forbidden: "You need write access, or a place on the crew, to plan here.",
1099 forbidden: THREAD_SAYS.planForbidden,
10991100 };
11001101 return flashTo(ctx, dest, {
11011102 tone: "danger",
test/thread-words.test.ts added +186
@@ -0,0 +1,186 @@
1 /**
2 * Changing a thread's labels or planning it is refused in the same words by
3 * every door, because every door reads them from src/actions/thread_words.ts.
4 * Each refusal is triggered through the page and through MCP, and each
5 * answer must be the module's sentence.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { THREAD_SAYS } from "../src/actions/thread_words.ts";
14 import { createIssue, createLabel } from "../src/core/issues.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { FLASH_COOKIE } from "../src/web/flash.ts";
19 import { promote } from "./helpers/members.ts";
20
21 const PASSWORD = "a-good-passphrase";
22
23 describe("label and plan refusals on a thread", () => {
24 let dataDir: string;
25 let app: App;
26 let servers: Servers;
27 let base: string;
28 let aliceRw: string;
29 let bobRw: string;
30 let aliceCookie: string;
31 let bobCookie: string;
32 let first: number;
33 let second: number;
34 let id = 1;
35
36 const mcp = async (token: string, name: string, args: unknown) => {
37 const res = await fetch(`${base}/mcp`, {
38 method: "POST",
39 headers: {
40 "content-type": "application/json",
41 accept: "application/json, text/event-stream",
42 authorization: `Bearer ${token}`,
43 },
44 body: JSON.stringify({
45 jsonrpc: "2.0",
46 id: id++,
47 method: "tools/call",
48 params: { name, arguments: args },
49 }),
50 });
51 const body: any = await res.json();
52 expect(body.result.isError).toBe(true);
53 return body.result.content[0].text as string;
54 };
55 const signIn = async (username: string): Promise<string> =>
56 (
57 (
58 await fetch(`${base}/signin`, {
59 method: "POST",
60 body: new URLSearchParams({ username, password: PASSWORD }),
61 redirect: "manual",
62 })
63 ).headers.get("set-cookie") ?? ""
64 ).split(";")[0]!;
65 /** Post a form and read back the message its flash carries. */
66 const flashed = async (
67 path: string,
68 cookie: string,
69 fields: [string, string][],
70 ): Promise<string> => {
71 const res = await fetch(`${base}${path}`, {
72 method: "POST",
73 headers: {
74 cookie,
75 origin: base,
76 "content-type": "application/x-www-form-urlencoded",
77 },
78 body: new URLSearchParams(fields).toString(),
79 redirect: "manual",
80 });
81 expect(res.status).toBe(303);
82 const flash = res.headers
83 .getSetCookie()
84 .map((c) => c.split(";")[0]!)
85 .find((c) => c.startsWith(`${FLASH_COOKIE}=`));
86 expect(flash).toBeDefined();
87 return JSON.parse(decodeURIComponent(flash!.slice(FLASH_COOKIE.length + 1)))
88 .message;
89 };
90
91 beforeAll(async () => {
92 dataDir = await mkdtemp(join(tmpdir(), "forge-thread-words-"));
93 app = createApp({ dataDir, port: 0 });
94 servers = await serve(app);
95 base = servers.url;
96 const a = await createUser(app.db, {
97 origin: "seed",
98 trusted: true,
99 username: "alice",
100 email: "[email protected]",
101 password: PASSWORD,
102 adminIfFirst: true,
103 });
104 if (!a.ok) throw new Error(a.error);
105 const b = await createUser(app.db, {
106 origin: "seed",
107 trusted: true,
108 username: "bob",
109 email: "[email protected]",
110 password: PASSWORD,
111 });
112 if (!b.ok) throw new Error(b.error);
113 promote(app.db, b.value);
114 aliceRw = createToken(app.db, a.value.id, "rw", "rw").token;
115 bobRw = createToken(app.db, b.value.id, "rw", "rw").token;
116 const made = await createRepo(app, a.value, {
117 name: "game",
118 visibility: "public",
119 });
120 if (!made.ok) throw new Error(String(made.error));
121 createLabel(app.db, made.value.id, "bug", "d73a4a");
122 for (const title of ["First", "Second"]) {
123 const issue = createIssue(app.db, {
124 repoId: made.value.id,
125 authorId: a.value.id,
126 title,
127 });
128 if (!issue.ok) throw new Error(issue.error);
129 if (title === "First") first = issue.value.number;
130 else second = issue.value.number;
131 }
132 aliceCookie = await signIn("alice");
133 bobCookie = await signIn("bob");
134 }, 30_000);
135
136 afterAll(async () => {
137 servers.stop();
138 app.close();
139 await rm(dataDir, { recursive: true, force: true });
140 });
141
142 test("a label that does not exist", async () => {
143 expect(
144 await flashed(`/alice/game/issues/${first}/labels`, aliceCookie, [
145 ["labels", "nope"],
146 ]),
147 ).toBe(THREAD_SAYS.unknownLabel);
148 expect(
149 await mcp(aliceRw, "labels", {
150 repo: "alice/game",
151 number: first,
152 labels: ["nope"],
153 }),
154 ).toBe(`unknown_label: ${THREAD_SAYS.unknownLabel}`);
155 });
156
157 test("a reader changing labels", async () => {
158 expect(
159 await flashed(`/alice/game/issues/${first}/labels`, bobCookie, [
160 ["labels", "bug"],
161 ]),
162 ).toBe(THREAD_SAYS.labelsForbidden);
163 expect(
164 await mcp(bobRw, "labels", {
165 repo: "alice/game",
166 number: first,
167 labels: ["bug"],
168 }),
169 ).toBe(`forbidden: ${THREAD_SAYS.labelsForbidden}`);
170 });
171
172 test("a reader planning", async () => {
173 expect(
174 await flashed(`/alice/game/issues/${second}/dependencies`, bobCookie, [
175 ["blockedBy", String(first)],
176 ]),
177 ).toBe(THREAD_SAYS.planForbidden);
178 expect(
179 await mcp(bobRw, "wait_for", {
180 app: "alice/game",
181 issue: second,
182 blockedBy: first,
183 }),
184 ).toBe(`forbidden: ${THREAD_SAYS.planForbidden}`);
185 });
186 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs