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

The owner sets filters on /next once (by app, by kind, hide for a day) and they hold, with one tap to clear #11859

Merged opened this

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

Merged

mason/forge:mason/11820-the-owner-sets-filters-on-next-once-by-a → main 6 commits fork 3 threads · 3 unresolved

  • Merged. mason/11820-the-owner-sets-filters-on-next-once-by-a was merged into main as a299a68.

Review

Sign in to review this change.
17 files changed +618 −26
docs/MCP.md +1 −1
@@ -59,7 +59,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
5959 | `apps` | — | Apps you can see: live or not, where served, how each runs, what works | 343 |
6060 | `archive` | — | Archive or wake a repository (`archived`) | 380 |
6161 | `asked` | — | What people asked the crew for: unmerged by default, oldest first | 372 |
62 | `asks` | — | What waits on you, most important first; saved filter applies | 453 |
62 | `asks` | `stopped`, `look`, `review`, `paused`, `parked`, `declined`, `handover`, `waiting`, `assigned`, `proposal` | What waits on you, most important first; saved filter applies; `filter` sets or clears one | 707 |
6363 | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 535 |
6464 | `asks_decide` | — | Answer a hand-over: the decider (`parked:<id>`), or `give_back` to its builder (`handover:`/`assigned:<id>`) | 270 |
6565 | `asks_proposal` | — | Act on a wave proposal: `approve`, `redirect` (`words`), `later` (`again`) | 315 |
docs/features/ask-filters.md added +27
@@ -0,0 +1,27 @@
1 # Ask filters (#11820)
2
3 An owner sets what hides from what asks them, once, and it holds: on the page, in the MCP, in the terminal, on home and in the phone's notifier. Rows live in `ask_filter` (per person, in the forge's database; no cookie). Logic: `src/core/ask_filters.ts`; the one action all three surfaces call: `src/actions/ask_filter.ts`.
4
5 - A filter only decides whether an ask shows. The ask is never changed, declined or lost; it returns when a day's hide ends or on **Show all**.
6 - A trust alarm (a security ask, score 100) is never hidden by any filter.
7 - The dispatcher's order is untouched: hiding removes rows, it does not reorder the rest.
8 - A filtered list says `N hidden by your filters` once, with one tap to clear.
9 - Deleting an app deletes its filters (`forgetAppFilters`).
10
11 Where the inbox is: `/asks` (and home's "on you" region, the same section). `/:owner/:name/next` is a repository's planning page, not the inbox, and carries no filter.
12
13 ## Page
14
15 `/asks` has one **Filter** disclosure above the list: pick an app and tap **Hide app for a day**, or pick a kind and tap **Hide kind**. When something is hidden the line `N hidden by your filters` and **Show all** lead the list. `POST /asks/filter` takes `do`, with `repo` (id) or `kind`; same-origin, signed in.
16
17 ## MCP
18
19 `asks` with `filter`: `hide_app_day` (**Hide app for a day**), `hide_app` and `show_app` with `app`; `hide_kind` (**Hide kind**) and `show_kind` with `kind`; `clear` (**Show all**). The answer carries `filters: { hidden, line }` while any is set. See `docs/MCP.md`.
20
21 ## Terminal
22
23 On the On you list: `H` hides the app under the cursor for a day; `F` shows all. The footer says `N hidden by your filters · F show all`.
24
25 ## Words
26
27 One set, in `ask_filters.ts`: `hiddenLine`, `HIDE_APP_DAY_WORDS`, `HIDE_KIND_WORDS`, `SHOW_ALL_WORDS`; the terminal lowercases them. `test/ask-filters.test.ts` holds that the terminal and tool use them.
src/actions/ask_filter.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * An owner's ask filters as one action (#11820), for the page, MCP `asks` and
3 * the terminal alike, in the same words: hide an app for a day or until shown,
4 * hide a kind of decision, show one again, or clear them all. The rows live in
5 * `core/ask_filters.ts`; nothing here touches an ask.
6 */
7 import type { Database } from "bun:sqlite";
8 import { clearAskFilters, hideApp, hideKind, showAgain, type AskKind } from "../core/ask_filters.ts";
9 import type { Repo } from "../core/repos.ts";
10 import type { User } from "../core/users.ts";
11
12 export const ASK_FILTER_DOS = ["hide_app_day", "hide_app", "show_app", "hide_kind", "show_kind", "clear"] as const;
13 export type AskFilterDo = (typeof ASK_FILTER_DOS)[number];
14
15 export const isAskFilterDo = (v: unknown): v is AskFilterDo =>
16 typeof v === "string" && (ASK_FILTER_DOS as readonly string[]).includes(v);
17
18 export type AskFilterChange =
19 | { readonly do: "hide_app_day" | "hide_app" | "show_app"; readonly repo: Repo }
20 | { readonly do: "hide_kind" | "show_kind"; readonly kind: AskKind }
21 | { readonly do: "clear" };
22
23 /** Make the change for this person and say what it did, one line. */
24 export function applyAskFilter(db: Database, user: User, change: AskFilterChange, now = Date.now()): string {
25 switch (change.do) {
26 case "hide_app_day":
27 hideApp(db, user.id, change.repo.id, true, now);
28 return `Hiding ${change.repo.ownerName}/${change.repo.name} for a day`;
29 case "hide_app":
30 hideApp(db, user.id, change.repo.id, false, now);
31 return `Hiding ${change.repo.ownerName}/${change.repo.name} until you show it`;
32 case "show_app":
33 showAgain(db, user.id, { kind: "app", value: change.repo.id });
34 return `Showing ${change.repo.ownerName}/${change.repo.name} again`;
35 case "hide_kind":
36 hideKind(db, user.id, change.kind);
37 return `Hiding ${change.kind} asks until you show them`;
38 case "show_kind":
39 showAgain(db, user.id, { kind: "kind", value: change.kind });
40 return `Showing ${change.kind} asks again`;
41 case "clear":
42 clearAskFilters(db, user.id);
43 return "Showing everything again";
44 }
45 }
src/core/ask_filters.ts added +103
@@ -0,0 +1,103 @@
1 /**
2 * The filters an owner sets on what asks them (#11820): hide an app (for a
3 * day, or until shown again) and hide a kind of decision. Saved per person in
4 * the forge's database, read by the web, the MCP and the terminal through
5 * these functions alone. A filter only decides whether an ask shows: the ask
6 * is never changed, declined or lost, and it returns when the day ends or the
7 * owner taps "Show all". A trust alarm (security) is never hidden by one.
8 * Pure data: the route owes the validation of what it passes in.
9 */
10
11 import type { Database } from "bun:sqlite";
12
13 /** Every kind of decision that can ask a person. */
14 export const ASK_KINDS = [
15 "stopped", "look", "review", "paused", "parked", "declined", "handover", "waiting", "assigned", "proposal",
16 ] as const;
17 export type AskKind = (typeof ASK_KINDS)[number];
18
19 export const isAskKind = (v: unknown): v is AskKind =>
20 typeof v === "string" && (ASK_KINDS as readonly string[]).includes(v);
21
22 /** The score a security ask carries: the top of the order, and exempt from every filter. */
23 export const TRUST_ALARM_SCORE = 100;
24
25 /** Whether an ask is a trust alarm: no filter hides it. */
26 export const isTrustAlarm = (c: { readonly score: number }): boolean => c.score >= TRUST_ALARM_SCORE;
27
28 /** How long "hide for a day" hides. */
29 export const HIDE_DAY_MS = 24 * 60 * 60 * 1000;
30
31 /** What is hidden right now: app (repository) ids and decision kinds. */
32 export interface AskFilters {
33 readonly apps: ReadonlySet<string>;
34 readonly kinds: ReadonlySet<AskKind>;
35 }
36
37 export const noAskFilters: AskFilters = { apps: new Set(), kinds: new Set() };
38
39 /** The words every surface uses for the line and the one tap that clears it. */
40 export const hiddenLine = (n: number): string => `${n} hidden by your filters`;
41 export const SHOW_ALL_WORDS = "Show all";
42 export const HIDE_APP_DAY_WORDS = "Hide app for a day"

Non-blocking: missing semicolon after the HIDE_APP_DAY_WORDS string.

43 export const HIDE_KIND_WORDS = "Hide kind";
44
45 /** The filters in force at `now`: a "for a day" hide that has ended is not one. */
46 export function askFiltersOf(db: Database, viewerId: string, now = Date.now()): AskFilters {
47 const apps = new Set<string>();
48 const kinds = new Set<AskKind>();
49 for (const r of db
50 .query<{ kind: string; value: string; until: string | null }, [string]>(
51 "SELECT kind, value, until FROM ask_filter WHERE viewer_id = ?1",
52 )
53 .all(viewerId)) {
54 if (r.until !== null && Date.parse(r.until) <= now) continue;
55 if (r.kind === "app") apps.add(r.value);
56 else if (isAskKind(r.value)) kinds.add(r.value);
57 }
58 return { apps, kinds };
59 }
60
61 export const hasAskFilters = (f: AskFilters): boolean => f.apps.size > 0 || f.kinds.size > 0;
62
63 /** Hide one app: for a day from `now`, or until it is shown again. Twice renews it. */
64 export function hideApp(db: Database, viewerId: string, repoId: string, forADay: boolean, now = Date.now()): void {
65 db.query(
66 "INSERT OR REPLACE INTO ask_filter (viewer_id, kind, value, until) VALUES (?1, 'app', ?2, ?3)",
67 ).run(viewerId, repoId, forADay ? new Date(now + HIDE_DAY_MS).toISOString() : null);
68 }
69
70 /** Hide one kind of decision until it is shown again. */
71 export function hideKind(db: Database, viewerId: string, kind: AskKind): void {
72 db.query(
73 "INSERT OR REPLACE INTO ask_filter (viewer_id, kind, value, until) VALUES (?1, 'kind', ?2, NULL)",
74 ).run(viewerId, kind);
75 }
76
77 /** Show one app or kind again; not hidden is the same as shown. */
78 export function showAgain(db: Database, viewerId: string, what: { kind: "app" | "kind"; value: string }): void {
79 db.query("DELETE FROM ask_filter WHERE viewer_id = ?1 AND kind = ?2 AND value = ?3").run(
80 viewerId,
81 what.kind,
82 what.value,
83 );
84 }
85
86 /** A deleted app's filters go with it: the app value has no foreign key to cascade. */
87 export function forgetAppFilters(db: Database, repoId: string): void {
88 db.query("DELETE FROM ask_filter WHERE kind = 'app' AND value = ?1").run(repoId);
89 }
90
91 /** The one tap: every filter of this person off. */
92 export function clearAskFilters(db: Database, viewerId: string): void {
93 db.query("DELETE FROM ask_filter WHERE viewer_id = ?1").run(viewerId);
94 }
95
96 /** Whether the filters hide an ask of this kind in this app; a trust alarm is never hidden. */
97 export function filterHides(
98 f: AskFilters,
99 ask: { readonly kind: AskKind; readonly repoId: string; readonly consequence: { readonly score: number } },
100 ): boolean {
101 if (isTrustAlarm(ask.consequence)) return false;
102 return f.apps.has(ask.repoId) || f.kinds.has(ask.kind);
103 }
src/core/migration-pins/20261112140000-ask-filters.pin added +1
@@ -0,0 +1,1 @@
1 4659027878577a43f15d61665a289f774db2ffa7fa1e4e56b42034b10baf0764
src/core/migrations/20261112140000-ask-filters.ts added +12
@@ -0,0 +1,12 @@
1 // The filters an owner sets on their asks (#11820): an app hidden (for a day or
2 // until shown again) or a kind of decision hidden, saved per person. A hidden
3 // ask is never touched; the filter only decides whether it shows.
4 export default `
5 CREATE TABLE ask_filter (
6 viewer_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
7 kind TEXT NOT NULL CHECK (kind IN ('app','kind')),
8 value TEXT NOT NULL,
9 until TEXT,
10 PRIMARY KEY (viewer_id, kind, value)
11 ) WITHOUT ROWID;
12 `;
src/core/repos.ts +2
@@ -5,6 +5,7 @@
55 * repo. Deletion inverts the order — row first, directory second.
66 */
77
8 import { forgetAppFilters } from "./ask_filters.ts";
89 import { isPacksMirror } from "../packs/owner.ts";
910 import { contributionForksSince, isContributionFork } from "./contribute.ts";
1011 import type { Database } from "bun:sqlite";
@@ -1082,6 +1083,7 @@ export async function deleteRepo(
10821083 // The doctor's rows carry `repo_id` without a foreign key (added columns on `maintenance_run`).
10831084 db.query("DELETE FROM maintenance_run WHERE duty = 'batch-doctor' AND repo_id = ?1").run(repo.id);
10841085 db.query("DELETE FROM governor_last WHERE repo_id = ?1").run(repo.id);
1086 forgetAppFilters(db, repo.id);
10851087 db.query("DELETE FROM repo WHERE id = ?1").run(repo.id);
10861088 forgetRepoCursors(db, repo.id);
10871089 forgetListings(db);
src/http/mcp/tools/asks.ts +32 −5
@@ -1,17 +1,44 @@
1 import { applyAskFilter, ASK_FILTER_DOS, isAskFilterDo, type AskFilterChange } from "../../../actions/ask_filter.ts";
2 import { ASK_KINDS, HIDE_APP_DAY_WORDS, HIDE_KIND_WORDS, isAskKind, SHOW_ALL_WORDS } from "../../../core/ask_filters.ts";
13 import { asksAnswer } from "../../../web/studio/asks.ts";
2 import { good, bad, type Tool, CHIP_ARGS, chipArgs } from "../shared.ts";
4 import { good, bad, type Tool, CHIP_ARGS, chipArgs, APP_ARG, repoArg, isAnswer } from "../shared.ts";
35
46 export const tool: Tool = {
57 name: "asks",
68 description:
7 "What waits on you, most important first; saved filter applies",
8 inputSchema: { type: "object", properties: { ...CHIP_ARGS } },
9 "What waits on you, most important first; saved filter applies; `filter` sets or clears one",
10 inputSchema: {
11 type: "object",
12 properties: {
13 ...CHIP_ARGS,
14 filter: { type: "string", enum: [...ASK_FILTER_DOS] },
15 app: APP_ARG,
16 kind: { type: "string", enum: [...ASK_KINDS] },
17 },
18 },
919 doc:
10 "`{ hide_kinds?, hide_people? }`: what waits on you, most important first; your saved filter applies unless a chip argument overrides it",
11 writes: false,
20 `\`{ hide_kinds?, hide_people?, filter?, app?, kind? }\`: what waits on you, most important first; your saved filter applies unless a chip argument overrides it. \`filter\` (\`hide_app_day\` is "${HIDE_APP_DAY_WORDS}", \`hide_app\`, \`show_app\` with \`app\`; \`hide_kind\` is "${HIDE_KIND_WORDS}", \`show_kind\` with \`kind\`; \`clear\` is "${SHOW_ALL_WORDS}") sets your ask filters, the same as the page and terminal: nothing hidden is lost, a hide for a day ends by itself, a security ask always shows; the answer says how many are hidden`,
21 writes: true,
22 readsWithout: "filter",
1223 run(ctx, user, args) {
1324 const chips = chipArgs(ctx, args);
1425 if (chips && "error" in chips) return bad("invalid", chips.error);
26 const db = ctx.app.db;
27 if (args["filter"] !== undefined) {
28 const what = args["filter"];
29 if (!isAskFilterDo(what)) return bad("invalid", `filter is ${ASK_FILTER_DOS.join(", ")}`);
30 let change: AskFilterChange;
31 if (what === "clear") change = { do: "clear" };
32 else if (what === "hide_kind" || what === "show_kind") {
33 if (!isAskKind(args["kind"])) return bad("invalid", `kind is ${ASK_KINDS.join(", ")}`);
34 change = { do: what, kind: args["kind"] };
35 } else {
36 const repo = repoArg(ctx, user, { repo: args["app"] });
37 if (isAnswer(repo)) return repo;
38 change = { do: what, repo };
39 }
40 applyAskFilter(db, user, change);
41 }
1542 return good(asksAnswer(ctx, user, chips));
1643 },
1744 };
src/http/server.ts +2
@@ -306,6 +306,7 @@ import {
306306 asksDecideUndo,
307307 asksListen,
308308 asksMute,
309 asksFilter,
309310 asksPage,
310311 } from "../web/studio/asks_page.ts";
311312 import { keeperAnswer } from "../web/studio/keeper_row.ts";
@@ -938,6 +939,7 @@ export function buildRouter(): Router<Handler> {
938939 router.add("POST", "/asks/bulk/:id/undo", asksBulkUndo);
939940 router.add("POST", "/asks/listen", asksListen);
940941 router.add("POST", "/asks/mute", asksMute);
942 router.add("POST", "/asks/filter", asksFilter);
941943 router.add("POST", "/asks/proposal/:id/approve", asksProposal("approve"));
942944 router.add("POST", "/asks/proposal/:id/redirect", asksProposal("redirect"));
943945 router.add("POST", "/asks/proposal/:id/later", asksProposal("later"));
src/tui/screens/dashboard.ts +32 −1
@@ -43,6 +43,8 @@ import { pinRepo, unpinRepo } from "../../actions/origins.ts";
4343 import { getProposal } from "../../core/proposals.ts";
4444 import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts";
4545 import { askLinesFor } from "../../web/studio/asks.ts";
46 import { applyAskFilter } from "../../actions/ask_filter.ts";
47 import { askFiltersOf, hasAskFilters, hiddenLine, HIDE_APP_DAY_WORDS, SHOW_ALL_WORDS } from "../../core/ask_filters.ts";
4648 import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
4749 import { giveBack } from "../../actions/give_back.ts";
4850 import { passReasonOf } from "../../crew/guard.ts";
@@ -197,6 +199,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
197199 let asideWaves: Proposal[] = [];
198200 /** Repositories the viewer muted that hold work assigned to them: said, never silent. */
199201 let silenced = new Map<string, Repo>();
202 /** How many asks the viewer's filters hide; undefined when none is set. `H` hides an app for a day, `F` shows all. */
203 let hidden: number | undefined;
200204 /** The rows marked for one verb, by issue id. */
201205 const marked = new Set<string>();
202206 let lastSet: Operation | undefined;
@@ -508,7 +512,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
508512 */
509513 const assignedToMe = (): OnYouItem[] => {
510514 if (f.user === undefined) return [];
511 const rows = askLinesFor(f.db, f.user);
515 let hiddenRows = 0;
516 const rows = askLinesFor(f.db, f.user, undefined, (n) => (hiddenRows = n));
512517 /** The ask row's word for an issue or pull, by its id; a plain assignment says so. */
513518 const waitingOf = (id: string): WaitingFor =>
514519 rows.find((row) => row.items.some((item) => item.slice(item.indexOf(":") + 1) === id))
@@ -525,7 +530,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
525530 if (repo !== undefined && canRead(f.db, repo, f.user)) silenced.set(id, repo);
526531 }
527532 const off = chipsOf(f.db, f.user.id).people;
533 const filters = askFiltersOf(f.db, f.user.id);
534 hidden = hasAskFilters(filters) ? hiddenRows : undefined;
535 // A filtered ask is not in `rows`; one that is there (a trust alarm) always shows.
536 const asked = (id: string): boolean =>
537 rows.some((row) => row.items.some((item) => item.slice(item.indexOf(":") + 1) === id));
528538 const assigned = issues.flatMap((issue) => {
539 if ((filters.apps.has(issue.repoId) || filters.kinds.has("assigned")) && !asked(issue.id)) return [];
529540 if (off.has(issue.authorId)) return [];
530541 const repo = byId.get(issue.repoId);
531542 if (
@@ -894,6 +905,21 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
894905 ctx.toast("listening again", "success");
895906 };
896907
908 /** `H`: hide the app of the row under the cursor for a day: the web's "Hide for a day", the same table. */
909 const hideAppForADay = (): void => {
910 const item = onYou[at("onyou").index];
911 if (f.user === undefined || item === undefined) return;
912 ctx.toast(applyAskFilter(f.db, f.user, { do: "hide_app_day", repo: item.repo }), "success");
913 reload();
914 };
915
916 /** `F`: every ask filter off; one tap. */
917 const showAll = (): void => {
918 if (f.user === undefined || hidden === undefined) return;
919 ctx.toast(applyAskFilter(f.db, f.user, { do: "clear" }), "success");
920 reload();
921 };
922
897923 /** `1`–`4` switch a kind chip off or on, `P` brings back every person; the web's chips, the same table. */
898924 const toggleChip = (n: number): void => {
899925 if (f.user === undefined) return;
@@ -1076,12 +1102,14 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10761102 binding(["space"], "mark"),
10771103 binding(["x"], "close"),
10781104 binding(["m"], "mute"),
1105 binding(["H"], HIDE_APP_DAY_WORDS.toLowerCase()),
10791106 binding(["S"], "ship"),
10801107 ]
10811108 : []),
10821109 ...(focus === "onyou" && (waitingSet?.stale.length ?? 0) > 0 ? [binding(["Z"], "archive stale")] : []),
10831110 ...(lastSet !== undefined || lastArchive !== undefined ? [binding(["u"], "undo")] : []),
10841111 ...(silenced.size > 0 ? [binding(["L"], "listen again")] : []),
1112 ...(hidden !== undefined ? [binding(["F"], SHOW_ALL_WORDS.toLowerCase())] : []),
10851113 binding(["d"], "unpin", {
10861114 disabled: focus !== "machine" || pins.length === 0,
10871115 }),
@@ -1294,6 +1322,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
12941322 if (e.key === "D" && item !== undefined) return handToDecider(), true;
12951323 if (e.key === "b" && item !== undefined) return giveBackHere(), true;
12961324 if (e.key === "h") return hideAuthor(), true;
1325 if (e.key === "H" && item !== undefined) return hideAppForADay(), true;
12971326 if (e.key === "y") return actOnWave("approve"), true;
12981327 if (e.key === "R") return redirectWave(), true;
12991328 if (e.key === "l") return actOnWave("later"), true;
@@ -1318,6 +1347,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
13181347 }
13191348 if (e.key === "U" && f.user) return undoHand(), true;
13201349 if (e.key === "L" && f.user) return listenAgain(), true;
1350 if (e.key === "F" && f.user) return showAll(), true;
13211351 if (e.key === "C" && f.user) return ctx.push(deciderScreen(f, ctx)), true;
13221352 if (e.key === "P" && f.user) return showPeople(), true;
13231353 if (f.user && e.key >= "1" && e.key <= String(KIND_CHIPS.length))
@@ -1499,6 +1529,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
14991529 );
15001530 if (lastSet !== undefined) parts.push(`u undo ${lastSet.verb}`);
15011531 if (asideWaves.length > 0) parts.push(`${asideWaves.length} ${asideWaves.length === 1 ? "wave" : "waves"} put aside · B bring back`);
1532 if (hidden !== undefined) parts.unshift(`${hiddenLine(hidden)} · F ${SHOW_ALL_WORDS.toLowerCase()}`);
15021533 return parts.length > 0 ? parts.join(" · ") : undefined;
15031534 }
15041535
src/web/studio/asks.ts +43 −7
@@ -22,6 +22,7 @@ import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.t
2222 import { chipsOf, type Chips } from "../../core/feed_chips.ts";
2323 import { chipHides } from "../../feed/chips.ts";
2424 import { askDismissals, mutedRepos } from "../../core/interactions.ts";
25 import { askFiltersOf, filterHides, hasAskFilters, hiddenLine, TRUST_ALARM_SCORE, type AskKind } from "../../core/ask_filters.ts";
2526 import { ASKS_WINDOW_DAYS, askMeasureWeek, syncAskLedger, type AskEnd } from "../../core/ask_ledger.ts";
2627 import {
2728 getIssueById,
@@ -108,7 +109,7 @@ export function writableAppsOf(ctx: Pick<Ctx, "app">, user: User | undefined) {
108109 );
109110 }
110111
111 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "waiting" | "assigned" | "proposal";
112 export type DecisionKind = AskKind;
112113
113114 export interface Decision {
114115 readonly kind: DecisionKind;
@@ -740,7 +741,7 @@ function consequencesOf(
740741 numbers.length === 0
741742 ? 0
742743 : (blocks.get(a.repo.id, JSON.stringify(numbers))?.n ?? 0);
743 if (security) c = { score: 100, why: "security" };
744 if (security) c = { score: TRUST_ALARM_SCORE, why: "security" };
744745 else if (waiting > 0 && c.score < 60)
745746 c = {
746747 score: 60,
@@ -911,7 +912,35 @@ export function repoAsks(
911912 };
912913 }
913914
914 /** What asks the viewer now: candidates, less what they set aside. */
915 /**
916 * What asks the viewer now, from one scoring pass: the candidates less what
917 * they set aside (`open`), then less what their ask filters hide (`shown`),
918 * and how many rows the filters took out. Nothing hidden is lost.
919 */
920 export function waitingSplit(
921 db: Database,
922 user: User,
923 repos: readonly Repo[],
924 chips?: Chips,
925 ): { shown: Scored[]; hidden: number } {
926 return splitByFilters(db, user, notDismissed(db, user, scoredFor(db, user, repos), chips));
927 }
928
outdated

Re-scores all asks twice more per render; reuse the page's single pass.

929 /** Apply the viewer's ask filters to asks already not set aside; a trust alarm always stays. */
930 function splitByFilters(
931 db: Database,
932 user: User,
933 open: readonly Scored[],
934 ): { shown: Scored[]; hidden: number } {
935 const filters = askFiltersOf(db, user.id);
936 if (!hasAskFilters(filters)) return { shown: [...open], hidden: 0 };
937 const shown = open.filter(
938 (a) => !filterHides(filters, { kind: a.kind, repoId: a.repo.id, consequence: a.consequence }),
939 );
940 return { shown, hidden: askRows(open).length - askRows(shown).length };
941 }
942
943 /** What asks the viewer now: candidates, less what they set aside or filtered out. */
915944 export function waitingOn(
916945 db: Database,
917946 user: User | undefined,
@@ -919,7 +948,7 @@ export function waitingOn(
919948 chips?: Chips,
920949 ): Scored[] {
921950 if (!user) return [];
922 return notDismissed(db, user, scoredFor(db, user, repos), chips);
951 return waitingSplit(db, user, repos, chips).shown;
923952 }
924953
925954 /** How an ask that is no longer asked ended, when nobody dismissed it. */
@@ -992,7 +1021,7 @@ export function recordAskLedger(db: Database, now = new Date()): void {
9921021 if (!user) continue;
9931022 const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true });
9941023 const all = scoredFor(db, user, repos, now.getTime());
995 const shown = notDismissed(db, user, all);
1024 const shown = splitByFilters(db, user, notDismissed(db, user, all)).shown;
9961025 const brief = (a: Scored) => ({ id: a.id, repoId: a.repo.id, kind: a.kind });
9971026 syncAskLedger(
9981027 db,
@@ -1062,6 +1091,8 @@ export function askLinesFor(
10621091 db: Database,
10631092 user: User,
10641093 chips?: Chips,
1094 /** Told how many rows the viewer's ask filters hid, from the same pass. */
1095 onHidden?: (rows: number) => void,
10651096 ): {
10661097 id: string;
10671098 line: string;
@@ -1092,7 +1123,9 @@ export function askLinesFor(
10921123 recommendation?: Recommendation;
10931124 }[] {
10941125 const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true });
1095 return askRows(waitingOn(db, user, repos, chips)).map((row) => {
1126 const { shown, hidden } = waitingSplit(db, user, repos, chips);
1127 onHidden?.(hidden);
1128 return askRows(shown).map((row) => {
10961129 const base = `/${row.repo.ownerName}/${row.repo.name}`;
10971130 const about = {
10981131 id: row.id,
@@ -1187,12 +1220,15 @@ export function askSentence(count: number): string {
11871220 */
11881221 export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) {
11891222 const set = reviewSet(ctx, user);
1223 let hidden = 0;
1224 const rows = askLinesFor(ctx.app.db, user, chips, (n) => (hidden = n));
11901225 return {
11911226 // The very figures Home's line and /review show: `count` is live asks plus the forge's needs; `stale` are older than 30 days.
11921227 waiting: { count: set.count, stale: set.stale.length },
1193 rows: askLinesFor(ctx.app.db, user, chips),
1228 rows,
11941229 forge: forgeNeedsFor(ctx, user).map((d) => needLine(d)),
11951230 measure: { days: ASKS_WINDOW_DAYS, ...askMeasureWeek(ctx.app.db, user.id) },
1231 ...(hasAskFilters(askFiltersOf(ctx.app.db, user.id)) ? { filters: { hidden, line: hiddenLine(hidden) } } : {}),
11961232 };
11971233 }
11981234
src/web/studio/asks_page.ts +81 −8
@@ -25,6 +25,8 @@ import { giveBack } from "../../actions/give_back.ts";
2525 import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts";
2626 import type { FleetValue } from "../../core/fleet_ops.ts";
2727 import { muteRepo, removeMutes } from "../../core/interactions.ts";
28 import { applyAskFilter, isAskFilterDo, type AskFilterChange } from "../../actions/ask_filter.ts";
29 import { askFiltersOf, hasAskFilters, hiddenLine, HIDE_APP_DAY_WORDS, HIDE_KIND_WORDS, isAskKind, SHOW_ALL_WORDS } from "../../core/ask_filters.ts";
2830 import { needLine } from "../../actions/health.ts";
2931 import type { Degraded } from "../../kernel/vitals.ts";
3032 import type { Ctx } from "../../http/context.ts";
@@ -47,7 +49,7 @@ import {
4749 import { html, type Html } from "../html.ts";
4850 import { noteLine } from "./note.ts";
4951 import { page, redirect } from "../layout.ts";
50 import { btn, card, icon, row } from "../ui.ts";
52 import { btn, card, disclosure, icon, row } from "../ui.ts";
5153 import {
5254 askAnchor,
5355 askRows,
@@ -294,6 +296,56 @@ function muteFlash(ctx: Ctx, user: NonNullable<Ctx["auth"]["user"]>): Html | nul
294296 return null;
295297 }
296298
299 /** A one-field form: a pick from `options`, saved as the viewer's filter by `asksFilter`. */
300 function filterPick(
301 ctx: Ctx,
302 change: string,
303 field: "repo" | "kind",
304 options: readonly { value: string; label: string }[],
305 label: string,
306 tap: string,
307 ): Html {
308 return html`<form class="bar" method="post" action="/asks/filter">
309 <input type="hidden" name="do" value="${change}" />
310 <input type="hidden" name="back" value="${here(ctx)}" />
311 <select name="${field}" class="input" aria-label="${label}">
312 ${options.map((o) => html`<option value="${o.value}">${o.label}</option>`)}
313 </select>
314 ${btn(tap, { size: "sm", variant: "ghost" })}
315 </form>`;
316 }
317
318 /**
319 * The ask filters, once for the whole list: what they hide with the one tap
320 * that clears them, and, closed, the two picks that set them (an app for a
321 * day, a kind). The apps and kinds offered are those in the rows below.
322 */
323 function filterNote(ctx: Ctx, rows: readonly AskRow<Decision>[], hidden: number): Html | null {
324 const on = hasAskFilters(askFiltersOf(ctx.app.db, ctx.auth.user!.id));
325 if (!on && rows.length < 2) return null;
326 const apps = [...new Map(rows.map((r) => [r.repo.id, `${r.repo.ownerName}/${r.repo.name}`])).entries()].map(
327 ([value, label]) => ({ value, label }),
328 );
329 const kinds = [...new Set(rows.map((r) => r.kind))].map((k) => ({ value: k, label: ASK_KIND_WORDS[k] ?? k }));
330 const clear = !on
331 ? null
332 : html`<form class="bar" method="post" action="/asks/filter">
333 <input type="hidden" name="do" value="clear" />
outdated

Principles 3, 6, 7: two more buttons under every row; the list becomes a button wall. Offer these once, not per row.

334 <input type="hidden" name="back" value="${here(ctx)}" />
335 <span class="muted small">${hiddenLine(hidden)}</span>
336 ${btn(SHOW_ALL_WORDS, { size: "sm", variant: "ghost" })}
337 </form>`;
338 const picks = rows.length === 0
339 ? null
340 : disclosure(
341 "Filter",
342 html`${filterPick(ctx, "hide_app_day", "repo", apps, "App to hide", HIDE_APP_DAY_WORDS)}
343 ${filterPick(ctx, "hide_kind", "kind", kinds, "Kind to hide", HIDE_KIND_WORDS)}`,
344 { small: true },
345 );
346 return html`<div id="asks-filters" class="stack">${clear}${picks}</div>`;
347 }
348
297349 /** A row and, under it, the chip that mutes its repository (`asksMute`). */
298350 function mutableRow(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Html {
299351 return html`${rowCard(ctx, r, picked)}
@@ -435,15 +487,14 @@ export function needsYouSection(
435487 top?: AsksTop,
436488 ): { body: Html; scriptJs: string } {
437489 const picked = top?.picked ?? new Set<string>();
438 const read = () =>
439 decisionsFor(
440 ctx,
441 listReposVisibleTo(ctx.app.db, user, { limit: 200, withoutArchived: true }),
442 );
490 const visible = listReposVisibleTo(ctx.app.db, user, { limit: 200, withoutArchived: true });
491 let hidden = 0;
492 const read = () => decisionsFor(ctx, visible, { onHidden: (n) => (hidden = n) });
443493 // Callers that build the section outside a request have none to time against.
444494 const decisions = ctx.request instanceof Request ? timePhase(ctx.request, "asks", read) : read();
445495 const needs = forgeNeedsFor(ctx, user);
446496 const rows = askRows(decisions);
497 const filtered = filterNote(ctx, rows, hidden);
447498 const silenced = silencedRepos(ctx.app.db, user);
448499 const silencedNote =
449500 silenced.length === 0
@@ -483,7 +534,7 @@ export function needsYouSection(
483534 const count = rows.length + needs.length;
484535 if (count === 0 && top === undefined)
485536 return {
486 body: html`<h2 id="asks-sentence" class="sr-only">${askSentence(0)}</h2>${silencedNote}${asideNote}${undo}`,
537 body: html`<h2 id="asks-sentence" class="sr-only">${askSentence(0)}</h2>${filtered}${silencedNote}${asideNote}${undo}`,
487538 scriptJs: "",
488539 };
489540 // The forge's own health first, then the few asks that matter most;
@@ -534,7 +585,7 @@ export function needsYouSection(
534585 ${reasonInput(top?.reason)} ${btn("Preview", { size: "sm" })}
535586 <span class="muted small">on the selected asks</span>
536587 </form>`}
537 ${silencedNote} ${asideNote} ${undo}
588 ${filtered} ${silencedNote} ${asideNote} ${undo}
538589 ${decisions.map((d) => decisionForm(d, ctx.url.pathname))}
539590 </section>`;
540591 return { body, scriptJs: SELECT_JS };
@@ -798,3 +849,25 @@ export async function asksMute(ctx: Ctx): Promise<Response> {
798849 muteRepo(ctx.app.db, ctx.auth.user.id, repo.id);
799850 return redirect(withFlash(asksBack(form), "muted", repo.id));
800851 }
852
853 /** POST /asks/filter — `do` (hide_app_day, hide_app, show_app, hide_kind, show_kind, clear) with `repo` or `kind`: one tap, saved for the viewer. */
854 export async function asksFilter(ctx: Ctx): Promise<Response> {
855 if (!ctx.auth.user) return signInFirst(ctx);
856 const form = await readForm(ctx, "rw");
857 if (form instanceof Response) return form;
858 const what = form.get("do");
859 let change: AskFilterChange | undefined;
860 if (what === "clear") change = { do: "clear" };
861 else if (what === "hide_kind" || what === "show_kind") {
862 const kind = form.get("kind");
863 if (isAskKind(kind)) change = { do: what, kind };
864 } else if (isAskFilterDo(what)) {
865 const id = form.get("repo");
866 const repo = typeof id === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(id) ? getRepoById(ctx.app.db, id) : undefined;
867 if (repo !== undefined && canRead(ctx.app.db, repo, ctx.auth.user) && (what === "hide_app_day" || what === "hide_app" || what === "show_app"))
868 change = { do: what, repo };
869 }
870 if (change === undefined) return notFoundPage(ctx);
871 applyAskFilter(ctx.app.db, ctx.auth.user, change);
872 return redirect(asksBack(form));
873 }
src/web/studio/decisions.ts +13 −3
@@ -22,11 +22,21 @@ import { changeHref } from "./nouns.ts";
2222 import { flatten } from "./note.ts";
2323 import { testsClause } from "./words.ts";
2424 import { reviewerPaused } from "../../crew/reviewer_paused.ts";
25 import { reviewWhy, scoredFor, waitingOn, type Decision, type Scored } from "./asks.ts";
25 import type { Database } from "bun:sqlite";
26 import type { User } from "../../core/users.ts";
27 import { reviewWhy, scoredFor, waitingSplit, type Decision, type Scored } from "./asks.ts";
2628
2729 /** Dollars as a person reads them: cents when small, whole when not. */
2830 const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0));
2931
32 /** What asks the viewer now, and, when `onHidden` is given, how many rows their filters hid in the same pass. */
33 function withoutHidden(db: Database, user: User | undefined, repos: readonly Repo[], onHidden?: (rows: number) => void): Scored[] {
34 if (!user) return [];
35 const { shown, hidden } = waitingSplit(db, user, repos);
36 onHidden?.(hidden);
37 return shown;
38 }
39
3040 /**
3141 * The decisions waiting on the viewer, worded and pictured. A change's own
3242 * page passes `evenDismissed`: setting an ask aside quiets the lists, never
@@ -36,12 +46,12 @@ const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0));
3646 export function decisionsFor(
3747 ctx: Ctx,
3848 allRepos: readonly Repo[],
39 options: { evenDismissed?: boolean; pull?: string } = {},
49 options: { evenDismissed?: boolean; pull?: string; onHidden?: (rows: number) => void } = {},
4050 ): Decision[] {
4151 const db = ctx.app.db;
4252 const candidates = options.evenDismissed
4353 ? scoredFor(db, ctx.auth.user, allRepos, Date.now(), options.pull)
44 : waitingOn(db, ctx.auth.user, allRepos);
54 : withoutHidden(db, ctx.auth.user, allRepos, options.onHidden);
4555 // The pictures, in one read: a green pull's row leads with its preview.
4656 const repoIds = [...new Set(candidates.map((c) => c.repo.id))];
4757 const records = appsForRepos(db, repoIds);
test/ask-filters.test.ts added +206
@@ -0,0 +1,206 @@
1 /**
2 * The filters an owner sets on their asks (#11820): hide an app (for a day or
3 * until shown) or a kind of decision, one tap each, saved per person in the
4 * forge's database. A hidden ask is never lost: it returns when the day ends
5 * or on "Show all", and a trust alarm (security) is never hidden by a filter.
6 */
7 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { applyAskFilter } from "../src/actions/ask_filter.ts";
13 import { addToCrew, createAgent } from "../src/actions/crew.ts";
14 import { assignIssue } from "../src/actions/issues.ts";
15 import { openPull } from "../src/actions/pulls.ts";
16 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
17 import { askFiltersOf, clearAskFilters, HIDE_APP_DAY_WORDS, HIDE_DAY_MS, hideApp, hideKind, SHOW_ALL_WORDS } from "../src/core/ask_filters.ts";
18 import { createIssue, createLabel, getIssueById, setIssueLabels as setLabels } from "../src/core/issues.ts";
19 import type { Repo } from "../src/core/repos.ts";
20 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
21 import { createToken } from "../src/core/tokens.ts";
22 import { createUser, getUserByName, type User } from "../src/core/users.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { deleteRepo } from "../src/core/repos.ts";
25 import { askLinesFor } from "../src/web/studio/asks.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27 import { mcpTool } from "./helpers/mcp.ts";
28
29 quietLogs();
30 setDefaultTimeout(30_000);
31
32 const PASSWORD = "a-long-filter-passphrase";
33
34 describe("ask filters", () => {
35 let dataDir = "";
36 let app: App;
37 let servers: Servers;
38 let alice: User;
39 let cookie = "";
40 let plain: Repo;
41 let alarm: Repo;
42
43 const lines = () => askLinesFor(app.db, alice);
44 const apps = () => lines().map((l) => l.repo).sort();
45 const post = (fields: [string, string][]) =>
46 fetch(`${servers.url}/asks/filter`, {
47 method: "POST",
48 headers: { cookie: `${cookie}; forge_mode=studio`, origin: servers.url },
49 body: new URLSearchParams(fields),
50 redirect: "manual",
51 });
52
53 async function greenPull(repo: Repo, title: string): Promise<string> {
54 const bot = getUserByName(app.db, "bot")!;
55 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# x\n" }] });
56 addToCrew(app, alice, repo, "bot");
57 await commitToBranch(app, alice, repo, { branch: "bot/1", base: "main", message: title, files: [{ path: "F.md", content: "1\n" }] });
58 const opened = await openPull(app, bot, repo, { title, body: "", baseBranch: "main", headBranch: "bot/1" });
59 if (!opened.ok) throw new Error(String(opened.error));
60 const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: opened.value.id, env: `pull-${opened.value.number}`, commitOid: opened.value.headOid, reason: "pull" });
61 claimRun(app.db, run.id);
62 finishRun(app.db, run.id, { state: "ok", summary: "ok" });
63 assignIssue(app, alice, repo, getIssueById(app.db, opened.value.id)!, alice);
64 return opened.value.id;
65 }
66
67 beforeAll(async () => {
68 dataDir = await mkdtemp(join(tmpdir(), "forge-ask-filters-"));
69 app = createApp({ dataDir, port: 0, disk: () => ({ freeBytes: 500e9, totalBytes: 1000e9 }) });
70 servers = await serve(app);
71 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, adminIfFirst: true });
72 if (!made.ok) throw new Error(made.error);
73 alice = made.value;
74 const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: PASSWORD }), redirect: "manual" });
75 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
76 await createAgent(app, alice, { username: "bot" });
77 for (const name of ["plain", "alarm"]) {
78 const r = await createRepo(app, alice, { name, visibility: "public" });
79 if (!r.ok) throw new Error(String(r.error));
80 if (name === "plain") plain = r.value;
81 else alarm = r.value;
82 }
83 await greenPull(plain, "Plain work");
84 const security = await greenPull(alarm, "Widen the board id");
85 const label = createLabel(app.db, alarm.id, "security", "d00000");
86 if (!label.ok) throw new Error("label");
87 setLabels(app.db, security, [label.value.id]);
88 });
89
90 afterAll(async () => {
91 await servers.stop();
92 await rm(dataDir, { recursive: true, force: true });
93 });
94
95 test("hiding an app for a day hides its asks and keeps the order of the rest", () => {
96 clearAskFilters(app.db, alice.id);
97 const before = lines();
98 expect(before).toHaveLength(2);
99 expect(before[0]!.consequence.why).toBe("security");
100 hideApp(app.db, alice.id, plain.id, true);
101 expect(apps()).toEqual([`alice/alarm`]);
102 // Nothing is lost: the ask still exists and comes back with the filter gone.
103 clearAskFilters(app.db, alice.id);
104 expect(lines().map((l) => l.id)).toEqual(before.map((l) => l.id));
105 });
106
107 test("a hidden app returns when the day ends, and a hide until shown does not", () => {
108 clearAskFilters(app.db, alice.id);
109 const now = Date.now();
110 hideApp(app.db, alice.id, plain.id, true, now);
111 expect(askFiltersOf(app.db, alice.id, now + HIDE_DAY_MS - 1000).apps.has(plain.id)).toBe(true);
112 expect(askFiltersOf(app.db, alice.id, now + HIDE_DAY_MS + 1000).apps.has(plain.id)).toBe(false);
113 // The same through the list a person reads: an ended hide is no filter.
114 app.db.query("UPDATE ask_filter SET until = ?1").run(new Date(now - 1000).toISOString());
115 expect(apps()).toEqual(["alice/alarm", "alice/plain"]);
116 hideApp(app.db, alice.id, plain.id, false, now);
117 expect(askFiltersOf(app.db, alice.id, now + 30 * HIDE_DAY_MS).apps.has(plain.id)).toBe(true);
118 clearAskFilters(app.db, alice.id);
119 });
120
121 test("a filter never hides a trust alarm, by app or by kind", () => {
122 clearAskFilters(app.db, alice.id);
123 hideApp(app.db, alice.id, alarm.id, false);
124 hideApp(app.db, alice.id, plain.id, false);
125 hideKind(app.db, alice.id, lines()[0]!.kind);
126 expect(lines().map((l) => l.consequence.why)).toEqual(["security"]);
127 clearAskFilters(app.db, alice.id);
128 });
129
130 test("the page: one tap hides, says how many are hidden, and one tap clears", async () => {
131 clearAskFilters(app.db, alice.id);
132 const page = () => fetch(`${servers.url}/asks`, { headers: { cookie: `${cookie}; forge_mode=studio` } }).then((r) => r.text());
133 const first = await page();
134 // Offered once for the list, not under every row: a clear form is absent, the two picks are not.
135 expect(first.split('action="/asks/filter"').length - 1).toBe(2);
136 expect(first).toContain(HIDE_APP_DAY_WORDS);
137 expect(first).not.toContain("hidden by your filters");
138 const res = await post([["do", "hide_app_day"], ["repo", plain.id], ["back", "/asks"]]);
139 expect(res.status).toBe(303);
140 expect(res.headers.get("location")).toBe("/asks");
141 const hidden = await page();
142 expect(hidden).toContain("1 hidden by your filters");
143 expect(hidden).toContain(SHOW_ALL_WORDS);
144 // Home draws the same region (/live/on-you), so its count is not a mystery.
145 const home = await fetch(`${servers.url}/live/on-you`, { headers: { cookie: `${cookie}; forge_mode=studio` } }).then((r) => r.text());
146 expect(home).toContain("1 hidden by your filters");
147 expect(hidden).not.toContain("Plain work");
148 expect((await post([["do", "clear"]])).status).toBe(303);
149 expect(askFiltersOf(app.db, alice.id).apps.size).toBe(0);
150 expect(await page()).toContain("Plain work");
151 // Bad input is refused, and it needs a signed-in person.
152 expect((await post([["do", "hide_kind"], ["kind", "nope"]])).status).toBe(404);
153 expect((await post([["do", "hide_app"], ["repo", "x'y"]])).status).toBe(404);
154 expect((await post([["do", "explode"]])).status).toBe(404);
155 });
156
157 test("MCP asks sets the same filters in the same words, and says how many are hidden", async () => {
158 clearAskFilters(app.db, alice.id);
159 const token = createToken(app.db, alice.id, "rw", "rw").token;
160 const call = async (args: unknown) => (await mcpTool(servers.url, token, "asks", args)) as any;
161 const done = await call({ filter: "hide_app_day", app: "alice/plain" });
162 expect(done.value.rows).toHaveLength(1);
163 expect(done.value.filters).toEqual({ hidden: 1, line: "1 hidden by your filters" });
164 expect((await call({ filter: "hide_kind", kind: "nope" })).error).toContain("kind is");
165 expect((await call({ filter: "hide_app", app: "alice/none" })).error).toBeDefined();
166 const cleared = await call({ filter: "clear" });
167 expect(cleared.value.rows).toHaveLength(2);
168 expect(cleared.value.filters).toBeUndefined();
169 // The terminal and the page use the same action, so its words are one set.
170 expect(applyAskFilter(app.db, alice, { do: "hide_app_day", repo: plain })).toBe("Hiding alice/plain for a day");
171 expect(applyAskFilter(app.db, alice, { do: "clear" })).toBe("Showing everything again");
172 });
173
174 test("the order of what shows is the order it had, with three rows", () => {
175 clearAskFilters(app.db, alice.id);
176 const third = createIssue(app.db, { repoId: plain.id, authorId: alice.id, title: "Third thing" });
177 if (!third.ok) throw new Error("issue");
178 assignIssue(app, alice, plain, third.value, alice);
179 const before = lines().map((l) => l.id);
180 expect(before.length).toBeGreaterThanOrEqual(3);
181 hideKind(app.db, alice.id, "assigned");
182 const after = lines().map((l) => l.id);
183 expect(after.length).toBeLessThan(before.length);
184 expect(after).toEqual(before.filter((id) => after.includes(id)));
185 clearAskFilters(app.db, alice.id);
186 });
187
188 test("the terminal's words are the page's and the tool's", async () => {
189 const dash = await Bun.file("src/tui/screens/dashboard.ts").text();
190 expect(dash).toContain("HIDE_APP_DAY_WORDS.toLowerCase()");
191 expect(dash).toContain("SHOW_ALL_WORDS.toLowerCase()");
192 const { tool } = await import("../src/http/mcp/tools/asks.ts");
193 expect(tool.doc).toContain(`"${HIDE_APP_DAY_WORDS}"`);
194 expect(tool.doc).toContain(`"${SHOW_ALL_WORDS}"`);
195 });
196
197 test("a deleted app's filters go with it", async () => {
198 const gone = await createRepo(app, alice, { name: "gone", visibility: "public" });
199 if (!gone.ok) throw new Error(String(gone.error));
200 hideApp(app.db, alice.id, gone.value.id, false);
201 expect(app.db.query("SELECT COUNT(*) AS n FROM ask_filter WHERE value = ?1").get(gone.value.id)).toEqual({ n: 1 });
202 await deleteRepo(app.db, app.reposRoot, gone.value);
203 expect(app.db.query("SELECT COUNT(*) AS n FROM ask_filter WHERE value = ?1").get(gone.value.id)).toEqual({ n: 0 });
204 clearAskFilters(app.db, alice.id);
205 });
206 });
test/conversation-confirm.test.ts +1 −1
@@ -121,7 +121,7 @@ describe("the one list of what asks first", () => {
121121 // settings and can be put back. A new `writes: true` tool is on neither list until someone decides.
122122 const NOT_ASKED = new Set([
123123 "access", "account_requests", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas",
124 "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
124 "asks", "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner",
126126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
127127 "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
test/data-browser.test.ts +1
@@ -24,6 +24,7 @@ quietLogs();
2424 const AUDITED_CLEAR: string[] = [
2525 "app_domain.said",
2626 "app_feature_setting.value",
27 "ask_filter.value",
2728 "comment.body",
2829 "degraded_span.sentence",
2930 "efficiency_sample.value",
test/tui-onyou.test.ts +16
@@ -13,6 +13,7 @@ import { assignIssue, openIssue } from "../src/actions/issues.ts";
1313 import { createRepo } from "../src/actions/repos.ts";
1414 import { signUp } from "../src/actions/users.ts";
1515 import { createApp, type App as Forge } from "../src/app.ts";
16 import { askFiltersOf } from "../src/core/ask_filters.ts";
1617 import { chipsOf, setChip } from "../src/core/feed_chips.ts";
1718 import { createIssue, getIssue } from "../src/core/issues.ts";
1819 import { mutedRepos, recordInteraction } from "../src/core/interactions.ts";
@@ -137,6 +138,21 @@ describe("terminal On you: mutes and many in one move", () => {
137138 h.stop();
138139 });
139140
141 test("H hides the row's app for a day, says how many are hidden, and F shows all (#11820)", async () => {
142 issueIn(vault, "Vault hinge squeaks");
143 const h = await boot();
144 expect(h.text()).toContain("Vault hinge squeaks");
145 await h.press("H");
146 expect(askFiltersOf(forge.db, alice.id).apps.size).toBe(1);
147 expect(h.text()).toContain("hidden by your filters");
148 expect(h.text()).toContain("F show all");
149 await h.press("F");
150 expect(askFiltersOf(forge.db, alice.id).apps.size).toBe(0);
151 expect(h.text()).not.toContain("hidden by your filters");
152 expect(h.text()).toContain("Vault hinge squeaks");
153 h.stop();
154 });
155
140156 test("chips: a key switches a kind off for the inbox and the web, and back", async () => {
141157 const h = await boot();
142158 expect(chipsOf(forge.db, alice.id).kinds.size).toBe(0);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs