Public: anyone can read this; the crew can change it.
Put back on a setting ask changes nothing unless its finding closes #2073
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2072-put-back-on-a-setting-ask-changes-nothin → main 4 commits fork 1 thread · 1 unresolved
6 files
changed +108
−12
README.md +4 −1
| @@ -682,7 +682,10 @@ signed-in site admin can answer, whatever they can write: anyone else, and any | ||
| 682 | 682 | agent, gets `not_found`/`unauthorized` and nothing changes. Keep closes the |
| 683 | 683 | finding and records the value in `sentinel_kept`; Put back goes through the |
| 684 | 684 | capacity page's own door (`changeCapacity`: same validation, record, event and |
| 685 | reversal) and then closes the finding. Only a person's close keeps a setting. | |
| 685 | reversal). The close is the gate: Put back changes the setting, and Keep records | |
| 686 | the value, only after a finding has closed. If none can (every one is on a | |
| 687 | mirrored repository, or the close is refused), nothing changes and the page and | |
| 688 | the terminal say so (`unclosable`). Only a person's close keeps a setting. | |
| 686 | 689 | |
| 687 | 690 | - **Board:** a site admin's forge strip shows the open findings and the last |
| 688 | 691 | hunt; a guest or member sees nothing. |
src/actions/setting_ask.ts +21 −8
| @@ -13,9 +13,13 @@ import { canWrite, getRepoById } from "../core/repos.ts"; | ||
| 13 | 13 | import type { User } from "../core/users.ts"; |
| 14 | 14 | import { recordKept } from "../crew/sentinel.ts"; |
| 15 | 15 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 16 | import { isMirror } from "../origins/write-through.ts"; | |
| 16 | 17 | import { changeCapacity } from "./capacity.ts"; |
| 17 | 18 | import { setIssueState } from "./issues.ts"; |
| 18 | 19 | |
| 20 | /** Why an answer is refused when no finding closes; the page and the terminal say it alike. */ | |
| 21 | export const UNCLOSABLE_WORDS = "its finding cannot be closed from here, so the setting was left as it is"; | |
| 22 | ||
| 19 | 23 | export type SettingAnswer = "keep" | "put"; |
| 20 | 24 | |
| 21 | 25 | /** The open findings for `setting:<key>` in repositories this person writes. */ |
| @@ -40,31 +44,40 @@ export function answerSetting( | ||
| 40 | 44 | key: unknown, |
| 41 | 45 | answer: unknown, |
| 42 | 46 | now = new Date(), |
| 43 | ): Result<{ key: string; answer: SettingAnswer }, "unauthorized" | "not_found" | "forbidden" | "invalid"> { | |
| 47 | ): Result<{ key: string; answer: SettingAnswer }, "unauthorized" | "not_found" | "forbidden" | "invalid" | "unclosable"> { | |
| 44 | 48 | if (!actor || actor.kind === "agent") return err("unauthorized"); |
| 45 | 49 | if (!actor.isAdmin) return err("not_found"); |
| 46 | 50 | if (typeof key !== "string" || !isCapacityKey(key)) return err("not_found"); |
| 47 | 51 | if (answer !== "keep" && answer !== "put") return err("invalid"); |
| 48 | 52 | const findings = openFindings(app, actor, key); |
| 49 | 53 | if (findings.length === 0) return err("not_found"); |
| 50 | if (answer === "put") { | |
| 51 | const put = changeCapacity(app, actor, key, null); | |
| 52 | if (!put.ok) return err(put.error === "not_found" ? "forbidden" : "invalid"); | |
| 53 | } | |
| 54 | // The close is the gate: a setting is put back only when a finding can be | |
| 55 | // closed, and a mirror's issues are not closed from here. | |
| 56 | const closable = findings.filter((id) => { | |
| 57 | const issue = getIssueById(app.db, id); | |
| 58 | const repo = issue && getRepoById(app.db, issue.repoId); | |
| 59 | return repo !== undefined && !isMirror(app, repo); | |
| 60 | }); | |
| 61 | if (closable.length === 0) return err("unclosable"); | |
| 54 | 62 | const stored = storedCapacity(app.db, key); |
| 55 | 63 | const row = app.db |
| 56 | 64 | .query<{ updated_at: string }, [string]>("SELECT updated_at FROM forge_setting WHERE key = ?1") |
| 57 | 65 | .get(key); |
| 58 | 66 | let closed = 0; |
| 59 | for (const id of findings) { | |
| 67 | for (const id of closable) { | |
| 60 | 68 | const issue = getIssueById(app.db, id); |
| 61 | 69 | const repo = issue && getRepoById(app.db, issue.repoId); |
| 62 | 70 | if (issue === undefined || repo === undefined) continue; |
| 63 | 71 | const done = setIssueState(app, actor, repo, issue, "closed", { reason: answer === "keep" ? "dropped" : "done" }); |
| 64 | 72 | if (done.ok) closed++; |
| 65 | 73 | } |
| 66 | // A kept value is recorded only when a close went through; otherwise the finding is still open. | |
| 67 | if (closed === 0) return err("forbidden"); | |
| 74 | // The close is the gate: a setting is put back, or a kept value recorded, | |
| 75 | // only when a finding closed; otherwise nothing has changed. | |
| 76 | if (closed === 0) return err("unclosable"); | |
| 77 | if (answer === "put") { | |
| 78 | const put = changeCapacity(app, actor, key, null); | |
| 79 | if (!put.ok) return err(put.error === "not_found" ? "forbidden" : "invalid"); | |
| 80 | } | |
| 68 | 81 | if (answer === "keep" && stored !== undefined && row !== null) |
| 69 | 82 | recordKept(app.db, `setting:${key}`, stored, row.updated_at, now.toISOString()); |
| 70 | 83 | return ok({ key, answer }); |
src/tui/screens/dashboard.ts +2 −2
| @@ -30,7 +30,7 @@ import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts"; | ||
| 30 | 30 | import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts"; |
| 31 | 31 | import { putAsideWaves } from "../../web/studio/asks.ts"; |
| 32 | 32 | import type { Proposal } from "../../core/proposals.ts"; |
| 33 | import { answerSetting, openFindings } from "../../actions/setting_ask.ts"; | |
| 33 | import { answerSetting, openFindings, UNCLOSABLE_WORDS } from "../../actions/setting_ask.ts"; | |
| 34 | 34 | import { |
| 35 | 35 | applyAsksBulk, |
| 36 | 36 | latestUndoable, |
| @@ -457,7 +457,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 457 | 457 | const done = answerSetting(f.app, f.user, here.setting, answer); |
| 458 | 458 | reload(); |
| 459 | 459 | ctx.toast( |
| 460 | done.ok ? (answer === "keep" ? "kept" : "put back") : `refused: ${done.error}`, | |
| 460 | done.ok ? (answer === "keep" ? "kept" : "put back") : `refused: ${done.error === "unclosable" ? UNCLOSABLE_WORDS : done.error}`, | |
| 461 | 461 | done.ok ? "success" : "danger", |
| 462 | 462 | ); |
| 463 | 463 | }; |
src/web/studio/asks_page.ts +2 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { WaitingFor } from "../../core/waiting_for.ts"; |
| 11 | import { answerSetting } from "../../actions/setting_ask.ts"; | |
| 11 | import { answerSetting, UNCLOSABLE_WORDS } from "../../actions/setting_ask.ts"; | |
| 12 | 12 | import { |
| 13 | 13 | applyAsksBulk, |
| 14 | 14 | BULK_MAX, |
| @@ -714,6 +714,7 @@ export async function asksSetting(ctx: Ctx): Promise<Response> { | ||
| 714 | 714 | if (!done.ok) { |
| 715 | 715 | const why: Record<string, string> = { |
| 716 | 716 | forbidden: "only a forge admin puts a setting back", |
| 717 | unclosable: UNCLOSABLE_WORDS, | |
| 717 | 718 | invalid: "that answer was not Keep or Put back", |
| 718 | 719 | }; |
| 719 | 720 | return await asksScreen(ctx, nothingDone(why[done.error] ?? "it no longer asks you")); |
test/asks.test.ts +48
| @@ -22,6 +22,8 @@ import type { Ctx } from "../src/http/context.ts"; | ||
| 22 | 22 | import { serve, type Servers } from "../src/http/server.ts"; |
| 23 | 23 | import { setCapacity, storedCapacity, capacityChanges, capacityDefault } from "../src/core/capacity.ts"; |
| 24 | 24 | import { listEvents } from "../src/core/events.ts"; |
| 25 | import { createOrigin } from "../src/core/origins.ts"; | |
| 26 | import { answerSetting, UNCLOSABLE_WORDS } from "../src/actions/setting_ask.ts"; | |
| 25 | 27 | import { hunt } from "../src/crew/sentinel.ts"; |
| 26 | 28 | import { ASK_WORDS, decisionsFor } from "../src/web/studio/asks.ts"; |
| 27 | 29 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -348,6 +350,52 @@ describe("the asks page", () => { | ||
| 348 | 350 | reset(); |
| 349 | 351 | }); |
| 350 | 352 | |
| 353 | test("Put back changes nothing when no finding can be closed, and says why", async () => { | |
| 354 | reset(); | |
| 355 | away("cells.egress", "report"); | |
| 356 | hunt(app, "alice/garden"); | |
| 357 | const alice0 = getUserByName(app.db, "alice")!; | |
| 358 | const made = createOrigin(app.db, dataDir, { | |
| 359 | userId: alice0.id, | |
| 360 | kind: "github", | |
| 361 | apiUrl: "https://api.example.test", | |
| 362 | webUrl: "https://example.test", | |
| 363 | login: "alice", | |
| 364 | token: "t", | |
| 365 | }); | |
| 366 | if (!made.ok) throw new Error(String(made.error)); | |
| 367 | app.db | |
| 368 | .query("UPDATE repo SET origin_id = ?1, origin_full_name = 'alice/garden' WHERE id = ?2") | |
| 369 | .run(made.value.id, repo.id); | |
| 370 | for (const answer of ["put", "keep"]) { | |
| 371 | const done = answerSetting(app, alice0, "cells.egress", answer); | |
| 372 | expect(done).toEqual({ ok: false, error: "unclosable" }); | |
| 373 | } | |
| 374 | expect(storedCapacity(app.db, "cells.egress")).toBe("report"); | |
| 375 | expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 0 }); | |
| 376 | const page = await (await post(alice, { key: "cells.egress", answer: "put" })).text(); | |
| 377 | expect(page).toContain(UNCLOSABLE_WORDS); | |
| 378 | expect(page).not.toContain("only a forge admin"); | |
| 379 | expect(storedCapacity(app.db, "cells.egress")).toBe("report"); | |
| 380 | app.db.query("UPDATE repo SET origin_id = NULL, origin_full_name = NULL WHERE id = ?1").run(repo.id); | |
| 381 | reset(); | |
| 382 | }); | |
| 383 | ||
| 384 | test("Put back changes nothing when the close is refused for a reason other than a mirror", () => { | |
| 385 | reset(); | |
| 386 | away("cells.egress", "report"); | |
| 387 | hunt(app, "alice/garden"); | |
| 388 | // a pull cannot be closed with an issue's reason, so setIssueState refuses | |
| 389 | app.db.query("UPDATE issue SET kind = 'pull' WHERE title LIKE 'Sentinel: Setting cells.egress %'").run(); | |
| 390 | const alice0 = getUserByName(app.db, "alice")!; | |
| 391 | const changes = capacityChanges(app.db).length; | |
| 392 | expect(answerSetting(app, alice0, "cells.egress", "put")).toEqual({ ok: false, error: "unclosable" }); | |
| 393 | expect(storedCapacity(app.db, "cells.egress")).toBe("report"); | |
| 394 | expect(capacityChanges(app.db).length).toBe(changes); | |
| 395 | app.db.query("UPDATE issue SET kind = 'issue' WHERE title LIKE 'Sentinel: Setting cells.egress %'").run(); | |
| 396 | reset(); | |
| 397 | }); | |
| 398 | ||
| 351 | 399 | test("a writer of the finding's repo who is not an admin cannot Keep or Put back", async () => { |
| 352 | 400 | reset(); |
| 353 | 401 | const carol = await signUp("carol"); |
test/tui-onyou.test.ts +31
| @@ -17,6 +17,8 @@ import { chipsOf, setChip } from "../src/core/feed_chips.ts"; | ||
| 17 | 17 | import { getIssue } from "../src/core/issues.ts"; |
| 18 | 18 | import { mutedRepos, recordInteraction } from "../src/core/interactions.ts"; |
| 19 | 19 | import { setCapacity, storedCapacity } from "../src/core/capacity.ts"; |
| 20 | import { UNCLOSABLE_WORDS } from "../src/actions/setting_ask.ts"; | |
| 21 | import { createOrigin } from "../src/core/origins.ts"; | |
| 20 | 22 | import { hunt } from "../src/crew/sentinel.ts"; |
| 21 | 23 | import { markVerified } from "../src/core/verify.ts"; |
| 22 | 24 | import type { Repo } from "../src/core/repos.ts"; |
| @@ -250,6 +252,35 @@ describe("terminal On you: mutes and many in one move", () => { | ||
| 250 | 252 | h.stop(); |
| 251 | 253 | }); |
| 252 | 254 | |
| 255 | test("a setting whose finding is on a mirror is refused in the same words as the page", async () => { | |
| 256 | setCapacity(forge.db, "cells.egress", "report", alice.id); | |
| 257 | forge.db | |
| 258 | .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'") | |
| 259 | .run(new Date(Date.now() - 9 * 86_400_000).toISOString()); | |
| 260 | forge.db.query("DELETE FROM sentinel_kept").run(); | |
| 261 | forge.db.query("DELETE FROM sentinel_finding").run(); | |
| 262 | hunt(forge, "alice/lamp", Date.now() + 6 * 3_600_000); | |
| 263 | const made = createOrigin(forge.db, dir, { | |
| 264 | userId: alice.id, | |
| 265 | kind: "github", | |
| 266 | apiUrl: "https://api.example.test", | |
| 267 | webUrl: "https://example.test", | |
| 268 | login: "alice", | |
| 269 | token: "t", | |
| 270 | }); | |
| 271 | if (!made.ok) throw new Error(String(made.error)); | |
| 272 | forge.db | |
| 273 | .query("UPDATE repo SET origin_id = ?1, origin_full_name = 'alice/lamp' WHERE id = ?2") | |
| 274 | .run(made.value.id, lamp.id); | |
| 275 | const h = await boot(); | |
| 276 | for (let i = 0; i < 20; i++) await h.press("j"); | |
| 277 | await h.press("B"); | |
| 278 | expect(storedCapacity(forge.db, "cells.egress")).toBe("report"); | |
| 279 | expect(h.text()).toContain(UNCLOSABLE_WORDS); | |
| 280 | h.stop(); | |
| 281 | forge.db.query("UPDATE repo SET origin_id = NULL, origin_full_name = NULL WHERE id = ?1").run(lamp.id); | |
| 282 | }); | |
| 283 | ||
| 253 | 284 | test("B with no wave put aside and no setting under the cursor does nothing", async () => { |
| 254 | 285 | hunt(forge, "alice/lamp"); |
| 255 | 286 | const h = await boot(); |
new above
offline
This mirror pre-filter duplicates what the
closed === 0check below already guarantees. Either drop it or say in the comment why it must stay.