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

Remove the legacy setting-finding path (sentinel_kept, recordKept, the asks setting card) once no open setting finding is left #3487

Merged opened this

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

Merged

mason/forge:mason/3314-remove-the-legacy-setting-finding-path-s → main 2 commits fork

  • Merged. mason/3314-remove-the-legacy-setting-finding-path-s was merged into main as 913b972.

Review

Sign in to review this change.
10 files changed +46 −485
README.md +4 −8
@@ -1293,14 +1293,10 @@ JSON API and MCP carry the same fact: a setting away from its default has a
12931293
12941294 A `setting:<key>` finding filed before that is closed on the next pass with a
12951295 comment pointing at the capacity page (`closeSettingFindings` in
1296 `crew/setting_finding.ts`). Until it closes, it is the site admin's ask, never a
1297 crew run: not assigned, left out of the crew's ready list, and shown on the home
1298 (kind `setting`) and in the terminal's On you card with **Keep** and **Put
1299 back** (`POST /asks/setting`, `key`, `answer=keep|put`, same-origin; `K` and `B`
1300 in the terminal). Only a signed-in site admin can answer. Keep and Put back act
1301 only after the finding has closed; if none can (a mirrored repository, or the
1302 close is refused) nothing changes and the page says so (`unclosable`). This is
1303 the legacy path, kept until no open setting finding is left.
1296 `crew/setting_finding.ts`). Until it closes it is never a crew run: not
1297 assigned, and left out of the crew's ready list (`isSettingFinding`). It raises
1298 no ask; the capacity page's Put back is the one answer. Both helpers go once no
1299 open setting finding is left, with the unused `sentinel_kept` table.
13041300
13051301 - **Board:** a site admin's forge strip shows the open findings and the last
13061302 hunt; a guest or member sees nothing.
src/actions/setting_ask.ts deleted −84
@@ -1,84 +0,0 @@
1 /**
2 * The owner's answer to a setting the sentinel found away from its default
3 * (#2029): Keep it, or Put it back. Both close the finding as a person, so
4 * the close is a person's on the record. Putting back goes through
5 * `changeCapacity`, the capacity page's own door: the same validation, the
6 * same record and event, the same reversal.
7 */
8
9 import type { App } from "../app.ts";
10 import { isCapacityKey, storedCapacity } from "../core/capacity.ts";
11 import { getIssueById } from "../core/issues.ts";
12 import { canWrite, getRepoById } from "../core/repos.ts";
13 import type { User } from "../core/users.ts";
14 import { recordKept } from "../crew/sentinel.ts";
15 import { err, ok, type Result } from "../kernel/result.ts";
16 import { isMirror } from "../origins/write-through.ts";
17 import { changeCapacity } from "./capacity.ts";
18 import { setIssueState } from "./issues.ts";
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
23 export type SettingAnswer = "keep" | "put";
24
25 /** The open findings for `setting:<key>` in repositories this person writes. */
26 export function openFindings(app: App, actor: User, key: string): string[] {
27 return app.db
28 .query<{ issue_id: string }, [string]>(
29 `SELECT t.issue_id FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
30 WHERE t.key = ?1 AND i.state = 'open' ORDER BY i.created_at`,
31 )
32 .all(`setting:${key}`)
33 .map((r) => r.issue_id)
34 .filter((id) => {
35 const issue = getIssueById(app.db, id);
36 const repo = issue && getRepoById(app.db, issue.repoId);
37 return issue !== undefined && repo !== undefined && canWrite(app.db, repo, actor);
38 });
39 }
40
41 export function answerSetting(
42 app: App,
43 actor: User | undefined,
44 key: unknown,
45 answer: unknown,
46 now = new Date(),
47 ): Result<{ key: string; answer: SettingAnswer }, "unauthorized" | "not_found" | "forbidden" | "invalid" | "unclosable"> {
48 if (!actor || actor.kind === "agent") return err("unauthorized");
49 if (!actor.isAdmin) return err("not_found");
50 if (typeof key !== "string" || !isCapacityKey(key)) return err("not_found");
51 if (answer !== "keep" && answer !== "put") return err("invalid");
52 const findings = openFindings(app, actor, key);
53 if (findings.length === 0) return err("not_found");
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");
62 const stored = storedCapacity(app.db, key);
63 const row = app.db
64 .query<{ updated_at: string }, [string]>("SELECT updated_at FROM forge_setting WHERE key = ?1")
65 .get(key);
66 let closed = 0;
67 for (const id of closable) {
68 const issue = getIssueById(app.db, id);
69 const repo = issue && getRepoById(app.db, issue.repoId);
70 if (issue === undefined || repo === undefined) continue;
71 const done = setIssueState(app, actor, repo, issue, "closed", { reason: answer === "keep" ? "dropped" : "done" });
72 if (done.ok) closed++;
73 }
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 }
81 if (answer === "keep" && stored !== undefined && row !== null)
82 recordKept(app.db, `setting:${key}`, stored, row.updated_at, now.toISOString());
83 return ok({ key, answer });
84 }
src/core/waiting_for.ts +1 −1
@@ -18,7 +18,7 @@ export interface WaitingFor {
1818
1919 export interface WaitFacts {
2020 /** The ask's kind (`DecisionKind`). */
21 readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal" | "setting";
21 readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal";
2222 /** Files stopping the pull from merging, as the last sweep found it. */
2323 readonly conflicts: number;
2424 /** Whether the pull or issue the ask is about is assigned to the viewer. */
src/crew/sentinel.ts −7
@@ -543,13 +543,6 @@ function labelBreach(app: App, repo: Repo, actor: User, issue: Issue): void {
543543 }
544544 }
545545
546 /** Remember that a person kept `key` as it stands: this value, set at `updatedAt`. */
547 export function recordKept(db: Database, key: string, value: string, updatedAt: string, at: string): void {
548 db.query(
549 "INSERT INTO sentinel_kept (key, value, updated_at, kept_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(key) DO UPDATE SET value = ?2, updated_at = ?3, kept_at = ?4",
550 ).run(key, value, updatedAt, at);
551 }
552
553546 /**
554547 * A degraded incident whose issue was closed as done and which goes on (same
555548 * cause, same first-open time) is told to that issue, not filed again: the
src/http/server.ts −2
@@ -258,7 +258,6 @@ import {
258258 asksBulkUndo,
259259 asksDecide,
260260 asksProposal,
261 asksSetting,
262261 asksDecideUndo,
263262 asksListen,
264263 asksMute,
@@ -724,7 +723,6 @@ export function buildRouter(): Router<Handler> {
724723 router.add("POST", "/asks/proposal/:id/later", asksProposal("later"));
725724 router.add("POST", "/asks/proposal/:id/again", asksProposal("again"));
726725 router.add("POST", "/asks/decide", asksDecide);
727 router.add("POST", "/asks/setting", asksSetting);
728726 router.add("POST", "/asks/decide/:id/undo", asksDecideUndo);
729727 // What people asked of the crew, and what became of it (core/asked.ts).
730728 router.add("GET", "/asked", askedPage);
src/tui/screens/dashboard.ts +4 −37
@@ -34,7 +34,6 @@ import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts";
3434 import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts";
3535 import { putAsideWaves } from "../../web/studio/asks.ts";
3636 import type { Proposal } from "../../core/proposals.ts";
37 import { answerSetting, openFindings, UNCLOSABLE_WORDS } from "../../actions/setting_ask.ts";
3837 import {
3938 applyAsksBulk,
4039 latestUndoable,
@@ -125,8 +124,6 @@ interface OnYouItem {
125124 readonly hand?: true;
126125 /** A pull the crew handed to a person: closed, marked and opened like any row. */
127126 readonly over?: true;
128 /** A setting away from its default: the key the owner keeps or puts back (admins only). */
129 readonly setting?: string;
130127 /** What it waits for, and on whom: the word the web's asks row shows. */
131128 readonly waiting: WaitingFor;
132129 }
@@ -319,14 +316,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
319316 ];
320317 const out: OnYouItem[] = [];
321318 const seen = new Set<string>();
322 if (f.user.isAdmin)
323 for (const id of rows.filter((row) => row.kind === "setting").flatMap((row) => row.items)) {
324 const key = id.slice("setting:".length);
325 const issue = getIssueById(f.db, openFindings(f.app, f.user, key)[0] ?? "");
326 const repo = issue === undefined ? undefined : getRepoById(f.db, issue.repoId);
327 if (issue !== undefined && repo !== undefined)
328 out.push({ issue, repo, waiting: waitingOf(issue.id), setting: key });
329 }
330319 for (const { id, flag } of wanted) {
331320 if (seen.has(id) || have.some((item) => item.issue.id === id)) continue;
332321 seen.add(id);
@@ -394,7 +383,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
394383
395384 /** The marked rows, or the one under the cursor when none is marked. */
396385 const targets = (): OnYouItem[] => {
397 const mine = onYou.filter((item) => item.hand !== true && item.setting === undefined);
386 const mine = onYou.filter((item) => item.hand !== true);
398387 const picked = mine.filter((item) => marked.has(item.issue.id));
399388 const here = mine.find((item) => item === onYou[at("onyou").index]);
400389 return picked.length > 0 ? picked : here === undefined ? [] : [here];
@@ -560,21 +549,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
560549 });
561550 };
562551
563 /** Keep or Put back the setting under the cursor, through the action the web uses. */
564 const answerHere = (answer: "keep" | "put"): void => {
565 const here = onYou[at("onyou").index];
566 if (here?.setting === undefined) {
567 ctx.toast("only a setting card is kept or put back", "attn");
568 return;
569 }
570 const done = answerSetting(f.app, f.user, here.setting, answer);
571 reload();
572 ctx.toast(
573 done.ok ? (answer === "keep" ? "kept" : "put back") : `refused: ${done.error === "unclosable" ? UNCLOSABLE_WORDS : done.error}`,
574 done.ok ? "success" : "danger",
575 );
576 };
577
578552 const undoHand = (): void => {
579553 const id = lastHand;
580554 if (id === undefined) {
@@ -737,12 +711,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
737711 ...(focus === "onyou" && waveHere() !== undefined
738712 ? [binding(["y"], "approve wave"), binding(["R"], "redirect"), binding(["l"], "later")]
739713 : []),
740 ...(asideWaves.length > 0 && !(focus === "onyou" && onYou[at("onyou").index]?.setting !== undefined)
741 ? [binding(["B"], "bring waves back")]
742 : []),
743 ...(focus === "onyou" && onYou[at("onyou").index]?.setting !== undefined
744 ? [binding(["K"], "keep setting"), binding(["B"], "put back")]
745 : []),
714 ...(asideWaves.length > 0 ? [binding(["B"], "bring waves back")] : []),
746715 ...(lastHand !== undefined ? [binding(["U"], "undo decider")] : []),
747716 ...(focus === "onyou" && onYou.length > 0
748717 ? [
@@ -898,7 +867,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
898867 }
899868 if (focus === "onyou" && f.user !== undefined) {
900869 const item = onYou[at("onyou").index];
901 if (e.key === "space" && item !== undefined && item.hand !== true && item.setting === undefined) {
870 if (e.key === "space" && item !== undefined && item.hand !== true) {
902871 if (!marked.delete(item.issue.id)) marked.add(item.issue.id);
903872 return true;
904873 }
@@ -906,8 +875,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
906875 if (e.key === "m") return clear("mute"), true;
907876 if (e.key === "S") return shipSet(), true;
908877 if (e.key === "D" && item !== undefined) return handToDecider(), true;
909 if (e.key === "K") return answerHere("keep"), true;
910 if (e.key === "B" && item?.setting !== undefined) return answerHere("put"), true;
911878 if (e.key === "h") return hideAuthor(), true;
912879 if (e.key === "y") return actOnWave("approve"), true;
913880 if (e.key === "R") return redirectWave(), true;
@@ -1004,7 +971,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
1004971 ? PULL_MARK
1005972 : ISSUE_MARK;
1006973 return {
1007 title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.setting !== undefined ? `setting ${item.setting} · K keep · B put back · ` : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : ""}${mark} #${item.issue.number} ${item.issue.title} · ${item.repo.ownerName}/${item.repo.name}`,
974 title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : ""}${mark} #${item.issue.number} ${item.issue.title} · ${item.repo.ownerName}/${item.repo.name}`,
1008975 meta: `${item.waiting.word} · on ${item.waiting.on}`,
1009976 };
1010977 }),
src/web/studio/asks.ts +2 −108
@@ -13,7 +13,6 @@ import type { Degraded } from "../../kernel/vitals.ts";
1313 import { previewEnv } from "../../apps/data.ts";
1414 import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
1515 import { crewForRepos } from "../../core/agents.ts";
16 import { capacityDefault, isCapacityKey, storedCapacity } from "../../core/capacity.ts";
1716 import { appsForRepos } from "../../core/apps.ts";
1817 import { latestDecision, latestDecisionsFor, stopStands } from "../../core/decisions.ts";
1918 import { getHold, holdsFor } from "../../core/holds.ts";
@@ -107,7 +106,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) {
107106 );
108107 }
109108
110 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal" | "setting";
109 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal";
111110
112111 /** Dollars as a person reads them: cents when small, whole when not. */
113112 const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0));
@@ -136,21 +135,10 @@ export interface Decision {
136135 /** Where the pull's preview opens, when its repository is an app. */
137136 readonly preview?: string | undefined;
138137 readonly consequence: Consequence;
139 /** For "setting": the key, its value, its default, and who set it and when. */
140 readonly setting?: SettingAway | undefined;
141138 /** What it waits for, and on whom (`core/waiting_for.ts`). */
142139 readonly waiting: WaitingFor;
143140 }
144141
145 /** A capacity setting away from its default, as the sentinel's finding reports it. */
146 export interface SettingAway {
147 readonly key: string;
148 readonly value: string;
149 readonly default: string;
150 readonly by: string;
151 readonly at: string;
152 }
153
154142 /** What an ask asks, the answers it takes, and what happens if nobody gives one. */
155143 export interface AskWords {
156144 readonly decision: string;
@@ -200,11 +188,6 @@ export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = {
200188 options: ["Approve", "Redirect", "Later"],
201189 ifNothing: "it stays proposed; no issue is opened",
202190 },
203 setting: {
204 decision: "A setting is away from its default: keep it, or put it back?",
205 options: ["Keep", "Put back"],
206 ifNothing: "the setting stays as it is, and this stays here",
207 },
208191 paused: {
209192 decision: "Raise today's ceiling?",
210193 options: ["Raise it in settings"],
@@ -234,7 +217,6 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = {
234217 parked: { score: 40, why: "parked by the crew" },
235218 look: { score: 30, why: "green, waiting" },
236219 assigned: { score: 20, why: "assigned to you" },
237 setting: { score: 35, why: "a setting away from its default needs your word" },
238220 };
239221
240222 /**
@@ -297,8 +279,6 @@ interface AskCandidate {
297279 readonly decline?: { readonly runId: string; readonly words: string };
298280 /** For "handover": the reviews that asked for changes. */
299281 readonly handover?: Handover;
300 /** For "setting": what is away, and who moved it. */
301 readonly setting?: SettingAway;
302282 }
303283
304284 /**
@@ -545,10 +525,6 @@ function askCandidatesFor(
545525 proposal,
546526 who: getUserById(db, proposal.authorId)?.username ?? "the planner",
547527 });
548 // A setting the sentinel found away from its default is the owner's to keep
549 // or put back, never a crew run: one ask per key, from its newest open finding.
550 if (user.isAdmin)
551 for (const f of settingFindings(db, repos)) out.push(f);
552528 // Open work assigned to the viewer that no ask above already covers: a
553529 // pull that is an ask and assigned to them is one row, the ask's.
554530 const taken = new Set(out.flatMap((a) => [a.pull?.id, a.issue?.id].filter((i) => i !== undefined)));
@@ -668,7 +644,7 @@ function consequencesOf(
668644 /** The row an ask collapses into: one kind in one repository. */
669645 const groupId = (a: { kind: DecisionKind; repo: Repo; id: string }): string =>
670646 // Work assigned to you is each its own row: they share no decision.
671 a.kind === "assigned" || a.kind === "proposal" || a.kind === "setting" ? a.id : `${a.kind}:${a.repo.id}`;
647 a.kind === "assigned" || a.kind === "proposal" ? a.id : `${a.kind}:${a.repo.id}`;
672648
673649 /**
674650 * The asks the viewer has not set aside: a muted repository's asks are all
@@ -766,52 +742,6 @@ function waitingOf(db: Database, user: User | undefined, a: AskCandidate): Waiti
766742 });
767743 }
768744
769 /** One candidate per capacity key still away whose finding is open in these repositories. */
770 function settingFindings(db: Database, repos: readonly Repo[]): AskCandidate[] {
771 if (repos.length === 0) return [];
772 const byId = new Map(repos.map((r) => [r.id, r]));
773 const seen = new Set<string>();
774 const out: AskCandidate[] = [];
775 const rows = db
776 .query<{ key: string; issue_id: string }, [string]>(
777 `SELECT t.key, t.issue_id FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
778 WHERE t.key LIKE 'setting:%' AND i.state = 'open' AND i.repo_id IN (SELECT value FROM json_each(?1))
779 ORDER BY i.created_at DESC`,
780 )
781 .all(JSON.stringify([...byId.keys()]));
782 for (const r of rows) {
783 const key = r.key.slice("setting:".length);
784 if (seen.has(key) || !isCapacityKey(key)) continue;
785 const value = storedCapacity(db, key);
786 if (value === undefined || value === capacityDefault(key)) continue;
787 const issue = getIssueById(db, r.issue_id);
788 const repo = issue && byId.get(issue.repoId);
789 if (issue === undefined || repo === undefined) continue;
790 seen.add(key);
791 const set = db
792 .query<{ updated_at: string; username: string | null }, [string]>(
793 "SELECT s.updated_at, u.username FROM forge_setting s LEFT JOIN user u ON u.id = s.updated_by WHERE s.key = ?1",
794 )
795 .get(key);
796 out.push({
797 kind: "setting",
798 id: `setting:${key}`,
799 since: issue.createdAt,
800 repo,
801 issue,
802 who: "the sentinel",
803 setting: {
804 key,
805 value,
806 default: capacityDefault(key),
807 by: set?.username ?? "unknown",
808 at: set?.updated_at ?? "",
809 },
810 });
811 }
812 return out;
813 }
814
815745 /** Every candidate for the viewer, scored, dismissed or not. */
816746 function scoredFor(
817747 db: Database,
@@ -895,14 +825,6 @@ export function endOf(
895825 if (pull !== undefined && pull.state !== "open") return "acted";
896826 return getHold(db, what)?.heldBy === viewerId ? "acted" : "expired";
897827 }
898 if (kind === "setting") {
899 const open = db
900 .query<{ n: number }, [string]>(
901 "SELECT COUNT(*) AS n FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id WHERE t.key = ?1 AND i.state = 'open'",
902 )
903 .get(`setting:${what}`);
904 return open !== null && open.n > 0 ? "expired" : "acted";
905 }
906828 if (kind === "assigned") {
907829 const issue = getIssueById(db, what);
908830 return issue !== undefined && issue.state !== "open" ? "acted" : "expired";
@@ -982,8 +904,6 @@ function lineOf(c: AskCandidate): string {
982904 return `proposes "${c.proposal?.title ?? ""}" and waits for your nod`;
983905 if (c.kind === "declined")
984906 return "the planner declined the direction and waits for you";
985 if (c.kind === "setting" && c.setting)
986 return `${c.setting.key} is "${c.setting.value}", not its default "${c.setting.default}"`;
987907 if (c.kind === "handover")
988908 return `"${c.pull?.title ?? ""}" had three reviews asking for changes and waits for you`;
989909 const title = c.pull?.title ?? "";
@@ -1014,7 +934,6 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> =
1014934 declined: (n) => `the planner declined ${n} times and waits for you`,
1015935 proposal: (n) => `${n} waves wait for your nod`,
1016936 paused: (n) => `${n} pauses at the ceiling`,
1017 setting: (n) => `${n} settings are away from their defaults`,
1018937 assigned: (n) => `${n} things assigned to you`,
1019938 };
1020939
@@ -1175,17 +1094,6 @@ export function decisionsFor(
11751094 says: `Three runs on #${c.issue.number} failed or were declined within an hour, so the crew parked it. Say what to try and it starts again.`,
11761095 short: `#${c.issue.number} parked after three failed runs`,
11771096 };
1178 if (c.kind === "setting" && c.issue && c.setting)
1179 return {
1180 kind: "setting",
1181 id: `setting:${c.setting.key}`,
1182 repo: c.repo,
1183 issue: c.issue,
1184 who: c.who,
1185 setting: c.setting,
1186 says: `${c.setting.key} is "${c.setting.value}" and its default is "${c.setting.default}". ${c.setting.by} set it at ${c.setting.at}. Keep it, or put it back (recorded, and reversible on the capacity page).`,
1187 short: `${c.setting.key} is "${c.setting.value}", default "${c.setting.default}", set by ${c.setting.by}`,
1188 };
11891097 if (c.kind === "declined" && c.issue && c.decline)
11901098 return {
11911099 kind: "declined",
@@ -1290,15 +1198,6 @@ export function decisionForm(d: Decision, back?: string): Html | null {
12901198 <input type="hidden" name="ask" value="${d.id}" />
12911199 </form>`;
12921200 if (d.kind === "proposal" && d.proposal) return proposalForms(d, back);
1293 if (d.kind === "setting" && d.setting)
1294 return html`<form ${own}-keep action="/asks/setting">
1295 <input type="hidden" name="key" value="${d.setting.key}" />
1296 <input type="hidden" name="answer" value="keep" />
1297 </form>
1298 <form ${own}-put action="/asks/setting">
1299 <input type="hidden" name="key" value="${d.setting.key}" />
1300 <input type="hidden" name="answer" value="put" />
1301 </form>`;
13021201 if (pull === undefined) return null;
13031202 const base = repoBase(d.repo);
13041203 const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`;
@@ -1366,11 +1265,6 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others:
13661265 primary: btn("Approve", { size: "sm", form: askFormId(d), variant: "primary" }),
13671266 others: [btn("Later", { size: "sm", form: `${askFormId(d)}-later` })],
13681267 };
1369 if (d.kind === "setting" && d.setting)
1370 return {
1371 primary: btn("Keep", { size: "sm", form: `${askFormId(d)}-keep`, variant: "primary" }),
1372 others: [btn("Put back", { size: "sm", form: `${askFormId(d)}-put` })],
1373 };
13741268 if (d.kind === "declined" && d.issue)
13751269 return {
13761270 primary: btn("Answer", {
src/web/studio/asks_page.ts −19
@@ -8,7 +8,6 @@
88 */
99
1010 import type { WaitingFor } from "../../core/waiting_for.ts";
11 import { answerSetting, UNCLOSABLE_WORDS } from "../../actions/setting_ask.ts";
1211 import {
1312 applyAsksBulk,
1413 BULK_MAX,
@@ -177,7 +176,6 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = {
177176 proposal: "Proposed",
178177 paused: "Paused",
179178 assigned: "Assigned",
180 setting: "Setting away",
181179 };
182180
183181 /**
@@ -712,23 +710,6 @@ export function asksProposal(verb: ProposalVerb) {
712710 };
713711 }
714712
715 /** POST /asks/setting — the owner's Keep or Put back on a setting away from its default. */
716 export async function asksSetting(ctx: Ctx): Promise<Response> {
717 if (!ctx.auth.user) return signInFirst(ctx);
718 const form = await readForm(ctx, "rw");
719 if (form instanceof Response) return form;
720 const done = answerSetting(ctx.app, ctx.auth.user, field(form, "key"), field(form, "answer"));
721 if (!done.ok) {
722 const why: Record<string, string> = {
723 forbidden: "only a forge admin puts a setting back",
724 unclosable: UNCLOSABLE_WORDS,
725 invalid: "that answer was not Keep or Put back",
726 };
727 return await asksScreen(ctx, nothingDone(why[done.error] ?? "it no longer asks you"));
728 }
729 return redirect("/asks");
730 }
731
732713 /** POST /asks/decide/:id/undo — put a set-aside card back on the board, within a day. */
733714 export async function asksDecideUndo(ctx: Ctx): Promise<Response> {
734715 if (!ctx.auth.user) return signInFirst(ctx);
test/asks.test.ts +28 −170
@@ -15,18 +15,16 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1515 import { TESTS_PASSED_TAIL } from "../src/apps/runtime.ts";
1616 import { recordDecision, resolveOpen } from "../src/core/decisions.ts";
1717 import { createIssue } from "../src/core/issues.ts";
18 import { getRepo, type Repo } from "../src/core/repos.ts";
18 import type { Repo } from "../src/core/repos.ts";
1919 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
2020 import { getUserByName } from "../src/core/users.ts";
2121 import { markVerified } from "../src/core/verify.ts";
2222 import type { Ctx } from "../src/http/context.ts";
2323 import { serve, type Servers } from "../src/http/server.ts";
24 import { setCapacity, storedCapacity, capacityChanges, capacityDefault } from "../src/core/capacity.ts";
25 import { listEvents } from "../src/core/events.ts";
24 import { setCapacity, storedCapacity } from "../src/core/capacity.ts";
2625 import { createOrigin } from "../src/core/origins.ts";
27 import { answerSetting, UNCLOSABLE_WORDS } from "../src/actions/setting_ask.ts";
2826 import { hunt } from "../src/crew/sentinel.ts";
29 import { ASK_WORDS, decisionsFor } from "../src/web/studio/asks.ts";
27 import { decisionsFor } from "../src/web/studio/asks.ts";
3028 import { quietLogs } from "./helpers/globals.ts";
3129 import { promote } from "./helpers/members.ts";
3230 import { adminCookie } from "./helpers/admin.ts";
@@ -277,176 +275,36 @@ describe("the asks page", () => {
277275 expect(card?.says).toContain("its tests passed");
278276 expect(card?.short).toContain("its tests passed");
279277 });
280 describe("a setting away from its default (#2029)", () => {
281 const away = (key: "checks.concurrency" | "cells.egress", value: string): void => {
282 setCapacity(app.db, key, value, getUserByName(app.db, "alice")!.id);
278 describe("a legacy setting finding (#3314)", () => {
279 // The sentinel files no setting now (#3269) and closes the ones it filed
280 // before; until it does, an open one asks nobody and is answered nowhere.
281 test("raises no ask, and /asks/setting is gone", async () => {
282 const alice0 = getUserByName(app.db, "alice")!;
283 setCapacity(app.db, "checks.concurrency", "4", alice0.id);
284 const made = createIssue(app.db, {
285 repoId: repo.id,
286 authorId: alice0.id,
287 title: "Sentinel: Setting checks.concurrency has been away from its default for 10 days",
288 body: "checks.concurrency is away",
289 });
290 if (!made.ok) throw new Error(made.error);
283291 app.db
284 .query("UPDATE forge_setting SET updated_at = ?2 WHERE key = ?1")
285 .run(key, new Date(Date.now() - 10 * 86_400_000).toISOString());
286 };
287 // The sentinel no longer files a setting (#3269); an issue it filed
288 // before still waits for an answer, so these tests plant one.
289 const fileLegacy = (path: string): void => {
290 const [owner = "", name = ""] = path.split("/");
291 const target = getRepo(app.db, owner, name)!;
292 const author = getUserByName(app.db, "alice")!;
293 const rows = app.db.query<{ key: string }, []>("SELECT key FROM forge_setting").all();
294 for (const { key } of rows) {
295 const made = createIssue(app.db, {
296 repoId: target.id,
297 authorId: author.id,
298 title: `Sentinel: Setting ${key} has been away from its default for 10 days`,
299 body: `${key} is away`,
300 });
301 if (!made.ok) throw new Error(made.error);
302 app.db
303 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES (?1, ?2, '', 'a', 'a')")
304 .run(`setting:${key}`, made.value.id);
305 }
306 };
307 const post = (cookie: string, fields: Record<string, string>, origin = servers.url) =>
308 fetch(`${servers.url}/asks/setting`, {
292 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES ('setting:checks.concurrency', ?1, '', 'a', 'a')")
293 .run(made.value.id);
294 const ids = decisionsFor({ app, auth: { user: alice0 } } as unknown as Ctx, [repo]).map((d) => d.id);
295 expect(ids.some((id) => id.startsWith("setting:"))).toBe(false);
296 expect(await get(alice, "/asks")).not.toContain("Put back");
297 const res = await fetch(`${servers.url}/asks/setting`, {
309298 method: "POST",
310 headers: { cookie, origin },
311 body: new URLSearchParams(fields),
299 headers: { cookie: alice, origin: servers.url },
300 body: new URLSearchParams({ key: "checks.concurrency", answer: "put" }),
312301 redirect: "manual",
313302 });
314 const settingCards = (): string[] =>
315 decisionsFor({ app, auth: { user: getUserByName(app.db, "alice")! } } as unknown as Ctx, [repo])
316 .filter((d) => d.kind === "setting")
317 .map((d) => d.id);
318 const reset = (): void => {
319 app.db.query("DELETE FROM forge_setting").run();
320 app.db.query("DELETE FROM sentinel_kept").run();
321 app.db.query("DELETE FROM sentinel_finding").run();
322 app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: %'").run();
323 };
324
325 test("one ask per key, with the key, value, default, who and when; the crew is not dispatched", async () => {
326 reset();
327 away("checks.concurrency", "4");
328 away("cells.egress", "report");
329 fileLegacy("alice/garden");
330 fileLegacy("alice/garden");
331 expect(settingCards().sort()).toEqual(["setting:cells.egress", "setting:checks.concurrency"]);
332 const card = decisionsFor({ app, auth: { user: getUserByName(app.db, "alice")! } } as unknown as Ctx, [repo]).find(
333 (d) => d.id === "setting:checks.concurrency",
334 )!;
335 expect(card.says).toContain('"4"');
336 expect(card.says).toContain("alice");
337 expect(card.says).toContain("checks.concurrency");
338 expect(ASK_WORDS.setting.options).toEqual(["Keep", "Put back"]);
339 const page = await get(alice, "/asks");
340 expect(page).toContain("Keep");
341 expect(page).toContain("Put back");
342 // not for a person who cannot put it back
343 expect(await get(bob, "/asks")).not.toContain("Put back");
344 expect(listEvents(app.db, { kind: "issue.assigned" }).length).toBe(0);
345 });
346
347 test("Keep closes the finding as kept", async () => {
348 const res = await post(alice, { key: "checks.concurrency", answer: "keep" });
349 expect(res.status).toBe(303);
350 expect(settingCards()).toEqual(["setting:cells.egress"]);
351 expect(app.db.query("SELECT value FROM sentinel_kept WHERE key = 'setting:checks.concurrency'").get()).toEqual({ value: "4" });
303 expect(res.status).toBe(404);
352304 expect(storedCapacity(app.db, "checks.concurrency")).toBe("4");
353 const closed = app.db
354 .query<{ close_reason: string }, []>("SELECT close_reason FROM issue WHERE title LIKE 'Sentinel: Setting checks.concurrency %'")
355 .get();
356 expect(closed?.close_reason).toBe("dropped");
357 });
358
359 test("Put back clears it on the capacity path, recorded, and closes the finding", async () => {
360 const res = await post(alice, { key: "cells.egress", answer: "put" });
361 expect(res.status).toBe(303);
362 expect(storedCapacity(app.db, "cells.egress")).toBeUndefined();
363 expect(capacityChanges(app.db).some((c) => c.key === "cells.egress" && c.to === capacityDefault("cells.egress"))).toBe(true);
364 expect(listEvents(app.db, { kind: "capacity.changed" }).some((e) => e.payload["key"] === "cells.egress")).toBe(true);
365 expect(settingCards()).toEqual([]);
366 });
367
368 test("a stranger, a bad answer and a foreign origin change nothing", async () => {
369 reset();
370 away("cells.egress", "report");
371 fileLegacy("alice/garden");
372 await post(bob, { key: "cells.egress", answer: "put" });
373 await post(alice, { key: "cells.egress", answer: "burn" });
374 await post(alice, { key: "nope", answer: "put" });
375 await post(alice, { key: "cells.egress", answer: "put" }, "https://evil.example");
376 expect(storedCapacity(app.db, "cells.egress")).toBe("report");
377 expect(settingCards()).toEqual(["setting:cells.egress"]);
378 reset();
379 });
380
381 test("Put back changes nothing when no finding can be closed, and says why", async () => {
382 reset();
383 away("cells.egress", "report");
384 fileLegacy("alice/garden");
385 const alice0 = getUserByName(app.db, "alice")!;
386 const made = createOrigin(app.db, dataDir, {
387 userId: alice0.id,
388 kind: "github",
389 apiUrl: "https://api.example.test",
390 webUrl: "https://example.test",
391 login: "alice",
392 token: "t",
393 });
394 if (!made.ok) throw new Error(String(made.error));
395 app.db
396 .query("UPDATE repo SET origin_id = ?1, origin_full_name = 'alice/garden' WHERE id = ?2")
397 .run(made.value.id, repo.id);
398 for (const answer of ["put", "keep"]) {
399 const done = answerSetting(app, alice0, "cells.egress", answer);
400 expect(done).toEqual({ ok: false, error: "unclosable" });
401 }
402 expect(storedCapacity(app.db, "cells.egress")).toBe("report");
403 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 0 });
404 const page = await (await post(alice, { key: "cells.egress", answer: "put" })).text();
405 expect(page).toContain(UNCLOSABLE_WORDS);
406 expect(page).not.toContain("only a forge admin");
407 expect(storedCapacity(app.db, "cells.egress")).toBe("report");
408 app.db.query("UPDATE repo SET origin_id = NULL, origin_full_name = NULL WHERE id = ?1").run(repo.id);
409 reset();
410 });
411
412 test("Put back changes nothing when the close is refused for a reason other than a mirror", () => {
413 reset();
414 away("cells.egress", "report");
415 fileLegacy("alice/garden");
416 // a pull cannot be closed with an issue's reason, so setIssueState refuses
417 app.db.query("UPDATE issue SET kind = 'pull' WHERE title LIKE 'Sentinel: Setting cells.egress %'").run();
418 const alice0 = getUserByName(app.db, "alice")!;
419 const changes = capacityChanges(app.db).length;
420 expect(answerSetting(app, alice0, "cells.egress", "put")).toEqual({ ok: false, error: "unclosable" });
421 expect(storedCapacity(app.db, "cells.egress")).toBe("report");
422 expect(capacityChanges(app.db).length).toBe(changes);
423 app.db.query("UPDATE issue SET kind = 'issue' WHERE title LIKE 'Sentinel: Setting cells.egress %'").run();
424 reset();
425 });
426
427 test("a writer of the finding's repo who is not an admin cannot Keep or Put back", async () => {
428 reset();
429 const carol = await signUp("carol");
430 const carolUser = getUserByName(app.db, "carol")!;
431 markVerified(app.db, carolUser.id, new Date().toISOString());
432 promote(app.db, carolUser);
433 const plot = await createRepo(app, carolUser, { name: "plot", visibility: "public" });
434 if (!plot.ok) throw new Error(String(plot.error));
435 away("cells.egress", "report");
436 fileLegacy("carol/plot");
437 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_finding WHERE key = 'setting:cells.egress'").get()).toEqual({ n: 1 });
438 await post(carol, { key: "cells.egress", answer: "keep" });
439 await post(carol, { key: "cells.egress", answer: "put" });
440 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 0 });
441 expect(storedCapacity(app.db, "cells.egress")).toBe("report");
442 const open = app.db
443 .query("SELECT COUNT(*) AS n FROM issue WHERE title LIKE 'Sentinel: Setting cells.egress %' AND state = 'open'")
444 .get();
445 expect(open).toEqual({ n: 1 });
446 // the admin still can
447 await post(alice, { key: "cells.egress", answer: "keep" });
448 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 1 });
449 reset();
305 app.db.query("DELETE FROM forge_setting").run();
306 app.db.query("DELETE FROM sentinel_finding").run();
307 app.db.query("DELETE FROM issue WHERE id = ?1").run(made.value.id);
450308 });
451309 });
452310 });
test/tui-onyou.test.ts +7 −49
@@ -17,8 +17,6 @@ import { chipsOf, setChip } from "../src/core/feed_chips.ts";
1717 import { createIssue, 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";
2220 import { hunt } from "../src/crew/sentinel.ts";
2321 import { markVerified } from "../src/core/verify.ts";
2422 import type { Repo } from "../src/core/repos.ts";
@@ -231,7 +229,7 @@ describe("terminal On you: mutes and many in one move", () => {
231229 h.stop();
232230 });
233231
234 // The sentinel files no setting now (#3269); one it filed before still waits for an answer.
232 // The sentinel files no setting now (#3269); one it filed before may still be open.
235233 const legacySetting = (key: string): void => {
236234 const made = createIssue(forge.db, {
237235 repoId: lamp.id,
@@ -245,61 +243,21 @@ describe("terminal On you: mutes and many in one move", () => {
245243 .run(`setting:${key}`, made.value.id);
246244 };
247245
248 test("a setting away from its default is Kept or Put back from the terminal", async () => {
246 test("a legacy setting finding has no card, and K and B leave the setting as it is", async () => {
249247 setCapacity(forge.db, "cells.egress", "report", alice.id);
250 forge.db
251 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'")
252 .run(new Date(Date.now() - 10 * 86_400_000).toISOString());
253248 legacySetting("cells.egress");
254249 const h = await boot();
255 for (let i = 0; i < 20; i++) await h.press("j"); // the setting row is last, and the cursor stops there
256 expect(h.text()).toContain("setting cells.egress");
257 await h.press("B");
258 expect(storedCapacity(forge.db, "cells.egress")).toBeUndefined();
259 expect(h.text()).not.toContain("setting cells.egress");
260 setCapacity(forge.db, "cells.egress", "report", alice.id);
261 forge.db
262 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'")
263 .run(new Date(Date.now() - 10 * 86_400_000).toISOString());
264 legacySetting("cells.egress");
265 await h.press("r");
266 for (let i = 0; i < 20; i++) await h.press("j"); // the setting row is last, and the cursor stops there
267 await h.press("K");
268 expect(storedCapacity(forge.db, "cells.egress")).toBe("report");
269 expect(h.text()).not.toContain("setting cells.egress");
270 h.stop();
271 });
272
273 test("a setting whose finding is on a mirror is refused in the same words as the page", async () => {
274 setCapacity(forge.db, "cells.egress", "report", alice.id);
275 forge.db
276 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'")
277 .run(new Date(Date.now() - 9 * 86_400_000).toISOString());
278 forge.db.query("DELETE FROM sentinel_kept").run();
279 forge.db.query("DELETE FROM sentinel_finding").run();
280 legacySetting("cells.egress");
281 const made = createOrigin(forge.db, dir, {
282 userId: alice.id,
283 kind: "github",
284 apiUrl: "https://api.example.test",
285 webUrl: "https://example.test",
286 login: "alice",
287 token: "t",
288 });
289 if (!made.ok) throw new Error(String(made.error));
290 forge.db
291 .query("UPDATE repo SET origin_id = ?1, origin_full_name = 'alice/lamp' WHERE id = ?2")
292 .run(made.value.id, lamp.id);
293 const h = await boot();
294250 for (let i = 0; i < 20; i++) await h.press("j");
251 expect(h.text()).not.toContain("setting cells.egress");
295252 await h.press("B");
253 await h.press("K");
296254 expect(storedCapacity(forge.db, "cells.egress")).toBe("report");
297 expect(h.text()).toContain(UNCLOSABLE_WORDS);
298255 h.stop();
299 forge.db.query("UPDATE repo SET origin_id = NULL, origin_full_name = NULL WHERE id = ?1").run(lamp.id);
256 forge.db.query("DELETE FROM forge_setting").run();
257 forge.db.query("DELETE FROM sentinel_finding").run();
300258 });
301259
302 test("B with no wave put aside and no setting under the cursor does nothing", async () => {
260 test("B with no wave put aside does nothing", async () => {
303261 hunt(forge, "alice/lamp");
304262 const h = await boot();
305263 await h.press("B");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs