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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3314-remove-the-legacy-setting-finding-path-s → main 2 commits fork
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 | ||
| 1293 | 1293 | |
| 1294 | 1294 | A `setting:<key>` finding filed before that is closed on the next pass with a |
| 1295 | 1295 | 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. | |
| 1304 | 1300 | |
| 1305 | 1301 | - **Board:** a site admin's forge strip shows the open findings and the last |
| 1306 | 1302 | 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 { | ||
| 18 | 18 | |
| 19 | 19 | export interface WaitFacts { |
| 20 | 20 | /** 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"; | |
| 22 | 22 | /** Files stopping the pull from merging, as the last sweep found it. */ |
| 23 | 23 | readonly conflicts: number; |
| 24 | 24 | /** 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 { | ||
| 543 | 543 | } |
| 544 | 544 | } |
| 545 | 545 | |
| 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 | ||
| 553 | 546 | /** |
| 554 | 547 | * A degraded incident whose issue was closed as done and which goes on (same |
| 555 | 548 | * cause, same first-open time) is told to that issue, not filed again: the |
src/http/server.ts −2
| @@ -258,7 +258,6 @@ import { | ||
| 258 | 258 | asksBulkUndo, |
| 259 | 259 | asksDecide, |
| 260 | 260 | asksProposal, |
| 261 | asksSetting, | |
| 262 | 261 | asksDecideUndo, |
| 263 | 262 | asksListen, |
| 264 | 263 | asksMute, |
| @@ -724,7 +723,6 @@ export function buildRouter(): Router<Handler> { | ||
| 724 | 723 | router.add("POST", "/asks/proposal/:id/later", asksProposal("later")); |
| 725 | 724 | router.add("POST", "/asks/proposal/:id/again", asksProposal("again")); |
| 726 | 725 | router.add("POST", "/asks/decide", asksDecide); |
| 727 | router.add("POST", "/asks/setting", asksSetting); | |
| 728 | 726 | router.add("POST", "/asks/decide/:id/undo", asksDecideUndo); |
| 729 | 727 | // What people asked of the crew, and what became of it (core/asked.ts). |
| 730 | 728 | router.add("GET", "/asked", askedPage); |
src/tui/screens/dashboard.ts +4 −37
| @@ -34,7 +34,6 @@ import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts"; | ||
| 34 | 34 | import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts"; |
| 35 | 35 | import { putAsideWaves } from "../../web/studio/asks.ts"; |
| 36 | 36 | import type { Proposal } from "../../core/proposals.ts"; |
| 37 | import { answerSetting, openFindings, UNCLOSABLE_WORDS } from "../../actions/setting_ask.ts"; | |
| 38 | 37 | import { |
| 39 | 38 | applyAsksBulk, |
| 40 | 39 | latestUndoable, |
| @@ -125,8 +124,6 @@ interface OnYouItem { | ||
| 125 | 124 | readonly hand?: true; |
| 126 | 125 | /** A pull the crew handed to a person: closed, marked and opened like any row. */ |
| 127 | 126 | readonly over?: true; |
| 128 | /** A setting away from its default: the key the owner keeps or puts back (admins only). */ | |
| 129 | readonly setting?: string; | |
| 130 | 127 | /** What it waits for, and on whom: the word the web's asks row shows. */ |
| 131 | 128 | readonly waiting: WaitingFor; |
| 132 | 129 | } |
| @@ -319,14 +316,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 319 | 316 | ]; |
| 320 | 317 | const out: OnYouItem[] = []; |
| 321 | 318 | 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 | } | |
| 330 | 319 | for (const { id, flag } of wanted) { |
| 331 | 320 | if (seen.has(id) || have.some((item) => item.issue.id === id)) continue; |
| 332 | 321 | seen.add(id); |
| @@ -394,7 +383,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 394 | 383 | |
| 395 | 384 | /** The marked rows, or the one under the cursor when none is marked. */ |
| 396 | 385 | const targets = (): OnYouItem[] => { |
| 397 | const mine = onYou.filter((item) => item.hand !== true && item.setting === undefined); | |
| 386 | const mine = onYou.filter((item) => item.hand !== true); | |
| 398 | 387 | const picked = mine.filter((item) => marked.has(item.issue.id)); |
| 399 | 388 | const here = mine.find((item) => item === onYou[at("onyou").index]); |
| 400 | 389 | return picked.length > 0 ? picked : here === undefined ? [] : [here]; |
| @@ -560,21 +549,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 560 | 549 | }); |
| 561 | 550 | }; |
| 562 | 551 | |
| 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 | ||
| 578 | 552 | const undoHand = (): void => { |
| 579 | 553 | const id = lastHand; |
| 580 | 554 | if (id === undefined) { |
| @@ -737,12 +711,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 737 | 711 | ...(focus === "onyou" && waveHere() !== undefined |
| 738 | 712 | ? [binding(["y"], "approve wave"), binding(["R"], "redirect"), binding(["l"], "later")] |
| 739 | 713 | : []), |
| 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")] : []), | |
| 746 | 715 | ...(lastHand !== undefined ? [binding(["U"], "undo decider")] : []), |
| 747 | 716 | ...(focus === "onyou" && onYou.length > 0 |
| 748 | 717 | ? [ |
| @@ -898,7 +867,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 898 | 867 | } |
| 899 | 868 | if (focus === "onyou" && f.user !== undefined) { |
| 900 | 869 | 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) { | |
| 902 | 871 | if (!marked.delete(item.issue.id)) marked.add(item.issue.id); |
| 903 | 872 | return true; |
| 904 | 873 | } |
| @@ -906,8 +875,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 906 | 875 | if (e.key === "m") return clear("mute"), true; |
| 907 | 876 | if (e.key === "S") return shipSet(), true; |
| 908 | 877 | 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; | |
| 911 | 878 | if (e.key === "h") return hideAuthor(), true; |
| 912 | 879 | if (e.key === "y") return actOnWave("approve"), true; |
| 913 | 880 | if (e.key === "R") return redirectWave(), true; |
| @@ -1004,7 +971,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1004 | 971 | ? PULL_MARK |
| 1005 | 972 | : ISSUE_MARK; |
| 1006 | 973 | 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}`, | |
| 1008 | 975 | meta: `${item.waiting.word} · on ${item.waiting.on}`, |
| 1009 | 976 | }; |
| 1010 | 977 | }), |
src/web/studio/asks.ts +2 −108
| @@ -13,7 +13,6 @@ import type { Degraded } from "../../kernel/vitals.ts"; | ||
| 13 | 13 | import { previewEnv } from "../../apps/data.ts"; |
| 14 | 14 | import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; |
| 15 | 15 | import { crewForRepos } from "../../core/agents.ts"; |
| 16 | import { capacityDefault, isCapacityKey, storedCapacity } from "../../core/capacity.ts"; | |
| 17 | 16 | import { appsForRepos } from "../../core/apps.ts"; |
| 18 | 17 | import { latestDecision, latestDecisionsFor, stopStands } from "../../core/decisions.ts"; |
| 19 | 18 | import { getHold, holdsFor } from "../../core/holds.ts"; |
| @@ -107,7 +106,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) { | ||
| 107 | 106 | ); |
| 108 | 107 | } |
| 109 | 108 | |
| 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"; | |
| 111 | 110 | |
| 112 | 111 | /** Dollars as a person reads them: cents when small, whole when not. */ |
| 113 | 112 | const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0)); |
| @@ -136,21 +135,10 @@ export interface Decision { | ||
| 136 | 135 | /** Where the pull's preview opens, when its repository is an app. */ |
| 137 | 136 | readonly preview?: string | undefined; |
| 138 | 137 | readonly consequence: Consequence; |
| 139 | /** For "setting": the key, its value, its default, and who set it and when. */ | |
| 140 | readonly setting?: SettingAway | undefined; | |
| 141 | 138 | /** What it waits for, and on whom (`core/waiting_for.ts`). */ |
| 142 | 139 | readonly waiting: WaitingFor; |
| 143 | 140 | } |
| 144 | 141 | |
| 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 | ||
| 154 | 142 | /** What an ask asks, the answers it takes, and what happens if nobody gives one. */ |
| 155 | 143 | export interface AskWords { |
| 156 | 144 | readonly decision: string; |
| @@ -200,11 +188,6 @@ export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = { | ||
| 200 | 188 | options: ["Approve", "Redirect", "Later"], |
| 201 | 189 | ifNothing: "it stays proposed; no issue is opened", |
| 202 | 190 | }, |
| 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 | }, | |
| 208 | 191 | paused: { |
| 209 | 192 | decision: "Raise today's ceiling?", |
| 210 | 193 | options: ["Raise it in settings"], |
| @@ -234,7 +217,6 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = { | ||
| 234 | 217 | parked: { score: 40, why: "parked by the crew" }, |
| 235 | 218 | look: { score: 30, why: "green, waiting" }, |
| 236 | 219 | assigned: { score: 20, why: "assigned to you" }, |
| 237 | setting: { score: 35, why: "a setting away from its default needs your word" }, | |
| 238 | 220 | }; |
| 239 | 221 | |
| 240 | 222 | /** |
| @@ -297,8 +279,6 @@ interface AskCandidate { | ||
| 297 | 279 | readonly decline?: { readonly runId: string; readonly words: string }; |
| 298 | 280 | /** For "handover": the reviews that asked for changes. */ |
| 299 | 281 | readonly handover?: Handover; |
| 300 | /** For "setting": what is away, and who moved it. */ | |
| 301 | readonly setting?: SettingAway; | |
| 302 | 282 | } |
| 303 | 283 | |
| 304 | 284 | /** |
| @@ -545,10 +525,6 @@ function askCandidatesFor( | ||
| 545 | 525 | proposal, |
| 546 | 526 | who: getUserById(db, proposal.authorId)?.username ?? "the planner", |
| 547 | 527 | }); |
| 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); | |
| 552 | 528 | // Open work assigned to the viewer that no ask above already covers: a |
| 553 | 529 | // pull that is an ask and assigned to them is one row, the ask's. |
| 554 | 530 | const taken = new Set(out.flatMap((a) => [a.pull?.id, a.issue?.id].filter((i) => i !== undefined))); |
| @@ -668,7 +644,7 @@ function consequencesOf( | ||
| 668 | 644 | /** The row an ask collapses into: one kind in one repository. */ |
| 669 | 645 | const groupId = (a: { kind: DecisionKind; repo: Repo; id: string }): string => |
| 670 | 646 | // 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}`; | |
| 672 | 648 | |
| 673 | 649 | /** |
| 674 | 650 | * 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 | ||
| 766 | 742 | }); |
| 767 | 743 | } |
| 768 | 744 | |
| 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 | ||
| 815 | 745 | /** Every candidate for the viewer, scored, dismissed or not. */ |
| 816 | 746 | function scoredFor( |
| 817 | 747 | db: Database, |
| @@ -895,14 +825,6 @@ export function endOf( | ||
| 895 | 825 | if (pull !== undefined && pull.state !== "open") return "acted"; |
| 896 | 826 | return getHold(db, what)?.heldBy === viewerId ? "acted" : "expired"; |
| 897 | 827 | } |
| 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 | } | |
| 906 | 828 | if (kind === "assigned") { |
| 907 | 829 | const issue = getIssueById(db, what); |
| 908 | 830 | return issue !== undefined && issue.state !== "open" ? "acted" : "expired"; |
| @@ -982,8 +904,6 @@ function lineOf(c: AskCandidate): string { | ||
| 982 | 904 | return `proposes "${c.proposal?.title ?? ""}" and waits for your nod`; |
| 983 | 905 | if (c.kind === "declined") |
| 984 | 906 | 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}"`; | |
| 987 | 907 | if (c.kind === "handover") |
| 988 | 908 | return `"${c.pull?.title ?? ""}" had three reviews asking for changes and waits for you`; |
| 989 | 909 | const title = c.pull?.title ?? ""; |
| @@ -1014,7 +934,6 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> = | ||
| 1014 | 934 | declined: (n) => `the planner declined ${n} times and waits for you`, |
| 1015 | 935 | proposal: (n) => `${n} waves wait for your nod`, |
| 1016 | 936 | paused: (n) => `${n} pauses at the ceiling`, |
| 1017 | setting: (n) => `${n} settings are away from their defaults`, | |
| 1018 | 937 | assigned: (n) => `${n} things assigned to you`, |
| 1019 | 938 | }; |
| 1020 | 939 | |
| @@ -1175,17 +1094,6 @@ export function decisionsFor( | ||
| 1175 | 1094 | 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.`, |
| 1176 | 1095 | short: `#${c.issue.number} parked after three failed runs`, |
| 1177 | 1096 | }; |
| 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 | }; | |
| 1189 | 1097 | if (c.kind === "declined" && c.issue && c.decline) |
| 1190 | 1098 | return { |
| 1191 | 1099 | kind: "declined", |
| @@ -1290,15 +1198,6 @@ export function decisionForm(d: Decision, back?: string): Html | null { | ||
| 1290 | 1198 | <input type="hidden" name="ask" value="${d.id}" /> |
| 1291 | 1199 | </form>`; |
| 1292 | 1200 | 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>`; | |
| 1302 | 1201 | if (pull === undefined) return null; |
| 1303 | 1202 | const base = repoBase(d.repo); |
| 1304 | 1203 | 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: | ||
| 1366 | 1265 | primary: btn("Approve", { size: "sm", form: askFormId(d), variant: "primary" }), |
| 1367 | 1266 | others: [btn("Later", { size: "sm", form: `${askFormId(d)}-later` })], |
| 1368 | 1267 | }; |
| 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 | }; | |
| 1374 | 1268 | if (d.kind === "declined" && d.issue) |
| 1375 | 1269 | return { |
| 1376 | 1270 | primary: btn("Answer", { |
src/web/studio/asks_page.ts −19
| @@ -8,7 +8,6 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { WaitingFor } from "../../core/waiting_for.ts"; |
| 11 | import { answerSetting, UNCLOSABLE_WORDS } from "../../actions/setting_ask.ts"; | |
| 12 | 11 | import { |
| 13 | 12 | applyAsksBulk, |
| 14 | 13 | BULK_MAX, |
| @@ -177,7 +176,6 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = { | ||
| 177 | 176 | proposal: "Proposed", |
| 178 | 177 | paused: "Paused", |
| 179 | 178 | assigned: "Assigned", |
| 180 | setting: "Setting away", | |
| 181 | 179 | }; |
| 182 | 180 | |
| 183 | 181 | /** |
| @@ -712,23 +710,6 @@ export function asksProposal(verb: ProposalVerb) { | ||
| 712 | 710 | }; |
| 713 | 711 | } |
| 714 | 712 | |
| 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 | ||
| 732 | 713 | /** POST /asks/decide/:id/undo — put a set-aside card back on the board, within a day. */ |
| 733 | 714 | export async function asksDecideUndo(ctx: Ctx): Promise<Response> { |
| 734 | 715 | 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"; | ||
| 15 | 15 | import { TESTS_PASSED_TAIL } from "../src/apps/runtime.ts"; |
| 16 | 16 | import { recordDecision, resolveOpen } from "../src/core/decisions.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 20 | 20 | import { getUserByName } from "../src/core/users.ts"; |
| 21 | 21 | import { markVerified } from "../src/core/verify.ts"; |
| 22 | 22 | import type { Ctx } from "../src/http/context.ts"; |
| 23 | 23 | 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"; | |
| 26 | 25 | import { createOrigin } from "../src/core/origins.ts"; |
| 27 | import { answerSetting, UNCLOSABLE_WORDS } from "../src/actions/setting_ask.ts"; | |
| 28 | 26 | 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"; | |
| 30 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 31 | 29 | import { promote } from "./helpers/members.ts"; |
| 32 | 30 | import { adminCookie } from "./helpers/admin.ts"; |
| @@ -277,176 +275,36 @@ describe("the asks page", () => { | ||
| 277 | 275 | expect(card?.says).toContain("its tests passed"); |
| 278 | 276 | expect(card?.short).toContain("its tests passed"); |
| 279 | 277 | }); |
| 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); | |
| 283 | 291 | 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`, { | |
| 309 | 298 | 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" }), | |
| 312 | 301 | redirect: "manual", |
| 313 | 302 | }); |
| 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); | |
| 352 | 304 | 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); | |
| 450 | 308 | }); |
| 451 | 309 | }); |
| 452 | 310 | }); |
test/tui-onyou.test.ts +7 −49
| @@ -17,8 +17,6 @@ import { chipsOf, setChip } from "../src/core/feed_chips.ts"; | ||
| 17 | 17 | import { createIssue, 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"; | |
| 22 | 20 | import { hunt } from "../src/crew/sentinel.ts"; |
| 23 | 21 | import { markVerified } from "../src/core/verify.ts"; |
| 24 | 22 | import type { Repo } from "../src/core/repos.ts"; |
| @@ -231,7 +229,7 @@ describe("terminal On you: mutes and many in one move", () => { | ||
| 231 | 229 | h.stop(); |
| 232 | 230 | }); |
| 233 | 231 | |
| 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. | |
| 235 | 233 | const legacySetting = (key: string): void => { |
| 236 | 234 | const made = createIssue(forge.db, { |
| 237 | 235 | repoId: lamp.id, |
| @@ -245,61 +243,21 @@ describe("terminal On you: mutes and many in one move", () => { | ||
| 245 | 243 | .run(`setting:${key}`, made.value.id); |
| 246 | 244 | }; |
| 247 | 245 | |
| 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 () => { | |
| 249 | 247 | 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()); | |
| 253 | 248 | legacySetting("cells.egress"); |
| 254 | 249 | 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(); | |
| 294 | 250 | for (let i = 0; i < 20; i++) await h.press("j"); |
| 251 | expect(h.text()).not.toContain("setting cells.egress"); | |
| 295 | 252 | await h.press("B"); |
| 253 | await h.press("K"); | |
| 296 | 254 | expect(storedCapacity(forge.db, "cells.egress")).toBe("report"); |
| 297 | expect(h.text()).toContain(UNCLOSABLE_WORDS); | |
| 298 | 255 | 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(); | |
| 300 | 258 | }); |
| 301 | 259 | |
| 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 () => { | |
| 303 | 261 | hunt(forge, "alice/lamp"); |
| 304 | 262 | const h = await boot(); |
| 305 | 263 | await h.press("B"); |
new above
offline