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

Clear what is on you in one move: close, decline, mute or decide many at once, undoable #1320

Merged opened this in answer to

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

Merged

scout/forge:scout/1111-clear-what-is-on-you-in-one-move-close-d → main 3 commits fork 7 threads · 7 unresolved

  • Merged. scout/1111-clear-what-is-on-you-in-one-move-close-d was merged into main as dc8e9fe.

Review

Sign in to review this change.
16 files changed +665 −65
docs/FEED.md +13
@@ -122,3 +122,16 @@ may not see the item are both `unreachable`.
122122 - **Sender weight:** `2·(opens+1)/(shares+2)` over 90 days of delivered
123123 shares to people, clamped 0.2–2; an open is the recipient's `opened` of the
124124 item or its share. A sender nobody opens sinks, a trusted one rises.
125
126 ## Clearing what is on you in one move
127
128 The home's On you card (what is assigned to you) and the asks section take a
129 set: tick boxes, or *Select all*, a kind, or a repository, then one verb for
130 all of it — **close** (one `reason`, written on each as "Closed: …"),
131 **mute** (the repository goes quiet in your feed and On you; asks are never
132 muted away), **ship**, or on asks **decline** (dismiss) and hand back. It is
133 one `POST /api/v1/asks/bulk` (`action`, `items`, `reason`, `preview: false`);
134 `items` are ask ids, or `issue:<id>` for something assigned to you. The card
135 swaps in place and the top bar's count follows. One undo link
136 (`POST /api/v1/asks/bulk/<id>/undo`) is kept for a day. The MCP's `asks_act`
137 takes the same set, verb and reason.
src/actions/asks_bulk.ts +48 −16
@@ -9,22 +9,30 @@
99 */
1010
1111 import type { App } from "../app.ts";
12 import type { Operation } from "../core/fleet_ops.ts";
12 import { listOperations, type Operation } from "../core/fleet_ops.ts";
1313 import type { User } from "../core/users.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
1515 import { askTargetsFor } from "../web/studio/asks.ts";
1616 import {
1717 applyFleetIds,
1818 getFleetOp,
19 isAsksOperation,
20 UNDO_WINDOW_MS,
1921 previewFleetIds,
2022 undoFleetOp,
2123 type FleetError,
2224 type Plan,
2325 } from "./fleet.ts";
2426
25 /** What a bulk action on asks may be: the fleet verb each one is. */
27 /**
28 * What a bulk action on asks may be: the fleet verb each one is. Decline is
29 * the asks' word for dismiss; close takes one reason, said on each; mute
30 * silences the repository of each in the person's own On you.
31 */
2632 export const ASK_ACTIONS: Readonly<Record<string, string>> = {
2733 dismiss: "dismiss",
34 decline: "dismiss",
35 mute: "mute",
2836 ship: "ship",
2937 close: "close",
3038 replan: "replan",
@@ -43,8 +51,9 @@ function resolve(
4351 actor: User | undefined,
4452 action: unknown,
4553 items: unknown,
54 reason: unknown,
4655 ): Result<
47 { verb: string; ids: string[]; skipped: Skipped[]; query: string },
56 { verb: unknown; ids: string[]; skipped: Skipped[]; query: string },
4857 FleetError
4958 > {
5059 if (!actor)
@@ -71,9 +80,15 @@ function resolve(
7180 code: "too_many",
7281 message: `at most ${BULK_MAX} asks at once`,
7382 });
83 if (reason !== undefined && reason !== null && typeof reason !== "string")
84 return err({ code: "bad_action", message: "reason is a sentence" });
85 if (reason !== undefined && reason !== null && reason !== "" && verb !== "close")
86 return err({ code: "bad_action", message: "only close takes a reason" });
7487 const { issueIds, refused } = askTargetsFor(app.db, actor, list);
7588 return ok({
76 verb,
89 verb: verb === "close" && typeof reason === "string" && reason.trim() !== ""
90 ? { verb, value: reason }
91 : verb,
7792 ids: issueIds,
7893 skipped: refused,
7994 query: `asks: ${list.length} selected`,
@@ -86,8 +101,9 @@ export function previewAsksBulk(
86101 actor: User | undefined,
87102 action: unknown,
88103 items: unknown,
104 reason?: unknown,
89105 ): Result<Plan & { skipped: Skipped[] }, FleetError> {
90 const r = resolve(app, actor, action, items);
106 const r = resolve(app, actor, action, items, reason);
91107 if (!r.ok) return r;
92108 const plan = previewFleetIds(
93109 app,
@@ -107,8 +123,9 @@ export async function applyAsksBulk(
107123 actor: User | undefined,
108124 action: unknown,
109125 items: unknown,
126 reason?: unknown,
110127 ): Promise<Result<Operation & { skipped: Skipped[] }, FleetError>> {
111 const r = resolve(app, actor, action, items);
128 const r = resolve(app, actor, action, items, reason);
112129 if (!r.ok) return r;
113130 const op = await applyFleetIds(
114131 app,
@@ -122,15 +139,6 @@ export async function applyAsksBulk(
122139 return ok({ ...op.value, skipped: r.value.skipped });
123140 }
124141
125 /** Whether a fleet receipt is a bulk action on asks. */
126 function isAsksOperation(op: Operation): boolean {
127 return (
128 op.kind === "issue" &&
129 op.query.startsWith("asks: ") &&
130 Object.values(ASK_ACTIONS).includes(op.verb)
131 );
132 }
133
134142 /**
135143 * A bulk action on asks, for the one person who took it — never another,
136144 * a site admin included: a person's asks are theirs, and so is undoing
@@ -150,7 +158,31 @@ export function ownAsksOperation(
150158 : undefined;
151159 }
152160
153 /** Undo one's own bulk action on asks, once (`undoFleetOp`). */
161 /** Whether a receipt is still inside its day. */
162 export const undoable = (op: Operation, now = Date.now()): boolean =>
163 op.reversible &&
164 op.undoOf === null &&
165 op.undoneBy === null &&
166 now - Date.parse(op.createdAt) <= UNDO_WINDOW_MS;
167
168 /**
169 * The viewer's latest bulk action on asks that can still be undone — the
170 * one undo link the On you card keeps for a day. Nothing when none.
171 */
172 export function latestUndoable(
173 app: App,
174 actor: User | undefined,
175 now = Date.now(),
176 ): Operation | undefined {
177 if (!actor) return undefined;
178 const op = listOperations(app.db, actor.id, 5).find(
179 (o) => isAsksOperation(o) && o.undoOf === null,
180 );
181 if (op === undefined || !undoable(op, now)) return undefined;
182 return ownAsksOperation(app, actor, op.id);
183 }
184
185 /** Undo one's own bulk action on asks, once, within a day (`undoFleetOp` holds the window). */
154186 export async function undoAsksBulk(
155187 app: App,
156188 actor: User | undefined,
src/actions/fleet.ts +19
@@ -45,6 +45,7 @@ import {
4545 import { latestRunsForIssues } from "../core/runs.ts";
4646 import { isStalePull } from "../crew/stale_asks.ts";
4747 import { askDismissedBy } from "../web/studio/asks.ts";
48 import { mutedRepos } from "../core/interactions.ts";
4849 import { getRunsByIds } from "../core/runs.ts";
4950 import type { User } from "../core/users.ts";
5051 import { err, ok, type Result } from "../kernel/result.ts";
@@ -63,6 +64,7 @@ export interface FleetError {
6364 | "not_found"
6465 | "bad_query"
6566 | "too_many"
67 | "expired"
6668 | "bad_action"
6769 | "not_reversible"
6870 | "already_undone";
@@ -179,6 +181,7 @@ function loadTargets(
179181 for (const [repoId, repo] of repos) pillars.set(repoId, pillarsOf(app.db, repo));
180182 const under = umbrellasOver(app, ids, [...pillars.values()]);
181183 const peers: ReadonlySet<string> = new Set(ids);
184 const muted = viewer === undefined ? new Set<string>() : mutedRepos(app.db, viewer.id);
182185 const pull = (id: string) => {
183186 const p = pulls.get(id);
184187 return p === undefined
@@ -213,6 +216,7 @@ function loadTargets(
213216 : {
214217 viewerId: viewer.id,
215218 askDismissed: askDismissedBy(app.db, viewer.id, issue),
219 muted: muted.has(repo.id),
216220 }),
217221 ...(pull(id) ?? {}),
218222 },
@@ -538,6 +542,18 @@ export async function applyFleetOp(
538542 return ok(await carryOutAndRecord(app, person.value, plan.value));
539543 }
540544
545 /** How long a bulk action on asks can be undone: a day. */
546 export const UNDO_WINDOW_MS = 86_400_000;
547
548 const ASK_VERBS: readonly string[] = ["dismiss", "mute", "ship", "close", "replan"];
549
550 /** Whether a receipt is a bulk action on asks: named one by one, "asks: n selected". */
551 export function isAsksOperation(op: Operation): boolean {
552 return (
553 op.kind === "issue" && op.query.startsWith("asks: ") && ASK_VERBS.includes(op.verb)
554 );
555 }
556
541557 /**
542558 * Undo: every row this operation changed goes back to its value from
543559 * before, if it still holds the value this operation left. Once per
@@ -565,6 +581,9 @@ export async function undoFleetOp(
565581 (verb.personal || !parseQuery(op.query).ok)
566582 )
567583 return fail("not_found", "no such operation here");
584 // The asks' day is the same on every door: the card, the API, MCP.
585 if (isAsksOperation(op) && Date.now() - Date.parse(op.createdAt) > UNDO_WINDOW_MS)
586 return fail("expired", "an undo is kept for a day, and this one's day is over");
568587 if (!claimUndo(app.db, op.id))
569588 return fail("already_undone", "this operation was undone already");
570589 let undone: Operation | undefined;
src/core/interactions.ts +16
@@ -186,3 +186,19 @@ export function removeDismissals(
186186 AND item_id IN (SELECT value FROM json_each(?2))`,
187187 ).run(viewerId, JSON.stringify(itemIds));
188188 }
189
190 /**
191 * Take back this viewer's bulk mutes of these `repo:<id>` items: the undo of
192 * a bulk mute. A mute made elsewhere, under another item, stays.
193 */
194 export function removeMutes(
195 db: Database,
196 viewerId: string,
197 itemIds: readonly string[],
198 ): void {
199 if (itemIds.length === 0) return;
200 db.query<unknown, [string, string]>(
201 `DELETE FROM interaction WHERE viewer_id = ?1 AND kind = 'muted'
202 AND item_id IN (SELECT value FROM json_each(?2))`,
203 ).run(viewerId, JSON.stringify(itemIds));
204 }
src/fleet/verbs.ts +43 −4
@@ -24,7 +24,7 @@ import { isCrew } from "../core/agents.ts";
2424 import { mergePull } from "../actions/pulls.ts";
2525 import { bringUpToDate } from "../crew/autonomy.ts";
2626 import { getHold } from "../core/holds.ts";
27 import { recordInteraction, removeDismissals } from "../core/interactions.ts";
27 import { recordInteraction, removeDismissals, removeMutes } from "../core/interactions.ts";
2828 import { getPull } from "../core/pulls.ts";
2929 import { askItemsOf } from "../web/studio/asks.ts";
3030 import type { FleetValue } from "../core/fleet_ops.ts";
@@ -59,6 +59,8 @@ export interface Target {
5959 readonly viewerId?: string;
6060 /** For an issue: whether the viewer's dismissal of its ask still holds. */
6161 readonly askDismissed?: boolean;
62 /** Whether the viewer muted this repository. */
63 readonly muted?: boolean;
6264 /** For a pull: merged, its latest check green, and whether it merges as it stands. */
6365 readonly merged?: boolean;
6466 readonly green?: boolean;
@@ -328,17 +330,54 @@ export const VERBS: Record<string, Verb> = {
328330 done(requeueRepoRun(app, actor, t.repo, t.id)),
329331 },
330332
333 // Close: with a reason (one sentence), it is said on each before it
334 // closes; undo opens it again and the reason stays in the conversation.
331335 close: {
332336 kind: "issue",
333337 reversible: true,
334 parse: noValue,
338 parse: (_app, value) =>
339 value === null || value === undefined || value === ""
340 ? ok(null)
341 : typeof value === "string" && value.length <= 300
342 ? ok(value.trim().replace(/\s+/g, " "))
343 : err("a reason is one sentence of at most 300 characters"),
335344 read: (t) => t.issue?.state ?? null,
336345 next: () => "closed",
337346 refuse: (app, t) => (isMirror(app, t.repo) ? words("mirrored") : undefined),
338 async write(app, actor, t, after) {
347 async write(app, actor, t, after, value) {
339348 if (!t.issue || (after !== "open" && after !== "closed"))
340349 return words("not_found");

The comment is written before the close. If setIssueState refuses, the issue stays open with "Closed: …" on it. Close first, then say it.

341 return done(setIssueState(app, actor, t.repo, t.issue, after));
350 const closed = done(setIssueState(app, actor, t.repo, t.issue, after));
351 // Said after the close, so a refused close leaves no comment behind.
352 if (closed === undefined && after === "closed" && typeof value === "string" && value !== "") {
353 const said = addComment(app, actor, t.repo, t.issue, `Closed: ${value}`);
354 if (!said.ok) return words(String(said.error));
355 }
356 return closed;
357 },
358 },
359
360 // Mute: the actor's own "not this app, not now": its repository goes

Mute now hides assigned work from On you, and after the undo link expires nothing lists or reverses it. Show a quiet line saying which repositories are muted, with a way to listen again (or open an issue for it), so a mistaken mute doesn't silently drop work.

361 // quiet in their feed and their On you card. Undo listens again.
362 mute: {
363 kind: "issue",
364 reversible: true,
365 personal: true,
366 parse: noValue,
367 read: (t) => (t.muted ? "muted" : "listening"),
outdated

removeMutes drops every muted row for the repository, including a mute made from the feed after this operation. Delete only the repo:<id> item this verb recorded.

368 next: () => "muted",
369 refuse: () => undefined,
370 async write(app, actor, t, after) {
371 if (after === "listening") removeMutes(app.db, actor.id, [`repo:${t.repo.id}`]);
372 else
373 recordInteraction(app.db, {
374 viewerId: actor.id,
375 itemId: `repo:${t.repo.id}`,
376 kind: "muted",
377 repoId: t.repo.id,
378 actorId: null,
379 });
380 return undefined;
342381 },
343382 },
344383
src/http/api.ts +1 −1
@@ -371,7 +371,7 @@ function respondFleet(error: FleetError): Response {
371371 if (error.code === "unauthorized") return unauthorized();
372372 return apiError(
373373 error.code,
374 error.code === "forbidden" ? 403 : error.code === "already_undone" ? 409 : 422,
374 error.code === "forbidden" ? 403 : error.code === "already_undone" || error.code === "expired" ? 409 : 422,
375375 error.message,
376376 );
377377 }
src/http/feed.ts +3 −3
@@ -82,11 +82,11 @@ export function feedRoutes(router: Router<Handler>): void {
8282 const denied = requireScope(ctx, preview ? "ro" : "rw");
8383 if (denied) return denied;
8484 if (preview) {
85 const plan = previewAsksBulk(ctx.app, ctx.auth.user, input.action, input.items);
85 const plan = previewAsksBulk(ctx.app, ctx.auth.user, input.action, input.items, input.reason);
8686 if (!plan.ok) return bulkError(plan.error);
8787 return json({ preview: true, ...plan.value });
8888 }
89 const op = await applyAsksBulk(ctx.app, ctx.auth.user, input.action, input.items);
89 const op = await applyAsksBulk(ctx.app, ctx.auth.user, input.action, input.items, input.reason);
9090 if (!op.ok) return bulkError(op.error);
9191 return json({ preview: false, ...op.value }, 201);
9292 });
@@ -211,7 +211,7 @@ function bulkError(error: FleetError): Response {
211211 if (error.code === "unauthorized") return unauthorized();
212212 return apiError(
213213 error.code,
214 error.code === "forbidden" ? 403 : error.code === "already_undone" ? 409 : 422,
214 error.code === "forbidden" ? 403 : error.code === "already_undone" || error.code === "expired" ? 409 : 422,
215215 error.message,
216216 );
217217 }
src/http/mcp.ts +5 −4
@@ -415,12 +415,13 @@ const TOOLS: readonly Tool[] = [
415415 {
416416 name: "asks_act",
417417 description:
418 "Act on many asks at once: dismiss, ship, close, or replan (hand an issue back to its app's planner). `items` are ask ids from `asks` (a row's `items`). A dry run (the default, and all a read-only token may do) changes nothing: the answer is each row's before and after and the rows refused with why. With dryRun false it acts and returns a receipt; dismiss, close and replan can be undone once (POST /api/v1/asks/bulk/<id>/undo). Ship is never undone. Dismiss needs only that the ask is yours; the others are checked against your admin of each app.",
418 "Decide many asks at once (the set and the verb): dismiss or decline, ship, close (with one `reason`, written on each), mute (silence the app's repository in your own On you), or replan (hand an issue back to its app's planner). `items` are ask ids from `asks` (a row's `items`), or `issue:<id>` for something assigned to you. A dry run (the default, and all a read-only token may do) changes nothing: the answer is each row's before and after and the rows refused with why. With dryRun false it acts and returns a receipt; dismiss, decline, mute, close and replan can be undone once, for a day (POST /api/v1/asks/bulk/<id>/undo). Ship is never undone. Dismiss needs only that the ask is yours; the others are checked against your admin of each app.",
419419 inputSchema: {
420420 type: "object",
421421 properties: {
422 action: { type: "string", enum: ["dismiss", "ship", "close", "replan"] },
422 action: { type: "string", enum: ["dismiss", "decline", "mute", "ship", "close", "replan"] },
423423 items: { type: "array", items: { type: "string" }, maxItems: 100 },
424 reason: { type: "string", maxLength: 300 },
424425 dryRun: { type: "boolean" },
425426 },
426427 required: ["action", "items"],
@@ -430,8 +431,8 @@ const TOOLS: readonly Tool[] = [
430431 async run(ctx, user, args) {
431432 const answer =
432433 args["dryRun"] === false
433 ? await applyAsksBulk(ctx.app, user, args["action"], args["items"])
434 : previewAsksBulk(ctx.app, user, args["action"], args["items"]);
434 ? await applyAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"])
435 : previewAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"]);
435436 return answer.ok ? good(answer.value) : bad(answer.error.code, answer.error.message);
436437 },
437438 },
src/http/server.ts +2
@@ -182,6 +182,7 @@ import { askedPage } from "../web/studio/asked_page.ts";
182182 import {
183183 asksBulkSubmit,
184184 asksBulkUndo,
185 asksListen,
185186 asksPage,
186187 } from "../web/studio/asks_page.ts";
187188 import { liveFeedFragment } from "../web/studio/feed.ts";
@@ -560,6 +561,7 @@ export function buildRouter(): Router<Handler> {
560561 router.add("GET", "/asks", asksPage);
561562 router.add("POST", "/asks/bulk", asksBulkSubmit);
562563 router.add("POST", "/asks/bulk/:id/undo", asksBulkUndo);
564 router.add("POST", "/asks/listen", asksListen);
563565 // What people asked of the crew, and what became of it (core/asked.ts).
564566 router.add("GET", "/asked", askedPage);
565567 // The big view, folded into the board: every old address lands on its wall mode.
src/web/layout.ts +2 −1
@@ -152,10 +152,11 @@ function themeMenu(active: ThemeName | undefined): Html {
152152 * The way to what needs you, with how many things ask the viewer — the very
153153 * count the home's pinned section leads with (`askCountFor`).
154154 */
155 function asksLink(ctx: Ctx): Html {
155 export function asksLink(ctx: Ctx): Html {
156156 if (!ctx.auth.user) return html``;
157157 const count = askCountFor(ctx, ctx.auth.user);
158158 return html`<a
159 id="asks-link"
159160 class="btn"
160161 data-size="sm"
161162 href="/#needs-you"
src/web/pages/site.ts +64 −15
@@ -29,7 +29,9 @@ import { hasAnyUser, verifyPassword, type User } from "../../core/users.ts";
2929 import { isThemeName } from "../../design/themes.ts";
3030 import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts";
3131 import { studioDoor, studioHome } from "../studio/home.ts";
32 import { homeTop } from "../studio/asks_page.ts";
32 import { homeTop, onYouBulk } from "../studio/asks_page.ts";
33 import { latestUndoable } from "../../actions/asks_bulk.ts";
34 import { mutedRepos } from "../../core/interactions.ts";
3335 import { edgeLoginUrl } from "../../http/edge.ts";
3436 import { sshCommand } from "../../ssh/command.ts";
3537 import { hostOf, pageHead, signInFirst } from "../chrome.ts";
@@ -182,29 +184,76 @@ export function onYouCard(ctx: Ctx, user: User): Html | undefined {
182184 const visible = new Set(
183185 listReposVisibleTo(db, user, { limit: 200 }).map((repo) => repo.id),
184186 );
185 const rows: Html[] = [];
187 const muted = mutedRepos(db, user.id);

The web card filters muted repositories here; the terminal's On you (src/tui/screens/dashboard.ts:129) does not, so the two doors disagree. Tracked in #1499.

188 const shown: { issue: Issue; repo: Repo }[] = [];
189 // Muted repositories that hold work assigned to the viewer: said, never silent.
190 const silenced = new Map<string, Repo>();
186191 for (const issue of assigned) {
187192 const repo = repos.get(issue.repoId);
188193 if (!repo) continue;
189194 if (!visible.has(repo.id) && !canRead(db, repo, user)) continue;
190195 if (!mayView(db, repo, issue, user)) continue;
191 rows.push(
192 row({
193 href: threadPath(repo, issue),
194 nav: true,
195 state: "open",
196 lead: icon(issue.kind === "pull" ? "pull" : "issue"),
197 title: issue.title,
198 titleAttr: issue.title,
199 meta: html`${repo.ownerName}/${repo.name} #${issue.number} · updated
200 ${time(issue.updatedAt)}`,
201 }),
202 );
196 if (muted.has(repo.id)) {
197 silenced.set(repo.id, repo);
198 continue;
199 }
200 shown.push({ issue, repo });
203201 }
204 if (rows.length === 0) return undefined;
202 // Two or more can be given one move: a box each, and one form for the set.
203 const bulk = shown.length >= 2;
204 const rows = shown.map(({ issue, repo }) =>
205 row({
206 ...(bulk ? {} : { href: threadPath(repo, issue), nav: true as const }),
207 state: "open",
208 lead: bulk
209 ? html`<input
210 type="checkbox"
211 class="ask-pick"
212 name="item"
213 value="issue:${issue.id}"
214 form="onyou-bulk"
215 data-repo="${repo.id}"
216 data-kind="${issue.kind}"
217 aria-label="Select ${issue.title}"
218 />`
219 : icon(issue.kind === "pull" ? "pull" : "issue"),
220 title: bulk
221 ? html`<a href="${threadPath(repo, issue)}">${issue.title}</a>`
222 : issue.title,
223 titleAttr: issue.title,
224 meta: html`${repo.ownerName}/${repo.name} #${issue.number} · updated
225 ${time(issue.updatedAt)}`,
226 }),
227 );
228 if (rows.length === 0 && silenced.size === 0) return undefined;
229 const last = latestUndoable(ctx.app, user);
230 const byKind = [...new Set(shown.map((s) => s.issue.kind))].map((k) => ({
231 id: k,
232 label: k === "pull" ? "Pulls" : "Issues",
233 }));
234 const byRepo = [...new Map(shown.map((s) => [s.repo.id, s.repo])).values()].map(
235 (r) => ({ id: r.id, label: `${r.ownerName}/${r.name}` }),
236 );
205237 return listCard(rows, {
206238 head: html`${icon("user")}
207239 <h2>On you</h2>`,
240 ...(bulk || last !== undefined || silenced.size > 0
241 ? {
242 foot: onYouBulk(
243 byKind,
244 byRepo,
245 last === undefined
246 ? undefined
247 : {
248 id: last.id,
249 verb: last.verb,
250 done: last.rows.filter((r) => r.outcome === "ok").length,
251 },
252 bulk,
253 [...silenced.values()].map((r) => ({ id: r.id, label: `${r.ownerName}/${r.name}` })),
254 ),
255 }
256 : {}),
208257 });
209258 }
210259
src/web/studio/asks.ts +9 −1
@@ -19,6 +19,7 @@ import { askMeasure, syncAskLedger, type AskEnd } from "../../core/ask_ledger.ts
1919 import {
2020 getIssueById,
2121 labelsForIssues,
22 listAssignedTo,
2223 listComments,
2324 listOpenPullsVisibleTo,
2425 type Issue,
@@ -1045,9 +1046,16 @@ export function askTargetsFor(
10451046 );
10461047 const issueIds: string[] = [];
10471048 const refused: { item: string; reason: string }[] = [];
1049 // `issue:<id>` names an issue or pull assigned to the viewer — what the
1050 // home's On you card holds — rather than an ask it raises.
1051 const assigned = new Map(
1052 listAssignedTo(db, user.id, 200).map((i) => [`issue:${i.id}`, i]),
1053 );
10481054 for (const item of new Set(items)) {
10491055 const ask = asks.get(item);
1050 if (ask === undefined) refused.push({ item, reason: "it no longer asks you" });
1056 const mine = assigned.get(item);
1057 if (mine !== undefined) issueIds.push(mine.id);
1058 else if (ask === undefined) refused.push({ item, reason: "it no longer asks you" });
10511059 else if (ask.kind === "paused")
10521060 refused.push({ item, reason: "a ceiling is raised in the app's settings" });
10531061 else if (ask.kind === "declined")
src/web/studio/asks_page.ts +192 −14
@@ -9,15 +9,17 @@
99
1010 import {
1111 applyAsksBulk,
12 BULK_MAX,
1213 ownAsksOperation,
1314 undoAsksBulk,
1415 previewAsksBulk,
1516 type Skipped,
1617 } from "../../actions/asks_bulk.ts";
1718 import type { FleetValue } from "../../core/fleet_ops.ts";
19 import { removeMutes } from "../../core/interactions.ts";
1820 import type { Ctx } from "../../http/context.ts";
1921 import type { User } from "../../core/users.ts";
20 import { studioHome } from "./home.ts";
22 import { onYouAnswer, studioHome } from "./home.ts";
2123 import { notFoundPage, repoBase, signInFirst } from "../chrome.ts";
2224 import { field, readForm } from "../form.ts";
2325 import { listReposVisibleTo } from "../../core/repos.ts";
@@ -40,6 +42,14 @@ import {
4042 } from "./asks.ts";
4143 import { changeHref } from "./nouns.ts";
4244
45 /** The kinds, in a word each, for "select by kind". */
46 const ASK_KIND_WORDS: Readonly<Record<string, string>> = {
47 look: "Looks",
48 review: "Reviews",
49 stopped: "Stopped",
50 parked: "Parked",
51 };
52
4353 /** The kinds a bulk action takes: a ceiling and a planner's decline are decided where they live. */
4454 const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "stopped", "parked"]);
4555
@@ -52,17 +62,62 @@ const pick = (d: Decision, picked: ReadonlySet<string>): Html | null =>
5262 name="item"
5363 value="${d.id}"
5464 form="asks-bulk"
65 data-repo="${d.repo.id}"
66 data-kind="${d.kind}"
5567 ${picked.has(d.id) ? html`checked` : null}
5668 aria-label="Select ${d.pull?.title ?? d.issue?.title ?? d.id}"
5769 />`
5870 : null;
5971
60 /** "Select all of this kind": the one box on a collapsed row's head. */
61 const SELECT_ALL_JS = `document.addEventListener('change', function (e) {
72 /**
73 * Selecting many, and acting on them in place. A box belongs to a bulk form
74 * by `form="<id>"`; a `data-pick` button or the repository chooser ticks
75 * the boxes of one form by all, kind or repository. A bulk form marked
76 * `data-bulk` posts without leaving the page and swaps the On you region
77 * and the top bar's count with what the server answers; on any trouble it
78 * falls back to the plain post, which says what happened.
79 */
80 const SELECT_JS = `document.addEventListener('change', function (e) {
6281 var t = e.target;
63 if (!t || !t.matches || !t.matches('[data-select-all]')) return;
64 var card = t.closest('.card');
65 if (card) card.querySelectorAll('input[name="item"]').forEach(function (b) { b.checked = t.checked; });
82 if (!t || !t.matches) return;
83 if (t.matches('[data-select-all]')) {
84 var card = t.closest('.card');
85 if (card) card.querySelectorAll('input[name="item"]').forEach(function (b) { b.checked = t.checked; });
86 } else if (t.matches('select[data-pick-repo]') && t.value) {
87 pick(t.form, function (b) { return b.getAttribute('data-repo') === t.value; });
88 t.value = '';
89 }
90 });
91 function pick(form, test) {
92 if (!form || !form.id) return;
93 document.querySelectorAll('input[form="' + form.id + '"][name="item"]').forEach(function (b) { b.checked = test(b); });
94 }
95 document.addEventListener('click', function (e) {
96 var t = e.target && e.target.closest && e.target.closest('[data-pick]');
97 if (!t) return;
98 var want = t.getAttribute('data-pick');
99 pick(t.form, function (b) { return want === 'all' || 'kind:' + b.getAttribute('data-kind') === want; });
100 });
101 document.addEventListener('submit', function (e) {
102 var f = e.target;
103 if (!f || !f.matches || !f.matches('form[data-bulk]') || !window.fetch) return;
104 if (!new FormData(f).has('item')) return;
105 e.preventDefault();
106 var region = document.getElementById('live-on-you');
107 if (region) region.setAttribute('data-busy', '');
108 fetch(f.action, { method: 'POST', body: new URLSearchParams(new FormData(f)), credentials: 'same-origin', headers: { 'x-forge-fragment': '1' } })
109 .then(function (r) { return r.ok ? r.text() : Promise.reject(r); })
110 .then(function (text) {
111 var t = document.createElement('template');
112 t.innerHTML = text;
113 var fresh = t.content.getElementById('live-on-you');
114 var link = t.content.getElementById('asks-link');
115 if (region && fresh) region.innerHTML = fresh.innerHTML;
116 var mine = document.getElementById('asks-link');
117 if (mine && link) mine.innerHTML = link.innerHTML;
118 })
119 .catch(function () { f.removeAttribute('data-bulk'); f.submit(); })
120 .then(function () { if (region) region.removeAttribute('data-busy'); });
66121 });`;
67122
68123 /** The rows shown before "N more": the few that matter most. */
@@ -184,6 +239,7 @@ export interface AsksTop {
184239 /** The asks the preview is about, kept ticked. */
185240 readonly picked?: ReadonlySet<string>;
186241 readonly action?: string;
242 readonly reason?: string;
187243 }
188244
189245 /** GET /asks — the home, scrolled to what needs you; `?op=<id>` (a bulk
@@ -259,18 +315,23 @@ export function needsYouSection(
259315 ${pickable < 2
260316 ? null
261317 : html`<form id="asks-bulk" method="post" action="/asks/bulk" class="bar">
318 ${pickControls(
319 "asks-bulk",
320 [...new Map(decisions.filter((d) => BULK_KINDS.has(d.kind)).map((d) => [d.kind, { id: d.kind, label: ASK_KIND_WORDS[d.kind] ?? d.kind }])).values()],
321 [...new Map(decisions.filter((d) => BULK_KINDS.has(d.kind)).map((d) => [d.repo.id, { id: d.repo.id, label: `${d.repo.ownerName}/${d.repo.name}` }])).values()],
322 )}
262323 <select name="action" class="input" aria-label="What to do with the selected asks">
263 ${Object.entries(VERB_WORDS).map(
264 ([value, words]) =>
265 html`<option value="${value}" ${top?.action === value ? html`selected` : null}>${words}</option>`,
324 ${ASK_VERBS.map(
325 (value) =>
326 html`<option value="${value}" ${top?.action === value ? html`selected` : null}>${VERB_WORDS[value]}</option>`,
266327 )}
267328 </select>
268 ${btn("Preview", { size: "sm" })}
329 ${reasonInput(top?.reason)} ${btn("Preview", { size: "sm" })}
269330 <span class="muted small">on the selected asks</span>
270331 </form>`}
271332 ${decisions.map((d) => decisionForm(d))}
272333 </section>`;
273 return { body, scriptJs: pickable < 2 ? "" : SELECT_ALL_JS };
334 return { body, scriptJs: SELECT_JS };
274335 }
275336
276337 /** The home with a bulk step's preview or receipt on top of what needs
@@ -285,11 +346,95 @@ async function asksScreen(ctx: Ctx, top: AsksTop): Promise<Response> {
285346
286347 const VERB_WORDS: Readonly<Record<string, string>> = {
287348 dismiss: "Dismiss",
349 decline: "Decline",
350 mute: "Mute repository",
288351 ship: "Ship",
289352 close: "Close",
290353 replan: "Hand back to the planner",
291354 };
292355
356 /** What the asks below can be given in one move; mute is the On you card's. */
357 const ASK_VERBS = ["decline", "ship", "close", "replan"] as const;
358
359 /** What the On you card's set can be given. */
360 const CARD_VERBS = ["close", "mute", "ship"] as const;
361
362 /** One reason, written on each pull or issue closed. */
363 const reasonInput = (value = ""): Html =>
364 html`<input
365 class="input"
366 name="reason"
367 maxlength="300"
368 value="${value}"
369 placeholder="Reason, written on each closed"
370 aria-label="Reason, written on each closed"
371 />`;
372
373 /**
374 * Pick-by-set controls for the bulk form `formId`: all, by kind, by
375 * repository. Plain buttons the page's script answers; the boxes still work alone.
376 */
377 export function pickControls(
378 formId: string,
379 kinds: readonly { id: string; label: string }[],
380 repos: readonly { id: string; label: string }[],
381 ): Html {
382 return html`<span class="bar" role="group" aria-label="Select">
383 <button type="button" class="btn" data-size="sm" form="${formId}" data-pick="all">Select all</button>
384 ${kinds.length < 2
385 ? null
386 : kinds.map(
387 (k) =>
388 html`<button type="button" class="btn" data-size="sm" form="${formId}" data-pick="kind:${k.id}">${k.label}</button>`,
389 )}
390 ${repos.length < 2
391 ? null
392 : html`<select class="input" form="${formId}" data-pick-repo aria-label="Select by repository">
393 <option value="">By repository…</option>
394 ${repos.map((r) => html`<option value="${r.id}">${r.label}</option>`)}
395 </select>`}
396 </span>`;
397 }
398
399 /**
400 * The On you card's one form: a verb for the set, one reason, one request.
401 * `receipt` is the day's undo link for the last set acted on.
402 */
403 export function onYouBulk(
404 kinds: readonly { id: string; label: string }[],
405 repos: readonly { id: string; label: string }[],
406 receipt: { id: string; verb: string; done: number } | undefined,
407 withForm = true,
408 muted: readonly { id: string; label: string }[] = [],
409 ): Html {
410 return html`${muted.length === 0
411 ? null
412 : html`<form class="bar" method="post" action="/asks/listen">
413 ${muted.map((r) => html`<input type="hidden" name="repo" value="${r.id}" />`)}
414 <span class="muted small" title="${muted.map((r) => r.label).join(", ")}"
415 >${muted.length} ${muted.length === 1 ? "repository" : "repositories"} muted</span
416 >
417 <button type="submit" class="btn btn-sm btn-ghost">Listen again</button>
418 </form>`}
419 ${receipt === undefined
420 ? null
421 : html`<form class="bar" method="post" action="/asks/bulk/${receipt.id}/undo" role="status">
422 <span class="small">${VERB_WORDS[receipt.verb] ?? receipt.verb}: ${receipt.done} done</span>
423 ${btn("Undo", { size: "sm" })}
424 <span class="muted small">kept for a day</span>
425 </form>`}
426 ${withForm
427 ? html`<form id="onyou-bulk" method="post" action="/asks/bulk" class="bar" data-bulk>
428 <input type="hidden" name="apply" value="1" />
429 ${pickControls("onyou-bulk", kinds, repos)}
430 <select name="action" class="input" aria-label="What to do with the selected">
431 ${CARD_VERBS.map((v) => html`<option value="${v}">${VERB_WORDS[v]}</option>`)}
432 </select>
433 ${reasonInput()} ${btn("Do it", { size: "sm" })}
434 </form>`
435 : null}`;
436 }
437
293438 const shown = (v: FleetValue): string =>
294439 v === null ? "nobody" : Array.isArray(v) ? v.join(", ") : String(v);
295440
@@ -360,21 +505,40 @@ export async function asksBulkSubmit(ctx: Ctx): Promise<Response> {
360505 const form = await readForm(ctx, "rw");
361506 if (form instanceof Response) return form;
outdated

op.value.skipped and the rows that were not ok are dropped here. A non-admin closing or shipping from On you is told nothing. Carry a one-line "N not done: why" into the region you return.

362507 const action = field(form, "action");
508 const reason = field(form, "reason").trim();
363509 const items = form.getAll("item").filter((i): i is string => typeof i === "string");
364510 if (items.length === 0)
365511 return await asksScreen(ctx, nothingDone("select at least one ask first"));
366512 const verb = VERB_WORDS[action] ?? action;
367513 if (field(form, "apply") === "1") {
368 const op = await applyAsksBulk(ctx.app, ctx.auth.user, action, items);
369 if (!op.ok) return await asksScreen(ctx, nothingDone(op.error.message));
514 const op = await applyAsksBulk(ctx.app, ctx.auth.user, action, items, reason);
515 const inPlace = ctx.request.headers.get("x-forge-fragment") === "1";
516 // The script falls back to the plain post on a refusal, which says why.
517 if (!op.ok)
518 return inPlace
519 ? new Response(op.error.message, { status: 422 })
520 : await asksScreen(ctx, nothingDone(op.error.message));
521 // The page's own script asks for the card alone, and swaps it in place.
522 if (inPlace) {
523 const why = [
524 ...op.value.rows.filter((r) => r.outcome !== "ok").map((r) => r.reason),
525 ...op.value.skipped.map((s) => s.reason),
526 ];
527 const note =
528 why.length === 0
529 ? undefined
530 : `${why.length} not done: ${[...new Set(why.filter((w) => w !== ""))].join("; ") || "refused"}`;
531 return onYouAnswer(ctx, ctx.auth.user, note);
532 }
370533 return redirect(`/?op=${op.value.id}#asks-bulk-result`);
371534 }
372 const plan = previewAsksBulk(ctx.app, ctx.auth.user, action, items);
535 const plan = previewAsksBulk(ctx.app, ctx.auth.user, action, items, reason);
373536 if (!plan.ok) return await asksScreen(ctx, nothingDone(plan.error.message));
374537 const p = plan.value;
375538 const foot = html`<form method="post" action="/asks/bulk" class="bar">
376539 <input type="hidden" name="action" value="${action}" />
377540 <input type="hidden" name="apply" value="1" />
541 <input type="hidden" name="reason" value="${reason}" />
378542 ${items.map((i) => html`<input type="hidden" name="item" value="${i}" />`)}
379543 ${p.counts.change > 0 ? btn(`${verb} ${p.counts.change}`, { size: "sm", variant: "primary" }) : null}
380544 <span class="muted small"
@@ -386,6 +550,7 @@ export async function asksBulkSubmit(ctx: Ctx): Promise<Response> {
386550 banner: bulkCard(`${verb}: ${p.counts.change} of ${items.length}`, bulkRows(p.rows, p.skipped), foot),
387551 picked: new Set(items),
388552 action,
553 reason,
389554 });
390555 }
391556
@@ -401,3 +566,16 @@ export async function asksBulkUndo(ctx: Ctx): Promise<Response> {
401566 : await asksScreen(ctx, nothingDone(op.error.message));
402567 return redirect(`/?op=${op.value.id}#asks-bulk-result`);
403568 }
569
570 /** POST /asks/listen — take back the viewer's own mute of the repositories named. */
571 export async function asksListen(ctx: Ctx): Promise<Response> {
572 if (!ctx.auth.user) return signInFirst(ctx);
573 const form = await readForm(ctx, "rw");
574 if (form instanceof Response) return form;
575 const repos = form
576 .getAll("repo")
577 .filter((r): r is string => typeof r === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(r))
578 .slice(0, BULK_MAX);
579 removeMutes(ctx.app.db, ctx.auth.user.id, repos.map((r) => `repo:${r}`));
580 return redirect("/");
581 }
src/web/studio/home.ts +12 −3
@@ -27,7 +27,7 @@ import { repoBase, signInFirst } from "../chrome.ts";
2727 import { field, readForm, type Form } from "../form.ts";
2828 import { liveRegionsJs } from "../client.ts";
2929 import { html, when, type Html } from "../html.ts";
30 import { fragment, page, redirect } from "../layout.ts";
30 import { asksLink, fragment, page, redirect } from "../layout.ts";
3131 import { liveClientJs } from "../live.ts";
3232 import {
3333 btn,
@@ -55,7 +55,7 @@ import { spawnedOrg } from "../../spawn/claim.ts";
5555
5656 /** The events that can change what is on you. */
5757 const ON_YOU_LIVE =
58 "issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged";
58 "issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged,fleet.operated";
5959 /** The events that can change how an app on the rail is. */
6060 const APPS_LIVE = "app.serving,app.stopped,run.finished";
6161 /** How many of your apps the rail names before "All apps". */
@@ -125,12 +125,13 @@ const appsRegion = (ctx: Ctx, user: User): Html =>
125125 ${yourApps(ctx, user)}
126126 </div>`;
127127
128 const onYouRegion = (ctx: Ctx, user: User): Html =>
128 const onYouRegion = (ctx: Ctx, user: User, note?: string): Html =>
129129 html`<div
130130 id="live-on-you"
131131 data-live="${ON_YOU_LIVE}"
132132 data-live-src="/live/on-you"
133133 >
134 ${note === undefined ? null : html`<p class="small" role="status">${note}</p>`}
134135 ${onYouCard(ctx, user) ?? null}
135136 </div>`;
136137
@@ -141,6 +142,14 @@ export function liveAppsFragment(ctx: Ctx): Response {
141142 return fragment(appsRegion(ctx, user), { request: ctx.request });
142143 }
143144
145 /**
146 * What a bulk step on the On you card answers its own script with: the
147 * region as it is now and the top bar's Asks link with its new count.
148 */
149 export function onYouAnswer(ctx: Ctx, user: User, note?: string): Response {
150 return fragment(html`<div>${onYouRegion(ctx, user, note)}${asksLink(ctx)}</div>`);
151 }
152
144153 // GET /live/on-you — what is on the viewer, alone.
145154 export function liveOnYouFragment(ctx: Ctx): Response {
146155 const user = ctx.auth.user;
test/asks-bulk.test.ts +207 −2
@@ -18,7 +18,10 @@ import { addTeamMember, createOrg, createTeam } from "../src/actions/orgs.ts";
1818 import { openPull } from "../src/actions/pulls.ts";
1919 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
2020 import { getAgentByName, setCrewRole } from "../src/core/agents.ts";
21 import { getIssueById, type Issue } from "../src/core/issues.ts";
21 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
22 import { assignIssue } from "../src/actions/issues.ts";
23 import { mutedRepos, recordInteraction, removeMutes } from "../src/core/interactions.ts";
24 import { onYouCard } from "../src/web/pages/site.ts";
2225 import { getPull, type Pull } from "../src/core/pulls.ts";
2326 import type { Repo } from "../src/core/repos.ts";
2427 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
@@ -362,7 +365,7 @@ describe("acting on many asks at once", () => {
362365 expect(preview).toContain('name="apply" value="1"');
363366 // One screen: the preview sits on top of the asks, the picked ones still ticked.
364367 expect(preview).toContain('id="asks-sentence"');
365 expect((preview.match(/form="asks-bulk" checked/g) ?? []).length).toBe(picked.length);
368 expect((preview.match(/form="asks-bulk" data-repo="[^"]*" data-kind="[^"]*" checked/g) ?? []).length).toBe(picked.length);
366369 expect(items(alice).filter((i) => i.startsWith("look:"))).toHaveLength(
367370 picked.length,
368371 );
@@ -567,4 +570,206 @@ describe("acting on many asks at once", () => {
567570 );
568571 expect(getPull(app.db, quiet.id, pull.number)!.mergedAt).toBeNull();
569572 });
573
574 test("decline is dismiss under the asks' own word, and undoes the same", async () => {
575 const pull = await greenPull(open, "Decline me");
576 const ask = items(alice).filter((i) => i.endsWith(pull.id));
577 expect(ask).toHaveLength(1);
578 const done = await api(aliceRw, "/api/v1/asks/bulk", {
579 action: "decline",
580 items: ask,
581 preview: false,
582 });
583 expect(done.status).toBe(201);
584 expect(done.body.verb).toBe("dismiss");
585 expect(items(alice).filter((i) => i.endsWith(pull.id))).toHaveLength(0);
586 const undone = await api(aliceRw, `/api/v1/asks/bulk/${done.body.id}/undo`, {});
587 expect(undone.status).toBe(201);
588 expect(items(alice).filter((i) => i.endsWith(pull.id))).toHaveLength(1);
589 });
590
591 test("close many with one reason, written on each; undo opens them again", async () => {
592 const a = await greenPull(open, "Close one");
593 const b = await greenPull(quiet, "Close two");
594 const ask = items(alice).filter((i) => i.endsWith(a.id) || i.endsWith(b.id));
595 expect(ask).toHaveLength(2);
596 // A reason belongs to close alone, and is a sentence.
597 const odd = await api(aliceRw, "/api/v1/asks/bulk", {
598 action: "dismiss",
599 items: ask,
600 reason: "nope",
601 preview: false,
602 });
603 expect(odd.status).toBe(422);
604 const long = await api(aliceRw, "/api/v1/asks/bulk", {
605 action: "close",
606 items: ask,
607 reason: "x".repeat(301),
608 preview: false,
609 });
610 expect(long.status).toBe(422);
611 expect(getIssueById(app.db, a.id)!.state).toBe("open");
612 const done = await api(aliceRw, "/api/v1/asks/bulk", {
613 action: "close",
614 items: ask,
615 reason: "the week moved on",
616 preview: false,
617 });
618 expect(done.status).toBe(201);
619 expect(done.body.rows.map((r: { outcome: string }) => r.outcome)).toEqual(["ok", "ok"]);
620 for (const pull of [a, b]) {
621 expect(getIssueById(app.db, pull.id)!.state).toBe("closed");
622 const said = listComments(app.db, pull.id).map((c) => c.body);
623 expect(said).toContain("Closed: the week moved on");
624 }
625 const undone = await api(aliceRw, `/api/v1/asks/bulk/${done.body.id}/undo`, {});
626 expect(undone.status).toBe(201);
627 for (const pull of [a, b]) expect(getIssueById(app.db, pull.id)!.state).toBe("open");
628 });
629
630 test("mute silences a repository in On you, for the person alone; undo listens again", async () => {
631 const a = await greenPull(open, "On you one");
632 const b = await greenPull(open, "On you two");
633 for (const pull of [a, b]) {
634 const issue = getIssueById(app.db, pull.id)!;
635 expect(assignIssue(app, alice, open, issue, alice).ok).toBe(true);
636 }
637 const ctx = (user: User) => ({ app, auth: { user } }) as never;
638 const card = (user: User) => onYouCard(ctx(user), user)?.text ?? "";
639 expect(card(alice)).toContain("On you one");
640 // The card offers the set: a box each, the kinds, one form.
641 expect(card(alice)).toContain('form="onyou-bulk"');
642 expect(card(alice)).toContain('value="issue:' + a.id + '"');
643 expect(card(alice)).toContain("data-pick");
644 const done = await api(aliceRw, "/api/v1/asks/bulk", {
645 action: "mute",
646 items: [`issue:${a.id}`, `issue:${b.id}`],
647 preview: false,
648 });
649 expect(done.status).toBe(201);
650 expect(mutedRepos(app.db, alice.id).has(open.id)).toBe(true);
651 expect(mutedRepos(app.db, bob.id).has(open.id)).toBe(false);
652 expect(card(alice)).not.toContain("On you one");
653 const undone = await api(aliceRw, `/api/v1/asks/bulk/${done.body.id}/undo`, {});
654 expect(undone.status).toBe(201);
655 expect(mutedRepos(app.db, alice.id).has(open.id)).toBe(false);
656 expect(card(alice)).toContain("On you one");
657 // Only what is assigned to the viewer is theirs to name this way.
658 const other = await api(bobRw, "/api/v1/asks/bulk", {
659 action: "mute",
660 items: [`issue:${a.id}`],
661 preview: false,
662 });
663 expect(other.body.rows).toHaveLength(0);
664 expect(other.body.skipped).toHaveLength(1);
665 });
666
667 test("the card's one request answers its script with the region and the count; one undo link stays for a day", async () => {
668 const a = await greenPull(open, "Card one");
669 const b = await greenPull(open, "Card two");
670 for (const pull of [a, b])
671 expect(assignIssue(app, alice, open, getIssueById(app.db, pull.id)!, alice).ok).toBe(true);
672 const headers = { cookie: `${aliceCookie}; forge_mode=studio`, origin: servers.url, "x-forge-fragment": "1" };
673 const res = await fetch(`${servers.url}/asks/bulk`, {
674 method: "POST",
675 headers,
676 body: new URLSearchParams([
677 ["action", "close"],
678 ["reason", "not this week"],
679 ["apply", "1"],
680 ["item", `issue:${a.id}`],
681 ["item", `issue:${b.id}`],
682 ]),
683 });
684 expect(res.status).toBe(200);
685 const text = await res.text();
686 expect(text).toContain('id="live-on-you"');
687 expect(text).toContain('id="asks-link"');
688 expect(text).not.toContain("Card one");
689 expect(getIssueById(app.db, a.id)!.state).toBe("closed");
690 // The undo link rides on the card, and is the same one after a refresh.
691 const again = await fetch(`${servers.url}/live/on-you`, { headers }).then((r) => r.text());
692 const undo = /action="(\/asks\/bulk\/[^"]+\/undo)"/.exec(again)?.[1];
693 expect(undo).toBeDefined();
694 const id = undo!.split("/")[3]!;
695 // A day later the link is gone and the undo is refused.
696 app.db.query("UPDATE fleet_operation SET created_at = ?1 WHERE id = ?2").run(
697 new Date(Date.now() - 86_400_000 - 60_000).toISOString(),
698 id,
699 );
700 const late = await fetch(`${servers.url}/live/on-you`, { headers }).then((r) => r.text());
701 expect(late).not.toContain("/undo");
702 const refused = await api(aliceRw, `/api/v1/asks/bulk/${id}/undo`, {});
703 expect(refused.status).toBe(409);
704 expect(getIssueById(app.db, a.id)!.state).toBe("closed");
705 // The same day on the other doors: the fleet route refuses too.
706 const admin = createToken(app.db, alice.id, "admin", "admin").token;
707 const fleet = await api(admin, `/api/v1/fleet/ops/${id}/undo`, {});
708 expect(fleet.status).toBe(409);
709 expect(getIssueById(app.db, a.id)!.state).toBe("closed");
710 });
711
712 test("a muted repository is named on the card, and one request listens again", async () => {
713 const a = await greenPull(open, "Muted one");
714 const b = await greenPull(open, "Muted two");
715 for (const pull of [a, b])
716 expect(assignIssue(app, alice, open, getIssueById(app.db, pull.id)!, alice).ok).toBe(true);
717 const card = () => onYouCard({ app, auth: { user: alice } } as never, alice)?.text ?? "";
718 expect(card()).not.toContain("muted");
719 const done = await api(aliceRw, "/api/v1/asks/bulk", {
720 action: "mute",
721 items: [`issue:${a.id}`, `issue:${b.id}`],
722 preview: false,
723 });
724 expect(done.status).toBe(201);
725 // Past the day's undo, the card still says it, and offers the way back.
726 expect(card()).toContain("1 repository muted");
727 expect(card()).toContain('action="/asks/listen"');
728 const headers = { cookie: `${aliceCookie}; forge_mode=studio`, origin: servers.url };
729 const res = await fetch(`${servers.url}/asks/listen`, {
730 method: "POST",
731 headers,
732 body: new URLSearchParams([["repo", open.id]]),
733 redirect: "manual",
734 });
735 expect(res.status).toBe(303);
736 expect(mutedRepos(app.db, alice.id).has(open.id)).toBe(false);
737 expect(card()).toContain("Muted one");
738 expect(card()).not.toContain("repository muted");
739 });
740 test("on the card, a refusal answers 422 with its message, and a partial set says what was not done", async () => {
741 const a = await greenPull(open, "Refuse one");
742 expect(assignIssue(app, alice, open, getIssueById(app.db, a.id)!, bob).ok).toBe(true);
743 const signed = await fetch(`${servers.url}/signin`, {
744 method: "POST",
745 body: new URLSearchParams({ username: "bob", password: PASSWORD }),
746 redirect: "manual",
747 });
748 const bobCookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!;
749 const post = (cookie: string, action: string, reason: string, item: string) =>
750 fetch(`${servers.url}/asks/bulk`, {
751 method: "POST",
752 headers: { cookie: `${cookie}; forge_mode=studio`, origin: servers.url, "x-forge-fragment": "1" },
753 body: new URLSearchParams([["action", action], ["reason", reason], ["apply", "1"], ["item", item]]),
754 });
755 // A reason belongs to close alone: the script gets an error, not a page.
756 const odd = await post(aliceCookie, "mute", "because", `issue:${a.id}`);
757 expect(odd.status).toBe(422);
758 expect(await odd.text()).toContain("reason");
759 // Bob cannot close it: nothing changes, and the card says why.
760 const res = await post(bobCookie, "close", "", `issue:${a.id}`);
761 const text = res.status === 200 ? await res.text() : "";
762 expect(res.status === 422 || /not done/.test(text)).toBe(true);
763 expect(getIssueById(app.db, a.id)!.state).toBe("open");
764 });
765
766 test("undoing a bulk mute leaves a mute made elsewhere", () => {
767 const item = `repo:${quiet.id}`;
768 recordInteraction(app.db, { viewerId: alice.id, itemId: item, kind: "muted", repoId: quiet.id, actorId: null });
769 recordInteraction(app.db, { viewerId: alice.id, itemId: "event:x", kind: "muted", repoId: quiet.id, actorId: null });
770 removeMutes(app.db, alice.id, [item]);
771 expect(mutedRepos(app.db, alice.id).has(quiet.id)).toBe(true);
772 removeMutes(app.db, alice.id, ["event:x"]);
773 expect(mutedRepos(app.db, alice.id).has(quiet.id)).toBe(false);
774 });
570775 });
test/home-feed.test.ts +29 −1
@@ -11,7 +11,7 @@ import { mkdtemp, rm } from "node:fs/promises";
1111 import { tmpdir } from "node:os";
1212 import { join } from "node:path";
1313 import { createApp, type App } from "../src/app.ts";
14 import { addComment, openIssue } from "../src/actions/issues.ts";
14 import { addComment, assignIssue, openIssue } from "../src/actions/issues.ts";
1515 import { createRepo } from "../src/actions/repos.ts";
1616 import { createApp as insertApp } from "../src/core/apps.ts";
1717 import { listIssues } from "../src/core/issues.ts";
@@ -268,4 +268,32 @@ describe("home is the feed", () => {
268268 const second = await call({ limit: 2, cursor: first.next_cursor });
269269 expect(second.items.map((i: any) => i.id)).toEqual(whole.slice(2, 4));
270270 });
271
272 test("On you can be cleared in one move: a box each, select all, by kind and by repository, one form", async () => {
273 const one = openIssue(app, alice, garden, { title: "Sweep the path" });
274 const two = openIssue(app, alice, vault, { title: "Oil the hinges" });
275 if (!one.ok || !two.ok) throw new Error("issues");
276 for (const [repo, issue] of [[garden, one.value], [vault, two.value]] as const)
277 expect(assignIssue(app, alice, repo, issue, alice).ok).toBe(true);
278 const text = await (await get("/", aliceCookie)).text();
279 const card = text.slice(text.indexOf('id="live-on-you"')).replace(/\s+/g, " ");
280 expect(card).toContain("On you");
281 expect(card).toContain(`value="issue:${one.value.id}"`);
282 expect(card).toContain(`value="issue:${two.value.id}"`);
283 expect((card.match(/form="onyou-bulk"/g) ?? []).length).toBeGreaterThanOrEqual(2);
284 expect(card).toContain('data-pick="all"');
285 expect(card).toContain("data-pick-repo");
286 expect(card).toContain('<option value="' + garden.id + '">alice/garden</option>');
287 expect(card).toContain('id="onyou-bulk"');
288 expect(card).toContain('action="/asks/bulk"');
289 expect(card).toContain('name="reason"');
290 expect(card).toContain(">Mute repository<");
291 // The live region follows the operation, and the page ships the script.
292 expect(text).toContain("fleet.operated");
293 expect(text).toContain("data-pick");
294 expect(text).toContain('id="asks-link"');
295 // Alone, a single row stays a plain link.
296 const bobCard = await (await get("/", bobCookie)).text();
297 expect(bobCard).not.toContain('id="onyou-bulk"');
298 });
271299 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs