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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11820-the-owner-sets-filters-on-next-once-by-a → main 6 commits fork 3 threads · 3 unresolved
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`; | ||
| 59 | 59 | | `apps` | — | Apps you can see: live or not, where served, how each runs, what works | 343 | |
| 60 | 60 | | `archive` | — | Archive or wake a repository (`archived`) | 380 | |
| 61 | 61 | | `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 | | |
| 63 | 63 | | `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 | |
| 64 | 64 | | `asks_decide` | — | Answer a hand-over: the decider (`parked:<id>`), or `give_back` to its builder (`handover:`/`assigned:<id>`) | 270 | |
| 65 | 65 | | `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" | |
| 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 @@ | ||
| 5 | 5 | * repo. Deletion inverts the order — row first, directory second. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { forgetAppFilters } from "./ask_filters.ts"; | |
| 8 | 9 | import { isPacksMirror } from "../packs/owner.ts"; |
| 9 | 10 | import { contributionForksSince, isContributionFork } from "./contribute.ts"; |
| 10 | 11 | import type { Database } from "bun:sqlite"; |
| @@ -1082,6 +1083,7 @@ export async function deleteRepo( | ||
| 1082 | 1083 | // The doctor's rows carry `repo_id` without a foreign key (added columns on `maintenance_run`). |
| 1083 | 1084 | db.query("DELETE FROM maintenance_run WHERE duty = 'batch-doctor' AND repo_id = ?1").run(repo.id); |
| 1084 | 1085 | db.query("DELETE FROM governor_last WHERE repo_id = ?1").run(repo.id); |
| 1086 | forgetAppFilters(db, repo.id); | |
| 1085 | 1087 | db.query("DELETE FROM repo WHERE id = ?1").run(repo.id); |
| 1086 | 1088 | forgetRepoCursors(db, repo.id); |
| 1087 | 1089 | 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"; | |
| 1 | 3 | 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"; | |
| 3 | 5 | |
| 4 | 6 | export const tool: Tool = { |
| 5 | 7 | name: "asks", |
| 6 | 8 | 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 | }, | |
| 9 | 19 | 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", | |
| 12 | 23 | run(ctx, user, args) { |
| 13 | 24 | const chips = chipArgs(ctx, args); |
| 14 | 25 | 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 | } | |
| 15 | 42 | return good(asksAnswer(ctx, user, chips)); |
| 16 | 43 | }, |
| 17 | 44 | }; |
src/http/server.ts +2
| @@ -306,6 +306,7 @@ import { | ||
| 306 | 306 | asksDecideUndo, |
| 307 | 307 | asksListen, |
| 308 | 308 | asksMute, |
| 309 | asksFilter, | |
| 309 | 310 | asksPage, |
| 310 | 311 | } from "../web/studio/asks_page.ts"; |
| 311 | 312 | import { keeperAnswer } from "../web/studio/keeper_row.ts"; |
| @@ -938,6 +939,7 @@ export function buildRouter(): Router<Handler> { | ||
| 938 | 939 | router.add("POST", "/asks/bulk/:id/undo", asksBulkUndo); |
| 939 | 940 | router.add("POST", "/asks/listen", asksListen); |
| 940 | 941 | router.add("POST", "/asks/mute", asksMute); |
| 942 | router.add("POST", "/asks/filter", asksFilter); | |
| 941 | 943 | router.add("POST", "/asks/proposal/:id/approve", asksProposal("approve")); |
| 942 | 944 | router.add("POST", "/asks/proposal/:id/redirect", asksProposal("redirect")); |
| 943 | 945 | 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"; | ||
| 43 | 43 | import { getProposal } from "../../core/proposals.ts"; |
| 44 | 44 | import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts"; |
| 45 | 45 | 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"; | |
| 46 | 48 | import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; |
| 47 | 49 | import { giveBack } from "../../actions/give_back.ts"; |
| 48 | 50 | import { passReasonOf } from "../../crew/guard.ts"; |
| @@ -197,6 +199,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 197 | 199 | let asideWaves: Proposal[] = []; |
| 198 | 200 | /** Repositories the viewer muted that hold work assigned to them: said, never silent. */ |
| 199 | 201 | 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; | |
| 200 | 204 | /** The rows marked for one verb, by issue id. */ |
| 201 | 205 | const marked = new Set<string>(); |
| 202 | 206 | let lastSet: Operation | undefined; |
| @@ -508,7 +512,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 508 | 512 | */ |
| 509 | 513 | const assignedToMe = (): OnYouItem[] => { |
| 510 | 514 | 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)); | |
| 512 | 517 | /** The ask row's word for an issue or pull, by its id; a plain assignment says so. */ |
| 513 | 518 | const waitingOf = (id: string): WaitingFor => |
| 514 | 519 | 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 { | ||
| 525 | 530 | if (repo !== undefined && canRead(f.db, repo, f.user)) silenced.set(id, repo); |
| 526 | 531 | } |
| 527 | 532 | 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)); | |
| 528 | 538 | const assigned = issues.flatMap((issue) => { |
| 539 | if ((filters.apps.has(issue.repoId) || filters.kinds.has("assigned")) && !asked(issue.id)) return []; | |
| 529 | 540 | if (off.has(issue.authorId)) return []; |
| 530 | 541 | const repo = byId.get(issue.repoId); |
| 531 | 542 | if ( |
| @@ -894,6 +905,21 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 894 | 905 | ctx.toast("listening again", "success"); |
| 895 | 906 | }; |
| 896 | 907 | |
| 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 | ||
| 897 | 923 | /** `1`–`4` switch a kind chip off or on, `P` brings back every person; the web's chips, the same table. */ |
| 898 | 924 | const toggleChip = (n: number): void => { |
| 899 | 925 | if (f.user === undefined) return; |
| @@ -1076,12 +1102,14 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1076 | 1102 | binding(["space"], "mark"), |
| 1077 | 1103 | binding(["x"], "close"), |
| 1078 | 1104 | binding(["m"], "mute"), |
| 1105 | binding(["H"], HIDE_APP_DAY_WORDS.toLowerCase()), | |
| 1079 | 1106 | binding(["S"], "ship"), |
| 1080 | 1107 | ] |
| 1081 | 1108 | : []), |
| 1082 | 1109 | ...(focus === "onyou" && (waitingSet?.stale.length ?? 0) > 0 ? [binding(["Z"], "archive stale")] : []), |
| 1083 | 1110 | ...(lastSet !== undefined || lastArchive !== undefined ? [binding(["u"], "undo")] : []), |
| 1084 | 1111 | ...(silenced.size > 0 ? [binding(["L"], "listen again")] : []), |
| 1112 | ...(hidden !== undefined ? [binding(["F"], SHOW_ALL_WORDS.toLowerCase())] : []), | |
| 1085 | 1113 | binding(["d"], "unpin", { |
| 1086 | 1114 | disabled: focus !== "machine" || pins.length === 0, |
| 1087 | 1115 | }), |
| @@ -1294,6 +1322,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1294 | 1322 | if (e.key === "D" && item !== undefined) return handToDecider(), true; |
| 1295 | 1323 | if (e.key === "b" && item !== undefined) return giveBackHere(), true; |
| 1296 | 1324 | if (e.key === "h") return hideAuthor(), true; |
| 1325 | if (e.key === "H" && item !== undefined) return hideAppForADay(), true; | |
| 1297 | 1326 | if (e.key === "y") return actOnWave("approve"), true; |
| 1298 | 1327 | if (e.key === "R") return redirectWave(), true; |
| 1299 | 1328 | if (e.key === "l") return actOnWave("later"), true; |
| @@ -1318,6 +1347,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1318 | 1347 | } |
| 1319 | 1348 | if (e.key === "U" && f.user) return undoHand(), true; |
| 1320 | 1349 | if (e.key === "L" && f.user) return listenAgain(), true; |
| 1350 | if (e.key === "F" && f.user) return showAll(), true; | |
| 1321 | 1351 | if (e.key === "C" && f.user) return ctx.push(deciderScreen(f, ctx)), true; |
| 1322 | 1352 | if (e.key === "P" && f.user) return showPeople(), true; |
| 1323 | 1353 | if (f.user && e.key >= "1" && e.key <= String(KIND_CHIPS.length)) |
| @@ -1499,6 +1529,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1499 | 1529 | ); |
| 1500 | 1530 | if (lastSet !== undefined) parts.push(`u undo ${lastSet.verb}`); |
| 1501 | 1531 | 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()}`); | |
| 1502 | 1533 | return parts.length > 0 ? parts.join(" · ") : undefined; |
| 1503 | 1534 | } |
| 1504 | 1535 |
src/web/studio/asks.ts +43 −7
| @@ -22,6 +22,7 @@ import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.t | ||
| 22 | 22 | import { chipsOf, type Chips } from "../../core/feed_chips.ts"; |
| 23 | 23 | import { chipHides } from "../../feed/chips.ts"; |
| 24 | 24 | import { askDismissals, mutedRepos } from "../../core/interactions.ts"; |
| 25 | import { askFiltersOf, filterHides, hasAskFilters, hiddenLine, TRUST_ALARM_SCORE, type AskKind } from "../../core/ask_filters.ts"; | |
| 25 | 26 | import { ASKS_WINDOW_DAYS, askMeasureWeek, syncAskLedger, type AskEnd } from "../../core/ask_ledger.ts"; |
| 26 | 27 | import { |
| 27 | 28 | getIssueById, |
| @@ -108,7 +109,7 @@ export function writableAppsOf(ctx: Pick<Ctx, "app">, user: User | undefined) { | ||
| 108 | 109 | ); |
| 109 | 110 | } |
| 110 | 111 | |
| 111 | export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "waiting" | "assigned" | "proposal"; | |
| 112 | export type DecisionKind = AskKind; | |
| 112 | 113 | |
| 113 | 114 | export interface Decision { |
| 114 | 115 | readonly kind: DecisionKind; |
| @@ -740,7 +741,7 @@ function consequencesOf( | ||
| 740 | 741 | numbers.length === 0 |
| 741 | 742 | ? 0 |
| 742 | 743 | : (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" }; | |
| 744 | 745 | else if (waiting > 0 && c.score < 60) |
| 745 | 746 | c = { |
| 746 | 747 | score: 60, |
| @@ -911,7 +912,35 @@ export function repoAsks( | ||
| 911 | 912 | }; |
| 912 | 913 | } |
| 913 | 914 | |
| 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. */ | |
| 915 | 944 | export function waitingOn( |
| 916 | 945 | db: Database, |
| 917 | 946 | user: User | undefined, |
| @@ -919,7 +948,7 @@ export function waitingOn( | ||
| 919 | 948 | chips?: Chips, |
| 920 | 949 | ): Scored[] { |
| 921 | 950 | if (!user) return []; |
| 922 | return notDismissed(db, user, scoredFor(db, user, repos), chips); | |
| 951 | return waitingSplit(db, user, repos, chips).shown; | |
| 923 | 952 | } |
| 924 | 953 | |
| 925 | 954 | /** 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 { | ||
| 992 | 1021 | if (!user) continue; |
| 993 | 1022 | const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true }); |
| 994 | 1023 | 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; | |
| 996 | 1025 | const brief = (a: Scored) => ({ id: a.id, repoId: a.repo.id, kind: a.kind }); |
| 997 | 1026 | syncAskLedger( |
| 998 | 1027 | db, |
| @@ -1062,6 +1091,8 @@ export function askLinesFor( | ||
| 1062 | 1091 | db: Database, |
| 1063 | 1092 | user: User, |
| 1064 | 1093 | chips?: Chips, |
| 1094 | /** Told how many rows the viewer's ask filters hid, from the same pass. */ | |
| 1095 | onHidden?: (rows: number) => void, | |
| 1065 | 1096 | ): { |
| 1066 | 1097 | id: string; |
| 1067 | 1098 | line: string; |
| @@ -1092,7 +1123,9 @@ export function askLinesFor( | ||
| 1092 | 1123 | recommendation?: Recommendation; |
| 1093 | 1124 | }[] { |
| 1094 | 1125 | 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) => { | |
| 1096 | 1129 | const base = `/${row.repo.ownerName}/${row.repo.name}`; |
| 1097 | 1130 | const about = { |
| 1098 | 1131 | id: row.id, |
| @@ -1187,12 +1220,15 @@ export function askSentence(count: number): string { | ||
| 1187 | 1220 | */ |
| 1188 | 1221 | export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) { |
| 1189 | 1222 | const set = reviewSet(ctx, user); |
| 1223 | let hidden = 0; | |
| 1224 | const rows = askLinesFor(ctx.app.db, user, chips, (n) => (hidden = n)); | |
| 1190 | 1225 | return { |
| 1191 | 1226 | // The very figures Home's line and /review show: `count` is live asks plus the forge's needs; `stale` are older than 30 days. |
| 1192 | 1227 | waiting: { count: set.count, stale: set.stale.length }, |
| 1193 | rows: askLinesFor(ctx.app.db, user, chips), | |
| 1228 | rows, | |
| 1194 | 1229 | forge: forgeNeedsFor(ctx, user).map((d) => needLine(d)), |
| 1195 | 1230 | measure: { days: ASKS_WINDOW_DAYS, ...askMeasureWeek(ctx.app.db, user.id) }, |
| 1231 | ...(hasAskFilters(askFiltersOf(ctx.app.db, user.id)) ? { filters: { hidden, line: hiddenLine(hidden) } } : {}), | |
| 1196 | 1232 | }; |
| 1197 | 1233 | } |
| 1198 | 1234 |
src/web/studio/asks_page.ts +81 −8
| @@ -25,6 +25,8 @@ import { giveBack } from "../../actions/give_back.ts"; | ||
| 25 | 25 | import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts"; |
| 26 | 26 | import type { FleetValue } from "../../core/fleet_ops.ts"; |
| 27 | 27 | 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"; | |
| 28 | 30 | import { needLine } from "../../actions/health.ts"; |
| 29 | 31 | import type { Degraded } from "../../kernel/vitals.ts"; |
| 30 | 32 | import type { Ctx } from "../../http/context.ts"; |
| @@ -47,7 +49,7 @@ import { | ||
| 47 | 49 | import { html, type Html } from "../html.ts"; |
| 48 | 50 | import { noteLine } from "./note.ts"; |
| 49 | 51 | 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"; | |
| 51 | 53 | import { |
| 52 | 54 | askAnchor, |
| 53 | 55 | askRows, |
| @@ -294,6 +296,56 @@ function muteFlash(ctx: Ctx, user: NonNullable<Ctx["auth"]["user"]>): Html | nul | ||
| 294 | 296 | return null; |
| 295 | 297 | } |
| 296 | 298 | |
| 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 | ||
| 297 | 349 | /** A row and, under it, the chip that mutes its repository (`asksMute`). */ |
| 298 | 350 | function mutableRow(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Html { |
| 299 | 351 | return html`${rowCard(ctx, r, picked)} |
| @@ -435,15 +487,14 @@ export function needsYouSection( | ||
| 435 | 487 | top?: AsksTop, |
| 436 | 488 | ): { body: Html; scriptJs: string } { |
| 437 | 489 | 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) }); | |
| 443 | 493 | // Callers that build the section outside a request have none to time against. |
| 444 | 494 | const decisions = ctx.request instanceof Request ? timePhase(ctx.request, "asks", read) : read(); |
| 445 | 495 | const needs = forgeNeedsFor(ctx, user); |
| 446 | 496 | const rows = askRows(decisions); |
| 497 | const filtered = filterNote(ctx, rows, hidden); | |
| 447 | 498 | const silenced = silencedRepos(ctx.app.db, user); |
| 448 | 499 | const silencedNote = |
| 449 | 500 | silenced.length === 0 |
| @@ -483,7 +534,7 @@ export function needsYouSection( | ||
| 483 | 534 | const count = rows.length + needs.length; |
| 484 | 535 | if (count === 0 && top === undefined) |
| 485 | 536 | 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}`, | |
| 487 | 538 | scriptJs: "", |
| 488 | 539 | }; |
| 489 | 540 | // The forge's own health first, then the few asks that matter most; |
| @@ -534,7 +585,7 @@ export function needsYouSection( | ||
| 534 | 585 | ${reasonInput(top?.reason)} ${btn("Preview", { size: "sm" })} |
| 535 | 586 | <span class="muted small">on the selected asks</span> |
| 536 | 587 | </form>`} |
| 537 | ${silencedNote} ${asideNote} ${undo} | |
| 588 | ${filtered} ${silencedNote} ${asideNote} ${undo} | |
| 538 | 589 | ${decisions.map((d) => decisionForm(d, ctx.url.pathname))} |
| 539 | 590 | </section>`; |
| 540 | 591 | return { body, scriptJs: SELECT_JS }; |
| @@ -798,3 +849,25 @@ export async function asksMute(ctx: Ctx): Promise<Response> { | ||
| 798 | 849 | muteRepo(ctx.app.db, ctx.auth.user.id, repo.id); |
| 799 | 850 | return redirect(withFlash(asksBack(form), "muted", repo.id)); |
| 800 | 851 | } |
| 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"; | ||
| 22 | 22 | import { flatten } from "./note.ts"; |
| 23 | 23 | import { testsClause } from "./words.ts"; |
| 24 | 24 | 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"; | |
| 26 | 28 | |
| 27 | 29 | /** Dollars as a person reads them: cents when small, whole when not. */ |
| 28 | 30 | const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0)); |
| 29 | 31 | |
| 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 | ||
| 30 | 40 | /** |
| 31 | 41 | * The decisions waiting on the viewer, worded and pictured. A change's own |
| 32 | 42 | * 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)); | ||
| 36 | 46 | export function decisionsFor( |
| 37 | 47 | ctx: Ctx, |
| 38 | 48 | allRepos: readonly Repo[], |
| 39 | options: { evenDismissed?: boolean; pull?: string } = {}, | |
| 49 | options: { evenDismissed?: boolean; pull?: string; onHidden?: (rows: number) => void } = {}, | |
| 40 | 50 | ): Decision[] { |
| 41 | 51 | const db = ctx.app.db; |
| 42 | 52 | const candidates = options.evenDismissed |
| 43 | 53 | ? 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); | |
| 45 | 55 | // The pictures, in one read: a green pull's row leads with its preview. |
| 46 | 56 | const repoIds = [...new Set(candidates.map((c) => c.repo.id))]; |
| 47 | 57 | 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", () => { | ||
| 121 | 121 | // settings and can be put back. A new `writes: true` tool is on neither list until someone decides. |
| 122 | 122 | const NOT_ASKED = new Set([ |
| 123 | 123 | "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", | |
| 125 | 125 | "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner", |
| 126 | 126 | "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app", |
| 127 | 127 | "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(); | ||
| 24 | 24 | const AUDITED_CLEAR: string[] = [ |
| 25 | 25 | "app_domain.said", |
| 26 | 26 | "app_feature_setting.value", |
| 27 | "ask_filter.value", | |
| 27 | 28 | "comment.body", |
| 28 | 29 | "degraded_span.sentence", |
| 29 | 30 | "efficiency_sample.value", |
test/tui-onyou.test.ts +16
| @@ -13,6 +13,7 @@ import { assignIssue, openIssue } from "../src/actions/issues.ts"; | ||
| 13 | 13 | import { createRepo } from "../src/actions/repos.ts"; |
| 14 | 14 | import { signUp } from "../src/actions/users.ts"; |
| 15 | 15 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 16 | import { askFiltersOf } from "../src/core/ask_filters.ts"; | |
| 16 | 17 | import { chipsOf, setChip } from "../src/core/feed_chips.ts"; |
| 17 | 18 | import { createIssue, getIssue } from "../src/core/issues.ts"; |
| 18 | 19 | import { mutedRepos, recordInteraction } from "../src/core/interactions.ts"; |
| @@ -137,6 +138,21 @@ describe("terminal On you: mutes and many in one move", () => { | ||
| 137 | 138 | h.stop(); |
| 138 | 139 | }); |
| 139 | 140 | |
| 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 | ||
| 140 | 156 | test("chips: a key switches a kind off for the inbox and the web, and back", async () => { |
| 141 | 157 | const h = await boot(); |
| 142 | 158 | expect(chipsOf(forge.db, alice.id).kinds.size).toBe(0); |
new above
offline
Non-blocking: missing semicolon after the HIDE_APP_DAY_WORDS string.