Public: anyone can read this; the crew can change it.
A bug closed as won't fix must carry a reason #8798
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8782-a-bug-closed-as-won-t-fix-must-carry-a-r → main 5 commits fork
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( | ||
| 229 | 229 | // Without its label the crew's warning would not show: take it back. |
| 230 | 230 | console.error(`app note: issue #${opened.value.number} could not take the ${FROM_APP_LABEL} label: ${labelled.error}`); |
| 231 | 231 | // 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 }); | |
| 233 | 233 | if (!closed.ok) console.error(`app note: issue #${opened.value.number} could not be closed: ${closed.error}`); |
| 234 | 234 | return unavailable; |
| 235 | 235 | } |
| @@ -258,7 +258,7 @@ export async function moderateNote( | ||
| 258 | 258 | const issue = getIssue(app.db, repo.id, note.issueNumber); |
| 259 | 259 | // Close first: a note is hidden only once its issue is, wherever the issue lives. |
| 260 | 260 | 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 }); | |
| 262 | 262 | if (!closed.ok) { |
| 263 | 263 | console.error(`app note: issue #${issue.number} could not be closed: ${closed.error}`); |
| 264 | 264 | return err("unavailable"); |
src/actions/gate_review.ts +1 −1
| @@ -103,7 +103,7 @@ export function applyGateReview( | ||
| 103 | 103 | const issue = getIssue(app.db, repo.id, number); |
| 104 | 104 | const verdict = proposed.get(number)!; |
| 105 | 105 | 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; | |
| 107 | 107 | // The marker follows the close, never the other way: a failed close leaves none. |
| 108 | 108 | app.bus.append({ |
| 109 | 109 | kind: "issue.gate_reviewed", |
src/actions/issues.ts +42 −2
| @@ -381,7 +381,7 @@ export function setIssueState( | ||
| 381 | 381 | closing: Closing = {}, |
| 382 | 382 | ): Result< |
| 383 | 383 | Issue, |
| 384 | AccessError | "already_merged" | MirrorError | "invalid_reason" | |
| 384 | AccessError | CloseError | MirrorError | |
| 385 | 385 | > { |
| 386 | 386 | const user = requireParticipant(app, actor, repo); |
| 387 | 387 | if (!user.ok) return user; |
| @@ -457,12 +457,35 @@ export function setIssueState( | ||
| 457 | 457 | state === "closed" && issue.kind === "issue" |
| 458 | 458 | ? (alreadyDone(app.db, [issue.id]).get(issue.id) ?? []) |
| 459 | 459 | : []; |
| 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"); | |
| 460 | 477 | const changed = app.db.transaction(() => { |
| 461 | 478 | const written = writeState(app.db, issue.id, state, reason); |
| 462 | 479 | if (written && state === "closed") { |
| 463 | 480 | clearFirst(app.db, issue.id); |
| 464 | 481 | clearRank(app.db, issue.id); |
| 465 | 482 | } |
| 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 | }); | |
| 466 | 489 | if (written && doneBy.length > 0) |
| 467 | 490 | insertComment(app.db, { |
| 468 | 491 | issueId: issue.id, |
| @@ -653,6 +676,16 @@ export function plannerCloseRefusal( | ||
| 653 | 676 | : refused; |
| 654 | 677 | } |
| 655 | 678 | |
| 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 | ||
| 656 | 689 | /** How an issue closes: done unless said otherwise; split names its children. */ |
| 657 | 690 | export interface Closing { |
| 658 | 691 | readonly reason?: CloseReason; |
| @@ -660,6 +693,10 @@ export interface Closing { | ||
| 660 | 693 | readonly into?: readonly number[]; |
| 661 | 694 | /** The crew's live triage closed it, not the person whose name it carries. */ |
| 662 | 695 | 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; | |
| 663 | 700 | /** A crew pull closed for staying conflicted 72 hours (`crew/stale_pulls.ts`). */ |
| 664 | 701 | readonly stale?: boolean; |
| 665 | 702 | } |
| @@ -682,7 +719,10 @@ export function closingFrom(input: Record<string, unknown>): Closing | undefined | ||
| 682 | 719 | return undefined; |
| 683 | 720 | into = input.into as number[]; |
| 684 | 721 | } |
| 722 | // A note's shape is judged where it is used (`invalid_note`), not here. | |
| 723 | const note = input.note as string | undefined; | |
| 685 | 724 | return { |
| 725 | ...(note === undefined ? {} : { note }), | |
| 686 | 726 | ...(reason === undefined ? {} : { reason: reason as CloseReason }), |
| 687 | 727 | ...(into === undefined ? {} : { into }), |
| 688 | 728 | }; |
| @@ -844,7 +884,7 @@ export async function setIssueStateThrough( | ||
| 844 | 884 | state: IssueState, |
| 845 | 885 | closing: Closing = {}, |
| 846 | 886 | ): Promise< |
| 847 | Result<Issue, ThroughError<AccessError | "already_merged" | "invalid_reason">> | |
| 887 | Result<Issue, ThroughError<AccessError | CloseError>> | |
| 848 | 888 | > { |
| 849 | 889 | const user = requireParticipant(app, actor, repo); |
| 850 | 890 | if (!user.ok) return user; |
src/crew/autonomy.ts +1 −1
| @@ -1644,7 +1644,7 @@ export function closeSuperseded( | ||
| 1644 | 1644 | } |
| 1645 | 1645 | if (about.assigneeId !== null) assignIssue(app, by, repo, about, null); |
| 1646 | 1646 | 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 }); | |
| 1648 | 1648 | } |
| 1649 | 1649 | log.info("superseded pull closed", { |
| 1650 | 1650 | 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 { | ||
| 890 | 890 | log.warn("sentinel could not comment", { key: t.key, error: String(said.error) }); |
| 891 | 891 | continue; |
| 892 | 892 | } |
| 893 | const closed = setIssueState(app, actor, repo, issue, "closed", { reason: "dropped" }); | |
| 893 | const closed = setIssueState(app, actor, repo, issue, "closed", { reason: "dropped", system: true }); | |
| 894 | 894 | if (!closed.ok) { |
| 895 | 895 | log.warn("sentinel could not close", { key: t.key, error: String(closed.error) }); |
| 896 | 896 | continue; |
| @@ -974,7 +974,7 @@ function closeHeld(app: App, repo: Repo, actor: User, active: ReadonlySet<string | ||
| 974 | 974 | log.warn("sentinel could not comment", { key: t.key, error: String(said.error) }); |
| 975 | 975 | continue; |
| 976 | 976 | } |
| 977 | const closed = setIssueState(app, actor, repo, issue, "closed", { reason: "done" }); | |
| 977 | const closed = setIssueState(app, actor, repo, issue, "closed", { reason: "done", system: true }); | |
| 978 | 978 | if (!closed.ok) log.warn("sentinel could not close", { key: t.key, error: String(closed.error) }); |
| 979 | 979 | } catch (error) { |
| 980 | 980 | 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> = { | ||
| 120 | 120 | assignee_cannot_read: "the assignee cannot read this repository", |
| 121 | 121 | agent_not_on_crew: AGENT_NOT_ON_CREW, |
| 122 | 122 | 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", | |
| 123 | 125 | finished: "already finished", |
| 124 | 126 | not_agent: "only an agent run is queued again", |
| 125 | 127 | not_finished: "only a cancelled or failed run is queued again", |
| @@ -370,12 +372,12 @@ export const VERBS: Record<string, Verb> = { | ||
| 370 | 372 | async write(app, actor, t, after, value) { |
| 371 | 373 | if (!t.issue || (after !== "open" && after !== "closed")) |
| 372 | 374 | 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 | ); | |
| 379 | 381 | return closed; |
| 380 | 382 | }, |
| 381 | 383 | }, |
src/http/api.ts +8
| @@ -603,6 +603,14 @@ export function originAware(error: string, status = 422): Response { | ||
| 603 | 603 | 422, |
| 604 | 604 | "a reason is done, dropped, or split (with `into`: the issues it became); only an issue closing takes one", |
| 605 | 605 | ); |
| 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 | ); | |
| 606 | 614 | if (error === "mirrored") |
| 607 | 615 | return apiError( |
| 608 | 616 | "mirrored", |
src/http/mcp/tools/close.ts +6 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../ | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | 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`", | |
| 8 | 8 | description: |
| 9 | 9 | "Close or reopen (`state` open) an issue or pull. `reason`: done, dropped, split (`into`).", |
| 10 | 10 | inputSchema: { |
| @@ -14,6 +14,7 @@ export const tool: Tool = { | ||
| 14 | 14 | number: { type: "integer", minimum: 1 }, |
| 15 | 15 | state: { type: "string", enum: ["closed", "open"] }, |
| 16 | 16 | reason: { type: "string", enum: [...CLOSE_REASONS] }, |
| 17 | note: { type: "string", maxLength: 300 }, | |
| 17 | 18 | into: { |
| 18 | 19 | type: "array", |
| 19 | 20 | items: { type: "integer", minimum: 1 }, |
| @@ -37,6 +38,10 @@ export const tool: Tool = { | ||
| 37 | 38 | return bad("invalid_reason", "a reason is done, dropped, or split (with `into`: up to 50 issue numbers)"); |
| 38 | 39 | const changed = await setIssueStateThrough(ctx.app, user, repo, issue, state, closing); |
| 39 | 40 | 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`"); | |
| 40 | 45 | // A planner told no learns which rule refused it. |
| 41 | 46 | const rule = |
| 42 | 47 | changed.error === "forbidden" && state === "closed" |
src/tui/screens/common.ts +2
| @@ -947,6 +947,8 @@ const MESSAGES: Record<string, string> = { | ||
| 947 | 947 | throttled: "too many attempts: try again in a minute", |
| 948 | 948 | already_forked: "you already have a fork of this repo", |
| 949 | 949 | 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", | |
| 950 | 952 | unknown_label: "no such label", |
| 951 | 953 | unknown_branch: "no such branch", |
| 952 | 954 | 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 { | ||
| 551 | 551 | inputmode="numeric" |
| 552 | 552 | placeholder="#, #" |
| 553 | 553 | 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" | |
| 554 | 562 | />` |
| 555 | 563 | : null; |
| 556 | 564 | const closeButton = |
| @@ -855,11 +863,18 @@ export async function stateSubmit( | ||
| 855 | 863 | tone: "danger", |
| 856 | 864 | message: "Close as done, dropped, or split into issue numbers.", |
| 857 | 865 | }); |
| 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 | }); | |
| 858 | 872 | const closing: Closing = |
| 859 | wanted === "closed" && kind === "issue" && why !== "" | |
| 873 | wanted === "closed" && kind === "issue" | |
| 860 | 874 | ? { |
| 861 | reason: why as CloseReason, | |
| 875 | ...(why !== "" ? { reason: why as CloseReason } : {}), | |
| 862 | 876 | ...(why === "split" ? { into } : {}), |
| 877 | ...(note !== "" ? { note } : {}), | |
| 863 | 878 | } |
| 864 | 879 | : {}; |
| 865 | 880 | // The `…Through` twin: identical on a native repo, and on a mirror it |
| @@ -879,13 +894,13 @@ export async function stateSubmit( | ||
| 879 | 894 | studioRecord(ctx, found.repo) !== undefined |
| 880 | 895 | ? "A merged change cannot be reopened." |
| 881 | 896 | : "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; | |
| 889 | 904 | return flashOrJson(ctx, dest, { tone: "danger", message }); |
| 890 | 905 | } |
| 891 | 906 | 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 | }); |
new above
offline