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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1111-clear-what-is-on-you-in-one-move-close-d → main 3 commits fork 7 threads · 7 unresolved
docs/FEED.md +13
| @@ -122,3 +122,16 @@ may not see the item are both `unreachable`. | ||
| 122 | 122 | - **Sender weight:** `2·(opens+1)/(shares+2)` over 90 days of delivered |
| 123 | 123 | shares to people, clamped 0.2–2; an open is the recipient's `opened` of the |
| 124 | 124 | 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 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | 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"; | |
| 13 | 13 | import type { User } from "../core/users.ts"; |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | 15 | import { askTargetsFor } from "../web/studio/asks.ts"; |
| 16 | 16 | import { |
| 17 | 17 | applyFleetIds, |
| 18 | 18 | getFleetOp, |
| 19 | isAsksOperation, | |
| 20 | UNDO_WINDOW_MS, | |
| 19 | 21 | previewFleetIds, |
| 20 | 22 | undoFleetOp, |
| 21 | 23 | type FleetError, |
| 22 | 24 | type Plan, |
| 23 | 25 | } from "./fleet.ts"; |
| 24 | 26 | |
| 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 | */ | |
| 26 | 32 | export const ASK_ACTIONS: Readonly<Record<string, string>> = { |
| 27 | 33 | dismiss: "dismiss", |
| 34 | decline: "dismiss", | |
| 35 | mute: "mute", | |
| 28 | 36 | ship: "ship", |
| 29 | 37 | close: "close", |
| 30 | 38 | replan: "replan", |
| @@ -43,8 +51,9 @@ function resolve( | ||
| 43 | 51 | actor: User | undefined, |
| 44 | 52 | action: unknown, |
| 45 | 53 | items: unknown, |
| 54 | reason: unknown, | |
| 46 | 55 | ): Result< |
| 47 | { verb: string; ids: string[]; skipped: Skipped[]; query: string }, | |
| 56 | { verb: unknown; ids: string[]; skipped: Skipped[]; query: string }, | |
| 48 | 57 | FleetError |
| 49 | 58 | > { |
| 50 | 59 | if (!actor) |
| @@ -71,9 +80,15 @@ function resolve( | ||
| 71 | 80 | code: "too_many", |
| 72 | 81 | message: `at most ${BULK_MAX} asks at once`, |
| 73 | 82 | }); |
| 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" }); | |
| 74 | 87 | const { issueIds, refused } = askTargetsFor(app.db, actor, list); |
| 75 | 88 | return ok({ |
| 76 | verb, | |
| 89 | verb: verb === "close" && typeof reason === "string" && reason.trim() !== "" | |
| 90 | ? { verb, value: reason } | |
| 91 | : verb, | |
| 77 | 92 | ids: issueIds, |
| 78 | 93 | skipped: refused, |
| 79 | 94 | query: `asks: ${list.length} selected`, |
| @@ -86,8 +101,9 @@ export function previewAsksBulk( | ||
| 86 | 101 | actor: User | undefined, |
| 87 | 102 | action: unknown, |
| 88 | 103 | items: unknown, |
| 104 | reason?: unknown, | |
| 89 | 105 | ): Result<Plan & { skipped: Skipped[] }, FleetError> { |
| 90 | const r = resolve(app, actor, action, items); | |
| 106 | const r = resolve(app, actor, action, items, reason); | |
| 91 | 107 | if (!r.ok) return r; |
| 92 | 108 | const plan = previewFleetIds( |
| 93 | 109 | app, |
| @@ -107,8 +123,9 @@ export async function applyAsksBulk( | ||
| 107 | 123 | actor: User | undefined, |
| 108 | 124 | action: unknown, |
| 109 | 125 | items: unknown, |
| 126 | reason?: unknown, | |
| 110 | 127 | ): Promise<Result<Operation & { skipped: Skipped[] }, FleetError>> { |
| 111 | const r = resolve(app, actor, action, items); | |
| 128 | const r = resolve(app, actor, action, items, reason); | |
| 112 | 129 | if (!r.ok) return r; |
| 113 | 130 | const op = await applyFleetIds( |
| 114 | 131 | app, |
| @@ -122,15 +139,6 @@ export async function applyAsksBulk( | ||
| 122 | 139 | return ok({ ...op.value, skipped: r.value.skipped }); |
| 123 | 140 | } |
| 124 | 141 | |
| 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 | ||
| 134 | 142 | /** |
| 135 | 143 | * A bulk action on asks, for the one person who took it — never another, |
| 136 | 144 | * a site admin included: a person's asks are theirs, and so is undoing |
| @@ -150,7 +158,31 @@ export function ownAsksOperation( | ||
| 150 | 158 | : undefined; |
| 151 | 159 | } |
| 152 | 160 | |
| 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). */ | |
| 154 | 186 | export async function undoAsksBulk( |
| 155 | 187 | app: App, |
| 156 | 188 | actor: User | undefined, |
src/actions/fleet.ts +19
| @@ -45,6 +45,7 @@ import { | ||
| 45 | 45 | import { latestRunsForIssues } from "../core/runs.ts"; |
| 46 | 46 | import { isStalePull } from "../crew/stale_asks.ts"; |
| 47 | 47 | import { askDismissedBy } from "../web/studio/asks.ts"; |
| 48 | import { mutedRepos } from "../core/interactions.ts"; | |
| 48 | 49 | import { getRunsByIds } from "../core/runs.ts"; |
| 49 | 50 | import type { User } from "../core/users.ts"; |
| 50 | 51 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -63,6 +64,7 @@ export interface FleetError { | ||
| 63 | 64 | | "not_found" |
| 64 | 65 | | "bad_query" |
| 65 | 66 | | "too_many" |
| 67 | | "expired" | |
| 66 | 68 | | "bad_action" |
| 67 | 69 | | "not_reversible" |
| 68 | 70 | | "already_undone"; |
| @@ -179,6 +181,7 @@ function loadTargets( | ||
| 179 | 181 | for (const [repoId, repo] of repos) pillars.set(repoId, pillarsOf(app.db, repo)); |
| 180 | 182 | const under = umbrellasOver(app, ids, [...pillars.values()]); |
| 181 | 183 | const peers: ReadonlySet<string> = new Set(ids); |
| 184 | const muted = viewer === undefined ? new Set<string>() : mutedRepos(app.db, viewer.id); | |
| 182 | 185 | const pull = (id: string) => { |
| 183 | 186 | const p = pulls.get(id); |
| 184 | 187 | return p === undefined |
| @@ -213,6 +216,7 @@ function loadTargets( | ||
| 213 | 216 | : { |
| 214 | 217 | viewerId: viewer.id, |
| 215 | 218 | askDismissed: askDismissedBy(app.db, viewer.id, issue), |
| 219 | muted: muted.has(repo.id), | |
| 216 | 220 | }), |
| 217 | 221 | ...(pull(id) ?? {}), |
| 218 | 222 | }, |
| @@ -538,6 +542,18 @@ export async function applyFleetOp( | ||
| 538 | 542 | return ok(await carryOutAndRecord(app, person.value, plan.value)); |
| 539 | 543 | } |
| 540 | 544 | |
| 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 | ||
| 541 | 557 | /** |
| 542 | 558 | * Undo: every row this operation changed goes back to its value from |
| 543 | 559 | * before, if it still holds the value this operation left. Once per |
| @@ -565,6 +581,9 @@ export async function undoFleetOp( | ||
| 565 | 581 | (verb.personal || !parseQuery(op.query).ok) |
| 566 | 582 | ) |
| 567 | 583 | 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"); | |
| 568 | 587 | if (!claimUndo(app.db, op.id)) |
| 569 | 588 | return fail("already_undone", "this operation was undone already"); |
| 570 | 589 | let undone: Operation | undefined; |
src/core/interactions.ts +16
| @@ -186,3 +186,19 @@ export function removeDismissals( | ||
| 186 | 186 | AND item_id IN (SELECT value FROM json_each(?2))`, |
| 187 | 187 | ).run(viewerId, JSON.stringify(itemIds)); |
| 188 | 188 | } |
| 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"; | ||
| 24 | 24 | import { mergePull } from "../actions/pulls.ts"; |
| 25 | 25 | import { bringUpToDate } from "../crew/autonomy.ts"; |
| 26 | 26 | import { getHold } from "../core/holds.ts"; |
| 27 | import { recordInteraction, removeDismissals } from "../core/interactions.ts"; | |
| 27 | import { recordInteraction, removeDismissals, removeMutes } from "../core/interactions.ts"; | |
| 28 | 28 | import { getPull } from "../core/pulls.ts"; |
| 29 | 29 | import { askItemsOf } from "../web/studio/asks.ts"; |
| 30 | 30 | import type { FleetValue } from "../core/fleet_ops.ts"; |
| @@ -59,6 +59,8 @@ export interface Target { | ||
| 59 | 59 | readonly viewerId?: string; |
| 60 | 60 | /** For an issue: whether the viewer's dismissal of its ask still holds. */ |
| 61 | 61 | readonly askDismissed?: boolean; |
| 62 | /** Whether the viewer muted this repository. */ | |
| 63 | readonly muted?: boolean; | |
| 62 | 64 | /** For a pull: merged, its latest check green, and whether it merges as it stands. */ |
| 63 | 65 | readonly merged?: boolean; |
| 64 | 66 | readonly green?: boolean; |
| @@ -328,17 +330,54 @@ export const VERBS: Record<string, Verb> = { | ||
| 328 | 330 | done(requeueRepoRun(app, actor, t.repo, t.id)), |
| 329 | 331 | }, |
| 330 | 332 | |
| 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. | |
| 331 | 335 | close: { |
| 332 | 336 | kind: "issue", |
| 333 | 337 | 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"), | |
| 335 | 344 | read: (t) => t.issue?.state ?? null, |
| 336 | 345 | next: () => "closed", |
| 337 | 346 | 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) { | |
| 339 | 348 | if (!t.issue || (after !== "open" && after !== "closed")) |
| 340 | 349 | return words("not_found"); |
| 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
|
||
| 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; | |
| 342 | 381 | }, |
| 343 | 382 | }, |
| 344 | 383 |
src/http/api.ts +1 −1
| @@ -371,7 +371,7 @@ function respondFleet(error: FleetError): Response { | ||
| 371 | 371 | if (error.code === "unauthorized") return unauthorized(); |
| 372 | 372 | return apiError( |
| 373 | 373 | 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, | |
| 375 | 375 | error.message, |
| 376 | 376 | ); |
| 377 | 377 | } |
src/http/feed.ts +3 −3
| @@ -82,11 +82,11 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 82 | 82 | const denied = requireScope(ctx, preview ? "ro" : "rw"); |
| 83 | 83 | if (denied) return denied; |
| 84 | 84 | 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); | |
| 86 | 86 | if (!plan.ok) return bulkError(plan.error); |
| 87 | 87 | return json({ preview: true, ...plan.value }); |
| 88 | 88 | } |
| 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); | |
| 90 | 90 | if (!op.ok) return bulkError(op.error); |
| 91 | 91 | return json({ preview: false, ...op.value }, 201); |
| 92 | 92 | }); |
| @@ -211,7 +211,7 @@ function bulkError(error: FleetError): Response { | ||
| 211 | 211 | if (error.code === "unauthorized") return unauthorized(); |
| 212 | 212 | return apiError( |
| 213 | 213 | 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, | |
| 215 | 215 | error.message, |
| 216 | 216 | ); |
| 217 | 217 | } |
src/http/mcp.ts +5 −4
| @@ -415,12 +415,13 @@ const TOOLS: readonly Tool[] = [ | ||
| 415 | 415 | { |
| 416 | 416 | name: "asks_act", |
| 417 | 417 | 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.", | |
| 419 | 419 | inputSchema: { |
| 420 | 420 | type: "object", |
| 421 | 421 | properties: { |
| 422 | action: { type: "string", enum: ["dismiss", "ship", "close", "replan"] }, | |
| 422 | action: { type: "string", enum: ["dismiss", "decline", "mute", "ship", "close", "replan"] }, | |
| 423 | 423 | items: { type: "array", items: { type: "string" }, maxItems: 100 }, |
| 424 | reason: { type: "string", maxLength: 300 }, | |
| 424 | 425 | dryRun: { type: "boolean" }, |
| 425 | 426 | }, |
| 426 | 427 | required: ["action", "items"], |
| @@ -430,8 +431,8 @@ const TOOLS: readonly Tool[] = [ | ||
| 430 | 431 | async run(ctx, user, args) { |
| 431 | 432 | const answer = |
| 432 | 433 | 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"]); | |
| 435 | 436 | return answer.ok ? good(answer.value) : bad(answer.error.code, answer.error.message); |
| 436 | 437 | }, |
| 437 | 438 | }, |
src/http/server.ts +2
| @@ -182,6 +182,7 @@ import { askedPage } from "../web/studio/asked_page.ts"; | ||
| 182 | 182 | import { |
| 183 | 183 | asksBulkSubmit, |
| 184 | 184 | asksBulkUndo, |
| 185 | asksListen, | |
| 185 | 186 | asksPage, |
| 186 | 187 | } from "../web/studio/asks_page.ts"; |
| 187 | 188 | import { liveFeedFragment } from "../web/studio/feed.ts"; |
| @@ -560,6 +561,7 @@ export function buildRouter(): Router<Handler> { | ||
| 560 | 561 | router.add("GET", "/asks", asksPage); |
| 561 | 562 | router.add("POST", "/asks/bulk", asksBulkSubmit); |
| 562 | 563 | router.add("POST", "/asks/bulk/:id/undo", asksBulkUndo); |
| 564 | router.add("POST", "/asks/listen", asksListen); | |
| 563 | 565 | // What people asked of the crew, and what became of it (core/asked.ts). |
| 564 | 566 | router.add("GET", "/asked", askedPage); |
| 565 | 567 | // 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 { | ||
| 152 | 152 | * The way to what needs you, with how many things ask the viewer — the very |
| 153 | 153 | * count the home's pinned section leads with (`askCountFor`). |
| 154 | 154 | */ |
| 155 | function asksLink(ctx: Ctx): Html { | |
| 155 | export function asksLink(ctx: Ctx): Html { | |
| 156 | 156 | if (!ctx.auth.user) return html``; |
| 157 | 157 | const count = askCountFor(ctx, ctx.auth.user); |
| 158 | 158 | return html`<a |
| 159 | id="asks-link" | |
| 159 | 160 | class="btn" |
| 160 | 161 | data-size="sm" |
| 161 | 162 | href="/#needs-you" |
src/web/pages/site.ts +64 −15
| @@ -29,7 +29,9 @@ import { hasAnyUser, verifyPassword, type User } from "../../core/users.ts"; | ||
| 29 | 29 | import { isThemeName } from "../../design/themes.ts"; |
| 30 | 30 | import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts"; |
| 31 | 31 | 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"; | |
| 33 | 35 | import { edgeLoginUrl } from "../../http/edge.ts"; |
| 34 | 36 | import { sshCommand } from "../../ssh/command.ts"; |
| 35 | 37 | import { hostOf, pageHead, signInFirst } from "../chrome.ts"; |
| @@ -182,29 +184,76 @@ export function onYouCard(ctx: Ctx, user: User): Html | undefined { | ||
| 182 | 184 | const visible = new Set( |
| 183 | 185 | listReposVisibleTo(db, user, { limit: 200 }).map((repo) => repo.id), |
| 184 | 186 | ); |
| 185 | const rows: Html[] = []; | |
| 187 | const muted = mutedRepos(db, user.id); | |
The web card filters muted repositories here; the terminal's On you ( |
||
| 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>(); | |
| 186 | 191 | for (const issue of assigned) { |
| 187 | 192 | const repo = repos.get(issue.repoId); |
| 188 | 193 | if (!repo) continue; |
| 189 | 194 | if (!visible.has(repo.id) && !canRead(db, repo, user)) continue; |
| 190 | 195 | 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 }); | |
| 203 | 201 | } |
| 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 | ); | |
| 205 | 237 | return listCard(rows, { |
| 206 | 238 | head: html`${icon("user")} |
| 207 | 239 | <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 | : {}), | |
| 208 | 257 | }); |
| 209 | 258 | } |
| 210 | 259 |
src/web/studio/asks.ts +9 −1
| @@ -19,6 +19,7 @@ import { askMeasure, syncAskLedger, type AskEnd } from "../../core/ask_ledger.ts | ||
| 19 | 19 | import { |
| 20 | 20 | getIssueById, |
| 21 | 21 | labelsForIssues, |
| 22 | listAssignedTo, | |
| 22 | 23 | listComments, |
| 23 | 24 | listOpenPullsVisibleTo, |
| 24 | 25 | type Issue, |
| @@ -1045,9 +1046,16 @@ export function askTargetsFor( | ||
| 1045 | 1046 | ); |
| 1046 | 1047 | const issueIds: string[] = []; |
| 1047 | 1048 | 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 | ); | |
| 1048 | 1054 | for (const item of new Set(items)) { |
| 1049 | 1055 | 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" }); | |
| 1051 | 1059 | else if (ask.kind === "paused") |
| 1052 | 1060 | refused.push({ item, reason: "a ceiling is raised in the app's settings" }); |
| 1053 | 1061 | else if (ask.kind === "declined") |
src/web/studio/asks_page.ts +192 −14
| @@ -9,15 +9,17 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { |
| 11 | 11 | applyAsksBulk, |
| 12 | BULK_MAX, | |
| 12 | 13 | ownAsksOperation, |
| 13 | 14 | undoAsksBulk, |
| 14 | 15 | previewAsksBulk, |
| 15 | 16 | type Skipped, |
| 16 | 17 | } from "../../actions/asks_bulk.ts"; |
| 17 | 18 | import type { FleetValue } from "../../core/fleet_ops.ts"; |
| 19 | import { removeMutes } from "../../core/interactions.ts"; | |
| 18 | 20 | import type { Ctx } from "../../http/context.ts"; |
| 19 | 21 | import type { User } from "../../core/users.ts"; |
| 20 | import { studioHome } from "./home.ts"; | |
| 22 | import { onYouAnswer, studioHome } from "./home.ts"; | |
| 21 | 23 | import { notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 22 | 24 | import { field, readForm } from "../form.ts"; |
| 23 | 25 | import { listReposVisibleTo } from "../../core/repos.ts"; |
| @@ -40,6 +42,14 @@ import { | ||
| 40 | 42 | } from "./asks.ts"; |
| 41 | 43 | import { changeHref } from "./nouns.ts"; |
| 42 | 44 | |
| 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 | ||
| 43 | 53 | /** The kinds a bulk action takes: a ceiling and a planner's decline are decided where they live. */ |
| 44 | 54 | const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "stopped", "parked"]); |
| 45 | 55 | |
| @@ -52,17 +62,62 @@ const pick = (d: Decision, picked: ReadonlySet<string>): Html | null => | ||
| 52 | 62 | name="item" |
| 53 | 63 | value="${d.id}" |
| 54 | 64 | form="asks-bulk" |
| 65 | data-repo="${d.repo.id}" | |
| 66 | data-kind="${d.kind}" | |
| 55 | 67 | ${picked.has(d.id) ? html`checked` : null} |
| 56 | 68 | aria-label="Select ${d.pull?.title ?? d.issue?.title ?? d.id}" |
| 57 | 69 | />` |
| 58 | 70 | : null; |
| 59 | 71 | |
| 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) { | |
| 62 | 81 | 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'); }); | |
| 66 | 121 | });`; |
| 67 | 122 | |
| 68 | 123 | /** The rows shown before "N more": the few that matter most. */ |
| @@ -184,6 +239,7 @@ export interface AsksTop { | ||
| 184 | 239 | /** The asks the preview is about, kept ticked. */ |
| 185 | 240 | readonly picked?: ReadonlySet<string>; |
| 186 | 241 | readonly action?: string; |
| 242 | readonly reason?: string; | |
| 187 | 243 | } |
| 188 | 244 | |
| 189 | 245 | /** GET /asks — the home, scrolled to what needs you; `?op=<id>` (a bulk |
| @@ -259,18 +315,23 @@ export function needsYouSection( | ||
| 259 | 315 | ${pickable < 2 |
| 260 | 316 | ? null |
| 261 | 317 | : 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 | )} | |
| 262 | 323 | <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>`, | |
| 266 | 327 | )} |
| 267 | 328 | </select> |
| 268 | ${btn("Preview", { size: "sm" })} | |
| 329 | ${reasonInput(top?.reason)} ${btn("Preview", { size: "sm" })} | |
| 269 | 330 | <span class="muted small">on the selected asks</span> |
| 270 | 331 | </form>`} |
| 271 | 332 | ${decisions.map((d) => decisionForm(d))} |
| 272 | 333 | </section>`; |
| 273 | return { body, scriptJs: pickable < 2 ? "" : SELECT_ALL_JS }; | |
| 334 | return { body, scriptJs: SELECT_JS }; | |
| 274 | 335 | } |
| 275 | 336 | |
| 276 | 337 | /** 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> { | ||
| 285 | 346 | |
| 286 | 347 | const VERB_WORDS: Readonly<Record<string, string>> = { |
| 287 | 348 | dismiss: "Dismiss", |
| 349 | decline: "Decline", | |
| 350 | mute: "Mute repository", | |
| 288 | 351 | ship: "Ship", |
| 289 | 352 | close: "Close", |
| 290 | 353 | replan: "Hand back to the planner", |
| 291 | 354 | }; |
| 292 | 355 | |
| 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 | ||
| 293 | 438 | const shown = (v: FleetValue): string => |
| 294 | 439 | v === null ? "nobody" : Array.isArray(v) ? v.join(", ") : String(v); |
| 295 | 440 | |
| @@ -360,21 +505,40 @@ export async function asksBulkSubmit(ctx: Ctx): Promise<Response> { | ||
| 360 | 505 | const form = await readForm(ctx, "rw"); |
| 361 | 506 | if (form instanceof Response) return form; |
outdated
|
||
| 362 | 507 | const action = field(form, "action"); |
| 508 | const reason = field(form, "reason").trim(); | |
| 363 | 509 | const items = form.getAll("item").filter((i): i is string => typeof i === "string"); |
| 364 | 510 | if (items.length === 0) |
| 365 | 511 | return await asksScreen(ctx, nothingDone("select at least one ask first")); |
| 366 | 512 | const verb = VERB_WORDS[action] ?? action; |
| 367 | 513 | 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 | } | |
| 370 | 533 | return redirect(`/?op=${op.value.id}#asks-bulk-result`); |
| 371 | 534 | } |
| 372 | const plan = previewAsksBulk(ctx.app, ctx.auth.user, action, items); | |
| 535 | const plan = previewAsksBulk(ctx.app, ctx.auth.user, action, items, reason); | |
| 373 | 536 | if (!plan.ok) return await asksScreen(ctx, nothingDone(plan.error.message)); |
| 374 | 537 | const p = plan.value; |
| 375 | 538 | const foot = html`<form method="post" action="/asks/bulk" class="bar"> |
| 376 | 539 | <input type="hidden" name="action" value="${action}" /> |
| 377 | 540 | <input type="hidden" name="apply" value="1" /> |
| 541 | <input type="hidden" name="reason" value="${reason}" /> | |
| 378 | 542 | ${items.map((i) => html`<input type="hidden" name="item" value="${i}" />`)} |
| 379 | 543 | ${p.counts.change > 0 ? btn(`${verb} ${p.counts.change}`, { size: "sm", variant: "primary" }) : null} |
| 380 | 544 | <span class="muted small" |
| @@ -386,6 +550,7 @@ export async function asksBulkSubmit(ctx: Ctx): Promise<Response> { | ||
| 386 | 550 | banner: bulkCard(`${verb}: ${p.counts.change} of ${items.length}`, bulkRows(p.rows, p.skipped), foot), |
| 387 | 551 | picked: new Set(items), |
| 388 | 552 | action, |
| 553 | reason, | |
| 389 | 554 | }); |
| 390 | 555 | } |
| 391 | 556 | |
| @@ -401,3 +566,16 @@ export async function asksBulkUndo(ctx: Ctx): Promise<Response> { | ||
| 401 | 566 | : await asksScreen(ctx, nothingDone(op.error.message)); |
| 402 | 567 | return redirect(`/?op=${op.value.id}#asks-bulk-result`); |
| 403 | 568 | } |
| 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"; | ||
| 27 | 27 | import { field, readForm, type Form } from "../form.ts"; |
| 28 | 28 | import { liveRegionsJs } from "../client.ts"; |
| 29 | 29 | 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"; | |
| 31 | 31 | import { liveClientJs } from "../live.ts"; |
| 32 | 32 | import { |
| 33 | 33 | btn, |
| @@ -55,7 +55,7 @@ import { spawnedOrg } from "../../spawn/claim.ts"; | ||
| 55 | 55 | |
| 56 | 56 | /** The events that can change what is on you. */ |
| 57 | 57 | 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"; | |
| 59 | 59 | /** The events that can change how an app on the rail is. */ |
| 60 | 60 | const APPS_LIVE = "app.serving,app.stopped,run.finished"; |
| 61 | 61 | /** How many of your apps the rail names before "All apps". */ |
| @@ -125,12 +125,13 @@ const appsRegion = (ctx: Ctx, user: User): Html => | ||
| 125 | 125 | ${yourApps(ctx, user)} |
| 126 | 126 | </div>`; |
| 127 | 127 | |
| 128 | const onYouRegion = (ctx: Ctx, user: User): Html => | |
| 128 | const onYouRegion = (ctx: Ctx, user: User, note?: string): Html => | |
| 129 | 129 | html`<div |
| 130 | 130 | id="live-on-you" |
| 131 | 131 | data-live="${ON_YOU_LIVE}" |
| 132 | 132 | data-live-src="/live/on-you" |
| 133 | 133 | > |
| 134 | ${note === undefined ? null : html`<p class="small" role="status">${note}</p>`} | |
| 134 | 135 | ${onYouCard(ctx, user) ?? null} |
| 135 | 136 | </div>`; |
| 136 | 137 | |
| @@ -141,6 +142,14 @@ export function liveAppsFragment(ctx: Ctx): Response { | ||
| 141 | 142 | return fragment(appsRegion(ctx, user), { request: ctx.request }); |
| 142 | 143 | } |
| 143 | 144 | |
| 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 | ||
| 144 | 153 | // GET /live/on-you — what is on the viewer, alone. |
| 145 | 154 | export function liveOnYouFragment(ctx: Ctx): Response { |
| 146 | 155 | 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"; | ||
| 18 | 18 | import { openPull } from "../src/actions/pulls.ts"; |
| 19 | 19 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 20 | 20 | 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"; | |
| 22 | 25 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 23 | 26 | import type { Repo } from "../src/core/repos.ts"; |
| 24 | 27 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| @@ -362,7 +365,7 @@ describe("acting on many asks at once", () => { | ||
| 362 | 365 | expect(preview).toContain('name="apply" value="1"'); |
| 363 | 366 | // One screen: the preview sits on top of the asks, the picked ones still ticked. |
| 364 | 367 | 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); | |
| 366 | 369 | expect(items(alice).filter((i) => i.startsWith("look:"))).toHaveLength( |
| 367 | 370 | picked.length, |
| 368 | 371 | ); |
| @@ -567,4 +570,206 @@ describe("acting on many asks at once", () => { | ||
| 567 | 570 | ); |
| 568 | 571 | expect(getPull(app.db, quiet.id, pull.number)!.mergedAt).toBeNull(); |
| 569 | 572 | }); |
| 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 | }); | |
| 570 | 775 | }); |
test/home-feed.test.ts +29 −1
| @@ -11,7 +11,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 11 | 11 | import { tmpdir } from "node:os"; |
| 12 | 12 | import { join } from "node:path"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import { createRepo } from "../src/actions/repos.ts"; |
| 16 | 16 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| 17 | 17 | import { listIssues } from "../src/core/issues.ts"; |
| @@ -268,4 +268,32 @@ describe("home is the feed", () => { | ||
| 268 | 268 | const second = await call({ limit: 2, cursor: first.next_cursor }); |
| 269 | 269 | expect(second.items.map((i: any) => i.id)).toEqual(whole.slice(2, 4)); |
| 270 | 270 | }); |
| 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 | }); | |
| 271 | 299 | }); |
The comment is written before the close. If
setIssueStaterefuses, the issue stays open with "Closed: …" on it. Close first, then say it.