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

Put back on a setting ask changes nothing unless its finding closes #2073

Merged opened this

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

Merged

scout/forge:scout/2072-put-back-on-a-setting-ask-changes-nothin → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/2072-put-back-on-a-setting-ask-changes-nothin was merged into main as 44e2741.

Review

Sign in to review this change.
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
682682 agent, gets `not_found`/`unauthorized` and nothing changes. Keep closes the
683683 finding and records the value in `sentinel_kept`; Put back goes through the
684684 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.
686689
687690 - **Board:** a site admin's forge strip shows the open findings and the last
688691 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";
1313 import type { User } from "../core/users.ts";
1414 import { recordKept } from "../crew/sentinel.ts";
1515 import { err, ok, type Result } from "../kernel/result.ts";
16 import { isMirror } from "../origins/write-through.ts";
1617 import { changeCapacity } from "./capacity.ts";
1718 import { setIssueState } from "./issues.ts";
1819
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
1923 export type SettingAnswer = "keep" | "put";
2024
2125 /** The open findings for `setting:<key>` in repositories this person writes. */
@@ -40,31 +44,40 @@ export function answerSetting(
4044 key: unknown,
4145 answer: unknown,
4246 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"> {
4448 if (!actor || actor.kind === "agent") return err("unauthorized");
4549 if (!actor.isAdmin) return err("not_found");
4650 if (typeof key !== "string" || !isCapacityKey(key)) return err("not_found");
4751 if (answer !== "keep" && answer !== "put") return err("invalid");
4852 const findings = openFindings(app, actor, key);
4953 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);

This mirror pre-filter duplicates what the closed === 0 check below already guarantees. Either drop it or say in the comment why it must stay.

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");
5462 const stored = storedCapacity(app.db, key);
5563 const row = app.db
5664 .query<{ updated_at: string }, [string]>("SELECT updated_at FROM forge_setting WHERE key = ?1")
5765 .get(key);
5866 let closed = 0;
59 for (const id of findings) {
67 for (const id of closable) {
6068 const issue = getIssueById(app.db, id);
6169 const repo = issue && getRepoById(app.db, issue.repoId);
6270 if (issue === undefined || repo === undefined) continue;
6371 const done = setIssueState(app, actor, repo, issue, "closed", { reason: answer === "keep" ? "dropped" : "done" });
6472 if (done.ok) closed++;
6573 }
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 }
6881 if (answer === "keep" && stored !== undefined && row !== null)
6982 recordKept(app.db, `setting:${key}`, stored, row.updated_at, now.toISOString());
7083 return ok({ key, answer });
src/tui/screens/dashboard.ts +2 −2
@@ -30,7 +30,7 @@ import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts";
3030 import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts";
3131 import { putAsideWaves } from "../../web/studio/asks.ts";
3232 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";
3434 import {
3535 applyAsksBulk,
3636 latestUndoable,
@@ -457,7 +457,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
457457 const done = answerSetting(f.app, f.user, here.setting, answer);
458458 reload();
459459 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}`,
461461 done.ok ? "success" : "danger",
462462 );
463463 };
src/web/studio/asks_page.ts +2 −1
@@ -8,7 +8,7 @@
88 */
99
1010 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";
1212 import {
1313 applyAsksBulk,
1414 BULK_MAX,
@@ -714,6 +714,7 @@ export async function asksSetting(ctx: Ctx): Promise<Response> {
714714 if (!done.ok) {
715715 const why: Record<string, string> = {
716716 forbidden: "only a forge admin puts a setting back",
717 unclosable: UNCLOSABLE_WORDS,
717718 invalid: "that answer was not Keep or Put back",
718719 };
719720 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";
2222 import { serve, type Servers } from "../src/http/server.ts";
2323 import { setCapacity, storedCapacity, capacityChanges, capacityDefault } from "../src/core/capacity.ts";
2424 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";
2527 import { hunt } from "../src/crew/sentinel.ts";
2628 import { ASK_WORDS, decisionsFor } from "../src/web/studio/asks.ts";
2729 import { quietLogs } from "./helpers/globals.ts";
@@ -348,6 +350,52 @@ describe("the asks page", () => {
348350 reset();
349351 });
350352
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
351399 test("a writer of the finding's repo who is not an admin cannot Keep or Put back", async () => {
352400 reset();
353401 const carol = await signUp("carol");
test/tui-onyou.test.ts +31
@@ -17,6 +17,8 @@ import { chipsOf, setChip } from "../src/core/feed_chips.ts";
1717 import { getIssue } from "../src/core/issues.ts";
1818 import { mutedRepos, recordInteraction } from "../src/core/interactions.ts";
1919 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";
2022 import { hunt } from "../src/crew/sentinel.ts";
2123 import { markVerified } from "../src/core/verify.ts";
2224 import type { Repo } from "../src/core/repos.ts";
@@ -250,6 +252,35 @@ describe("terminal On you: mutes and many in one move", () => {
250252 h.stop();
251253 });
252254
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
253284 test("B with no wave put aside and no setting under the cursor does nothing", async () => {
254285 hunt(forge, "alice/lamp");
255286 const h = await boot();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs