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

A bug closed as won't fix must carry a reason #8798

Merged opened this

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

Merged

scout/forge:scout/8782-a-bug-closed-as-won-t-fix-must-carry-a-r → main 5 commits fork

  • Merged. scout/8782-a-bug-closed-as-won-t-fix-must-carry-a-r was merged into main as 08796f0.

Review

Sign in to review this change.
13 files changed +374 −24
docs/features/bugs.md added +23
@@ -0,0 +1,23 @@
1 # A bug closes with a reason
2
3 An issue labelled `bug` cannot be closed by hand with nothing behind it. Unless
4 a merged pull closed it, commits on main already name it, or it was split into
5 other issues, the close needs a `note`: one sentence, at most 300 characters. It
6 is posted as a comment (`Closed as won't fix: …` when the reason is `dropped`,
7 `Closed: …` otherwise). A missing note is `reason_required`; a note that is too
8 long or not text is `invalid_note`.
9
10 - Page: the close form has a "why" field beside the reason select; it is
11 optional for any issue that is not a bug.
12 - API: `PATCH …/issues/:number` with `{ state: "closed", note }`; without it on a bug, `422 reason_required`.
13 - MCP: `close` takes `note`; the same refusals.
14 - Terminal: `close <issue> "why"`. The reason is a note on every issue, bug or not.
15 The issue screen's `x` has no field for one, so it refuses a bug and points to the page.
16
17 Exempt, because they say why themselves: the crew's live triage (`triage`), its
18 stale sweeps (`stale`), and the forge's own closes (`system`: the sentinel, the
19 superseded-pull sweep, the gate review, an app note withdrawn). Each is tested in
20 `test/bug-close-doors.test.ts`.
21
22 On a mirrored repository the close is forwarded to the origin, but the note is
23 kept as a comment on this forge only: the origin does not see why.
src/actions/app_notes.ts +2 −2
@@ -229,7 +229,7 @@ export async function sendNote(
229229 // Without its label the crew's warning would not show: take it back.
230230 console.error(`app note: issue #${opened.value.number} could not take the ${FROM_APP_LABEL} label: ${labelled.error}`);
231231 // Through the action layer, as the owner: the bus, the webhooks and a mirror's origin see it.
232 const closed = await setIssueStateThrough(app, owner, repo, opened.value, "closed", { reason: "dropped" });
232 const closed = await setIssueStateThrough(app, owner, repo, opened.value, "closed", { reason: "dropped", system: true });
233233 if (!closed.ok) console.error(`app note: issue #${opened.value.number} could not be closed: ${closed.error}`);
234234 return unavailable;
235235 }
@@ -258,7 +258,7 @@ export async function moderateNote(
258258 const issue = getIssue(app.db, repo.id, note.issueNumber);
259259 // Close first: a note is hidden only once its issue is, wherever the issue lives.
260260 if (issue !== undefined && issue.state === "open") {
261 const closed = await setIssueStateThrough(app, actor, repo, issue, "closed", { reason: "dropped" });
261 const closed = await setIssueStateThrough(app, actor, repo, issue, "closed", { reason: "dropped", system: true });
262262 if (!closed.ok) {
263263 console.error(`app note: issue #${issue.number} could not be closed: ${closed.error}`);
264264 return err("unavailable");
src/actions/gate_review.ts +1 −1
@@ -103,7 +103,7 @@ export function applyGateReview(
103103 const issue = getIssue(app.db, repo.id, number);
104104 const verdict = proposed.get(number)!;
105105 if (issue === undefined || issue.state !== "open") continue;
106 if (!setIssueState(app, actor, repo, issue, "closed", { reason: verdict.reason }).ok) continue;
106 if (!setIssueState(app, actor, repo, issue, "closed", { reason: verdict.reason, system: true }).ok) continue;
107107 // The marker follows the close, never the other way: a failed close leaves none.
108108 app.bus.append({
109109 kind: "issue.gate_reviewed",
src/actions/issues.ts +42 −2
@@ -381,7 +381,7 @@ export function setIssueState(
381381 closing: Closing = {},
382382 ): Result<
383383 Issue,
384 AccessError | "already_merged" | MirrorError | "invalid_reason"
384 AccessError | CloseError | MirrorError
385385 > {
386386 const user = requireParticipant(app, actor, repo);
387387 if (!user.ok) return user;
@@ -457,12 +457,35 @@ export function setIssueState(
457457 state === "closed" && issue.kind === "issue"
458458 ? (alreadyDone(app.db, [issue.id]).get(issue.id) ?? [])
459459 : [];
460 // A bug closed with no fix behind it (no commit on main, no split) says
461 // why; the crew's own live triage and stale sweeps note it themselves.
462 if (closing.note !== undefined && typeof closing.note !== "string") return err("invalid_note");
463 const note = (closing.note ?? "").trim();
464 if (note.length > MAX_NOTE) return err("invalid_note");
465 if (
466 state === "closed" &&
467 issue.kind === "issue" &&
468 reason !== "split" &&
469 doneBy.length === 0 &&
470 note === "" &&
471 !closing.triage &&
472 !closing.stale &&
473 !closing.system &&
474 labelsFor(app.db, issue.id).some((l) => l.name === "bug")
475 )
476 return err("reason_required");
460477 const changed = app.db.transaction(() => {
461478 const written = writeState(app.db, issue.id, state, reason);
462479 if (written && state === "closed") {
463480 clearFirst(app.db, issue.id);
464481 clearRank(app.db, issue.id);
465482 }
483 if (written && state === "closed" && note !== "")
484 insertComment(app.db, {
485 issueId: issue.id,
486 authorId: user.value.id,
487 body: `Closed${reason === "dropped" ? " as won't fix" : ""}: ${note}`,
488 });
466489 if (written && doneBy.length > 0)
467490 insertComment(app.db, {
468491 issueId: issue.id,
@@ -653,6 +676,16 @@ export function plannerCloseRefusal(
653676 : refused;
654677 }
655678
679 /** What a close can be refused for beyond access: a merged pull, a bad reason or note, a bug with no why. */
680 export type CloseError =
681 | "already_merged"
682 | "invalid_reason"
683 | "invalid_note"
684 | "reason_required";
685
686 /** The longest note a close takes: one sentence. */
687 export const MAX_NOTE = 300;
688
656689 /** How an issue closes: done unless said otherwise; split names its children. */
657690 export interface Closing {
658691 readonly reason?: CloseReason;
@@ -660,6 +693,10 @@ export interface Closing {
660693 readonly into?: readonly number[];
661694 /** The crew's live triage closed it, not the person whose name it carries. */
662695 readonly triage?: boolean;
696 /** Why, in a sentence: a bug closed with no fix behind it needs one. */
697 readonly note?: string;
698 /** The forge's own close: it says why itself, so a bug needs no note. */
699 readonly system?: boolean;
663700 /** A crew pull closed for staying conflicted 72 hours (`crew/stale_pulls.ts`). */
664701 readonly stale?: boolean;
665702 }
@@ -682,7 +719,10 @@ export function closingFrom(input: Record<string, unknown>): Closing | undefined
682719 return undefined;
683720 into = input.into as number[];
684721 }
722 // A note's shape is judged where it is used (`invalid_note`), not here.
723 const note = input.note as string | undefined;
685724 return {
725 ...(note === undefined ? {} : { note }),
686726 ...(reason === undefined ? {} : { reason: reason as CloseReason }),
687727 ...(into === undefined ? {} : { into }),
688728 };
@@ -844,7 +884,7 @@ export async function setIssueStateThrough(
844884 state: IssueState,
845885 closing: Closing = {},
846886 ): Promise<
847 Result<Issue, ThroughError<AccessError | "already_merged" | "invalid_reason">>
887 Result<Issue, ThroughError<AccessError | CloseError>>
848888 > {
849889 const user = requireParticipant(app, actor, repo);
850890 if (!user.ok) return user;
src/crew/autonomy.ts +1 −1
@@ -1644,7 +1644,7 @@ export function closeSuperseded(
16441644 }
16451645 if (about.assigneeId !== null) assignIssue(app, by, repo, about, null);
16461646 addComment(app, by, repo, about, `${CLOSED_NOTE}${named} (pull #${pull.number}).`);
1647 setIssueState(app, by, repo, about, "closed", { reason: "done" });
1647 setIssueState(app, by, repo, about, "closed", { reason: "done", system: true });
16481648 }
16491649 log.info("superseded pull closed", {
16501650 repo: `${repo.ownerName}/${repo.name}`,
src/crew/sentinel.ts +2 −2
@@ -890,7 +890,7 @@ function settleShared(app: App, repo: Repo, actor: User, now: number): void {
890890 log.warn("sentinel could not comment", { key: t.key, error: String(said.error) });
891891 continue;
892892 }
893 const closed = setIssueState(app, actor, repo, issue, "closed", { reason: "dropped" });
893 const closed = setIssueState(app, actor, repo, issue, "closed", { reason: "dropped", system: true });
894894 if (!closed.ok) {
895895 log.warn("sentinel could not close", { key: t.key, error: String(closed.error) });
896896 continue;
@@ -974,7 +974,7 @@ function closeHeld(app: App, repo: Repo, actor: User, active: ReadonlySet<string
974974 log.warn("sentinel could not comment", { key: t.key, error: String(said.error) });
975975 continue;
976976 }
977 const closed = setIssueState(app, actor, repo, issue, "closed", { reason: "done" });
977 const closed = setIssueState(app, actor, repo, issue, "closed", { reason: "done", system: true });
978978 if (!closed.ok) log.warn("sentinel could not close", { key: t.key, error: String(closed.error) });
979979 } catch (error) {
980980 log.error("sentinel close failed", { key: t.key, error: String(error) });
src/fleet/verbs.ts +8 −6
@@ -120,6 +120,8 @@ const WORDS: Record<string, string> = {
120120 assignee_cannot_read: "the assignee cannot read this repository",
121121 agent_not_on_crew: AGENT_NOT_ON_CREW,
122122 already_merged: "a merged pull stays closed",
123 reason_required: "a bug closed without a fix says why: add a reason",
124 invalid_note: "a reason is one sentence of at most 300 characters",
123125 finished: "already finished",
124126 not_agent: "only an agent run is queued again",
125127 not_finished: "only a cancelled or failed run is queued again",
@@ -370,12 +372,12 @@ export const VERBS: Record<string, Verb> = {
370372 async write(app, actor, t, after, value) {
371373 if (!t.issue || (after !== "open" && after !== "closed"))
372374 return words("not_found");
373 const closed = done(setIssueState(app, actor, t.repo, t.issue, after));
374 // Said after the close, so a refused close leaves no comment behind.
375 if (closed === undefined && after === "closed" && typeof value === "string" && value !== "") {
376 const said = addComment(app, actor, t.repo, t.issue, `Closed: ${value}`);
377 if (!said.ok) return words(String(said.error));
378 }
375 // The reason rides the close, so it is said with it or not at all,
376 // and a bug that needs one is refused without it.
377 const note = after === "closed" && typeof value === "string" ? value : "";
378 const closed = done(
379 setIssueState(app, actor, t.repo, t.issue, after, note === "" ? {} : { note }),
380 );
379381 return closed;
380382 },
381383 },
src/http/api.ts +8
@@ -603,6 +603,14 @@ export function originAware(error: string, status = 422): Response {
603603 422,
604604 "a reason is done, dropped, or split (with `into`: the issues it became); only an issue closing takes one",
605605 );
606 if (error === "invalid_note")
607 return apiError("invalid_note", 422, "`note` is one sentence of text, at most 300 characters");
608 if (error === "reason_required")
609 return apiError(
610 "reason_required",
611 422,
612 "a bug closed without a fix says why: send `note` (one sentence, at most 300 characters)",
613 );
606614 if (error === "mirrored")
607615 return apiError(
608616 "mirrored",
src/http/mcp/tools/close.ts +6 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../
44
55 export const tool: Tool = {
66 name: "close",
7 doc: "`{ repo, number, state?, reason?, into? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; answers `state` and `closeReason`",
7 doc: "`{ repo, number, state?, reason?, into?, note? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; a bug closed with no fix needs `note` (why, one sentence); answers `state` and `closeReason`",
88 description:
99 "Close or reopen (`state` open) an issue or pull. `reason`: done, dropped, split (`into`).",
1010 inputSchema: {
@@ -14,6 +14,7 @@ export const tool: Tool = {
1414 number: { type: "integer", minimum: 1 },
1515 state: { type: "string", enum: ["closed", "open"] },
1616 reason: { type: "string", enum: [...CLOSE_REASONS] },
17 note: { type: "string", maxLength: 300 },
1718 into: {
1819 type: "array",
1920 items: { type: "integer", minimum: 1 },
@@ -37,6 +38,10 @@ export const tool: Tool = {
3738 return bad("invalid_reason", "a reason is done, dropped, or split (with `into`: up to 50 issue numbers)");
3839 const changed = await setIssueStateThrough(ctx.app, user, repo, issue, state, closing);
3940 if (!changed.ok) {
41 if (changed.error === "invalid_note")
42 return bad("invalid_note", "`note` is one sentence of text, at most 300 characters");
43 if (changed.error === "reason_required")
44 return bad("reason_required", "a bug closed without a fix says why: pass `note`");
4045 // A planner told no learns which rule refused it.
4146 const rule =
4247 changed.error === "forbidden" && state === "closed"
src/tui/screens/common.ts +2
@@ -947,6 +947,8 @@ const MESSAGES: Record<string, string> = {
947947 throttled: "too many attempts: try again in a minute",
948948 already_forked: "you already have a fork of this repo",
949949 already_merged: "this pull is already merged",
950 reason_required: "a bug closed without a fix says why: close it from the page or the API with a note",
951 invalid_note: "a reason is one sentence of at most 300 characters",
950952 unknown_label: "no such label",
951953 unknown_branch: "no such branch",
952954 unknown_base: "the base branch does not exist",
src/web/pages/thread.ts +24 −9
@@ -551,6 +551,14 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html {
551551 inputmode="numeric"
552552 placeholder="#, #"
553553 aria-label="Issue numbers it was split into"
554 />
555 <input
556 class="input"
557 name="note"
558 form="thread-state"
559 maxlength="300"
560 placeholder="why (a bug needs one; else optional)"
561 aria-label="Why it closes, in a sentence"
554562 />`
555563 : null;
556564 const closeButton =
@@ -855,11 +863,18 @@ export async function stateSubmit(
855863 tone: "danger",
856864 message: "Close as done, dropped, or split into issue numbers.",
857865 });
866 const note = field(form, "note").trim();
867 if (note.length > 300)
868 return flashOrJson(ctx, dest, {
869 tone: "danger",
870 message: "A reason is one sentence of at most 300 characters.",
871 });
858872 const closing: Closing =
859 wanted === "closed" && kind === "issue" && why !== ""
873 wanted === "closed" && kind === "issue"
860874 ? {
861 reason: why as CloseReason,
875 ...(why !== "" ? { reason: why as CloseReason } : {}),
862876 ...(why === "split" ? { into } : {}),
877 ...(note !== "" ? { note } : {}),
863878 }
864879 : {};
865880 // The `…Through` twin: identical on a native repo, and on a mirror it
@@ -879,13 +894,13 @@ export async function stateSubmit(
879894 studioRecord(ctx, found.repo) !== undefined
880895 ? "A merged change cannot be reopened."
881896 : "A merged pull request cannot be reopened.";
882 const message =
883 originError(changed.error) ??
884 (changed.error === "already_merged"
885 ? merged
886 : changed.error === "forbidden"
887 ? "Only the author or someone with write access can do that."
888 : NOT_CHANGED);
897 const refusals: Record<string, string> = {
898 reason_required: "A bug closed without a fix says why: add a sentence.",
899 invalid_note: "A reason is one sentence of at most 300 characters.",
900 already_merged: merged,
901 forbidden: "Only the author or someone with write access can do that.",
902 };
903 const message = originError(changed.error) ?? refusals[changed.error] ?? NOT_CHANGED;
889904 return flashOrJson(ctx, dest, { tone: "danger", message });
890905 }
891906 return flashOrJson(ctx, dest, {
test/bug-close-doors.test.ts added +183
@@ -0,0 +1,183 @@
1 /**
2 * A bug closed with no fix behind it says why, through every door: the PATCH
3 * route, the MCP `close` tool, the page's form, the terminal's `close` verb;
4 * and the closes that need no note (triage, stale, system, split, a pull).
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { setIssueState } from "../src/actions/issues.ts";
13 import { createIssue, createLabel, getIssue, getIssueById, listComments, setIssueLabels, type Issue } from "../src/core/issues.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { applyFleetOp } from "../src/actions/fleet.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { FLASH_COOKIE } from "../src/web/flash.ts";
19 import type { Repo } from "../src/core/repos.ts";
20 import type { User } from "../src/core/users.ts";
21
22 const PASSWORD = "a-good-passphrase";
23
24 describe("a bug closes with a reason, through every door", () => {
25 let dataDir: string;
26 let app: App;
27 let servers: Servers;
28 let base: string;
29 let alice: User;
30 let repo: Repo;
31 let token: string;
32 let cookie: string;
33 let bugId: string;
34 let n = 0;
35 let rpc = 1;
36
37 const bug = (label = true): Issue => {
38 const made = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: `Bug ${++n}` });
39 if (!made.ok) throw new Error(made.error);
40 if (label) setIssueLabels(app.db, made.value.id, [bugId]);
41 return made.value;
42 };
43 const state = (issue: Issue) => getIssueById(app.db, issue.id)!.state;
44 const patch = (issue: Issue, body: unknown) =>
45 fetch(`${base}/api/v1/repos/alice/game/issues/${issue.number}`, {
46 method: "PATCH",
47 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
48 body: JSON.stringify(body),
49 });
50 const mcp = async (args: unknown) => {
51 const res = await fetch(`${base}/mcp`, {
52 method: "POST",
53 headers: {
54 "content-type": "application/json",
55 accept: "application/json, text/event-stream",
56 authorization: `Bearer ${token}`,
57 },
58 body: JSON.stringify({
59 jsonrpc: "2.0",
60 id: rpc++,
61 method: "tools/call",
62 params: { name: "close", arguments: args },
63 }),
64 });
65 const body: any = await res.json();
66 return { error: body.result.isError as boolean, text: body.result.content[0].text as string };
67 };
68 const form = async (issue: Issue, fields: [string, string][]): Promise<string> => {
69 const res = await fetch(`${base}/alice/game/issues/${issue.number}/state`, {
70 method: "POST",
71 headers: { cookie, origin: base, "content-type": "application/x-www-form-urlencoded" },
72 body: new URLSearchParams([["state", "closed"], ...fields]).toString(),
73 redirect: "manual",
74 });
75 const flash = res.headers.getSetCookie().map((c) => c.split(";")[0]!).find((c) => c.startsWith(`${FLASH_COOKIE}=`));
76 return JSON.parse(decodeURIComponent(flash!.slice(FLASH_COOKIE.length + 1))).message;
77 };
78
79 beforeAll(async () => {
80 dataDir = await mkdtemp(join(tmpdir(), "forge-bug-close-"));
81 app = createApp({ dataDir, port: 0 });
82 servers = await serve(app);
83 base = servers.url;
84 const a = await createUser(app.db, {
85 origin: "seed",
86 trusted: true,
87 username: "alice",
88 email: "[email protected]",
89 password: PASSWORD,
90 adminIfFirst: true,
91 });
92 if (!a.ok) throw new Error(a.error);
93 alice = a.value;
94 token = createToken(app.db, alice.id, "rw", "rw").token;
95 const made = await createRepo(app, alice, { name: "game", visibility: "public" });
96 if (!made.ok) throw new Error(String(made.error));
97 repo = made.value;
98 const label = createLabel(app.db, repo.id, "bug", "d73a4a");
99 if (!label.ok) throw new Error(label.error);
100 bugId = label.value.id;
101 cookie = (
102 (
103 await fetch(`${base}/signin`, {
104 method: "POST",
105 body: new URLSearchParams({ username: "alice", password: PASSWORD }),
106 redirect: "manual",
107 })
108 ).headers.get("set-cookie") ?? ""
109 ).split(";")[0]!;
110 }, 30_000);
111
112 afterAll(async () => {
113 servers.stop();
114 app.close();
115 await rm(dataDir, { recursive: true, force: true });
116 });
117
118 test("the API answers 422 reason_required, invalid_note for a bad one, and closes with a note", async () => {
119 const issue = bug();
120 const bare = await patch(issue, { state: "closed", reason: "dropped" });
121 expect(bare.status).toBe(422);
122 expect(((await bare.json()) as any).error).toBe("reason_required");
123 const long = await patch(issue, { state: "closed", note: "x".repeat(301) });
124 expect(((await long.json()) as any).error).toBe("invalid_note");
125 const odd = await patch(issue, { state: "closed", note: 7 });
126 expect(((await odd.json()) as any).error).toBe("invalid_note");
127 expect(state(issue)).toBe("open");
128 const ok = await patch(issue, { state: "closed", reason: "dropped", note: "works as designed" });
129 expect(ok.status).toBe(200);
130 expect(listComments(app.db, issue.id).map((c) => c.body)).toContain("Closed as won't fix: works as designed");
131 });
132
133 test("MCP close refuses the same way and takes a note", async () => {
134 const issue = bug();
135 const refused = await mcp({ repo: "alice/game", number: issue.number });
136 expect(refused.error).toBe(true);
137 expect(refused.text).toContain("reason_required");
138 expect(state(issue)).toBe("open");
139 const done = await mcp({ repo: "alice/game", number: issue.number, note: "cannot reproduce" });
140 expect(done.error).not.toBe(true);
141 expect(state(issue)).toBe("closed");
142 });
143
144 test("the page flashes the refusal and the long note, and closes with a note", async () => {
145 const issue = bug();
146 expect(await form(issue, [["reason", "dropped"]])).toContain("says why");
147 expect(await form(issue, [["note", "x".repeat(301)]])).toContain("300");
148 expect(state(issue)).toBe("open");
149 expect(await form(issue, [["reason", "dropped"], ["note", "by design"]])).toBe("Closed.");
150 expect(state(issue)).toBe("closed");
151 });
152
153 test("the terminal's close verb refuses a bug without a reason and writes it with one", async () => {
154 const issue = bug();
155 const q = `issue repo:alice/game number:${issue.number}`;
156 const bare = await applyFleetOp(app, alice, q, "close");
157 expect(bare.ok && bare.value.rows[0]?.outcome).not.toBe("ok");
158 expect(state(issue)).toBe("open");
159 const said = await applyFleetOp(app, alice, q, { verb: "close", value: "not a bug" });
160 expect(said.ok && said.value.rows[0]?.outcome).toBe("ok");
161 expect(listComments(app.db, issue.id).map((c) => c.body)).toContain("Closed: not a bug");
162 });
163
164 test("a reason on a plain issue is optional and still written", () => {
165 const plain = bug(false);
166 expect(setIssueState(app, alice, repo, plain, "closed").ok).toBe(true);
167 });
168
169 test("the closes that need no note: triage, stale, system, split, commits on main", () => {
170 for (const closing of [{ triage: true }, { stale: true }, { system: true, reason: "done" as const }]) {
171 const issue = bug();
172 expect(setIssueState(app, alice, repo, issue, "closed", closing).ok).toBe(true);
173 }
174 const parent = bug();
175 const child = bug(false);
176 expect(setIssueState(app, alice, repo, parent, "closed", { reason: "split", into: [child.number] }).ok).toBe(true);
177 // Without any exemption, the same close is refused.
178 const bare = bug();
179 const refused = setIssueState(app, alice, repo, bare, "closed");
180 expect(!refused.ok && refused.error).toBe("reason_required");
181 expect(getIssue(app.db, repo.id, bare.number)!.state).toBe("open");
182 });
183 });
test/bug-close-reason.test.ts added +72
@@ -0,0 +1,72 @@
1 /** A bug closed with no fix behind it must say why: the action, the API's code, the terminal's words. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { closingFrom, openIssue, setIssueLabels, setIssueState } from "../src/actions/issues.ts";
7 import { createLabel, createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { listComments } from "../src/core/issues.ts";
10 import { originAware } from "../src/http/api.ts";
11 import { signUpProven as signUp } from "./helpers/verified.ts";
12 import { promote } from "./helpers/members.ts";
13 import type { User } from "../src/core/users.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { words } from "../src/fleet/verbs.ts";
16
17 let dir: string;
18 let app: App;
19 let alice: User;
20 let repo: Repo;
21
22 beforeAll(async () => {
23 dir = await mkdtemp(join(tmpdir(), "bug-close-"));
24 app = createApp({ dataDir: dir, port: 0 });
25 const made = await signUp(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
26 if (!made.ok) throw new Error("user");
27 const extra = await signUp(app, { username: "bob", email: "[email protected]", password: "a-long-passphrase" });
28 if (!extra.ok) throw new Error("user");
29 alice = made.value;
30 promote(app.db, alice, extra.value);
31 const r = await createRepo(app, alice, { name: "p", visibility: "public" });
32 if (!r.ok) throw new Error("repo");
33 repo = r.value;
34 if (!createLabel(app, alice, repo, "bug", "d73a4a").ok) throw new Error("label");
35 });
36 afterAll(async () => {
37 await app.close();
38 await rm(dir, { recursive: true, force: true });
39 });
40
41 function issue(title: string, label: boolean) {
42 const o = openIssue(app, alice, repo, { title });
43 if (!o.ok) throw new Error("open");
44 if (label && !setIssueLabels(app, alice, repo, o.value, ["bug"]).ok) throw new Error("label");
45 return o.value;
46 }
47
48 test("a bug closed without a fix is refused until it says why", () => {
49 const bug = issue("crash", true);
50 expect(setIssueState(app, alice, repo, bug, "closed", { reason: "dropped" })).toEqual({
51 ok: false,
52 error: "reason_required",
53 });
54 expect(setIssueState(app, alice, repo, bug, "closed", { note: " " })).toEqual({
55 ok: false,
56 error: "reason_required",
57 });
58 const closed = setIssueState(app, alice, repo, bug, "closed", { reason: "dropped", note: "works as designed" });
59 expect(closed.ok).toBe(true);
60 expect(listComments(app.db, bug.id).map((c) => c.body)).toContain("Closed as won't fix: works as designed");
61 });
62
63 test("an issue that is not a bug closes as before", () => {
64 const plain = issue("idea", false);
65 expect(setIssueState(app, alice, repo, plain, "closed", { reason: "dropped" }).ok).toBe(true);
66 });
67
68 test("the API and the terminal name the refusal; the note is read from the request", () => {
69 expect(originAware("reason_required").status).toBe(422);
70 expect(words("reason_required")).toContain("says why");
71 expect(closingFrom({ reason: "dropped", note: " no repro " })).toEqual({ reason: "dropped", note: " no repro " });
72 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs