Public: anyone can read this; the crew can change it.
On you and Needs you are one list on the home with one count in the top bar, the same rows /live/on-you and MCP count #1999
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/1969-on-you-and-needs-you-are-one-list-on-the → main 7 commits fork 2 threads · 2 unresolved
18 files
changed +398
−221
docs/DESIGN.md +1 −1
| @@ -202,7 +202,7 @@ specification. | ||
| 202 | 202 | | **checks line** | `.checks-line` (`.ok`, `.bad`, `.now`) | — | inside the merge box, in the reviews chip's voice: `✓ Checks passed · deploy abc1234 · a passing deploy is required`. Rendered only where the repository runs as an app or requires checks; a merge box gains _Waiting on checks_ | the same line under the review line | |
| 203 | 203 | | **run log** | `pre.run-log` | `data-stream` while the run is going | the log as written, wrapped so a phone never scrolls sideways; a script tails the SSE stream and follows the bottom until the reader scrolls up, a `<meta refresh>` does it without one | a scrolling pane that follows the tail | |
| 204 | 204 | | **assignee card** | `.card` with an `.edit-row` form | — | who is on it, then one select and one button for a writer. People first, the crew last and marked `(agent)`, because assigning an agent is what starts it working and the choice is not the same kind of choice | a picker with people, then agents | |
| 205 | | **inbox** (`site.ts`) | two `listCard`s above the repositories | — | _Needs you_ (pulls the crew opened in repositories you can write, each with its check) and _On you_ (what is assigned to you). Absent when empty: a card saying nothing is a zero drawn | the same two panes on the dashboard | | |
| 205 | | **inbox** (`asks_page.ts`) | one list above the feed | — | _On you_: what asks you and what is assigned to you, one row each, each with what it waits for (`needsYouSection`), and one count in the top bar. Absent when empty: a card saying nothing is a zero drawn | the same two panes on the dashboard | | |
| 206 | 206 | |
| 207 | 207 | Two things are deliberately **not** in the vocabulary: skeleton loaders (the |
| 208 | 208 | server renders whole pages — there is nothing to skeletonise) and a custom |
docs/FEED.md +3 −3
| @@ -132,13 +132,13 @@ may not see the item are both `unreachable`. | ||
| 132 | 132 | |
| 133 | 133 | ## Clearing what is on you in one move |
| 134 | 134 | |
| 135 | The home's On you card (what is assigned to you) and the asks section take a | |
| 135 | The home's one On you list (what asks you, and what is assigned to you) takes a | |
| 136 | 136 | set: tick boxes, or *Select all*, a kind, or a repository, then one verb for |
| 137 | 137 | all of it — **close** (one `reason`, written on each as "Closed: …"), |
| 138 | 138 | **mute** (the repository goes quiet in your feed and On you; asks are never |
| 139 | 139 | muted away), **ship**, or on asks **decline** (dismiss) and hand back. It is |
| 140 | 140 | one `POST /api/v1/asks/bulk` (`action`, `items`, `reason`, `preview: false`); |
| 141 | `items` are ask ids, or `issue:<id>` for something assigned to you. The card | |
| 142 | swaps in place and the top bar's count follows. One undo link | |
| 141 | `items` are ask ids (`assigned:<id>` for something assigned to you; `issue:<id>` | |
| 142 | still names one). The list swaps in place and the top bar's count follows. One undo link | |
| 143 | 143 | (`POST /api/v1/asks/bulk/<id>/undo`) is kept for a day. The MCP's `asks_act` |
| 144 | 144 | takes the same set, verb and reason. |
scripts/gate.ts +1
| @@ -210,6 +210,7 @@ export const GATE: readonly string[] = [ | ||
| 210 | 210 | "test/review-wait.test.ts", |
| 211 | 211 | "test/asks-bulk.test.ts", |
| 212 | 212 | "test/asks-waiting-for.test.ts", |
| 213 | "test/asks-one-list.test.ts", | |
| 213 | 214 | "test/hand-decider.test.ts", |
| 214 | 215 | "test/decisions-by-doors.test.ts", |
| 215 | 216 | "test/standing-rule.test.ts", |
src/actions/asks_bulk.ts +1 −1
| @@ -167,7 +167,7 @@ export const undoable = (op: Operation, now = Date.now()): boolean => | ||
| 167 | 167 | |
| 168 | 168 | /** |
| 169 | 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. | |
| 170 | * one undo link the home's On you list keeps for a day. Nothing when none. | |
| 171 | 171 | */ |
| 172 | 172 | export function latestUndoable( |
| 173 | 173 | app: App, |
src/core/waiting_for.ts +1 −1
| @@ -18,7 +18,7 @@ export interface WaitingFor { | ||
| 18 | 18 | |
| 19 | 19 | export interface WaitFacts { |
| 20 | 20 | /** The ask's kind (`DecisionKind`). */ |
| 21 | readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover"; | |
| 21 | readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned"; | |
| 22 | 22 | /** Files stopping the pull from merging, as the last sweep found it. */ |
| 23 | 23 | readonly conflicts: number; |
| 24 | 24 | /** Whether the pull or issue the ask is about is assigned to the viewer. */ |
src/feed/feed.ts +10 −2
| @@ -35,7 +35,13 @@ export function parseCursor( | ||
| 35 | 35 | export function readFeed( |
| 36 | 36 | db: Database, |
| 37 | 37 | viewer: User, |
| 38 | options: { limit?: number; cursor?: { score: number; id: string }; now?: number } = {}, | |
| 38 | options: { | |
| 39 | limit?: number; | |
| 40 | cursor?: { score: number; id: string }; | |
| 41 | now?: number; | |
| 42 | /** The home pins asks above the feed: they must not fill its page. */ | |
| 43 | withoutAsks?: boolean; | |
| 44 | } = {}, | |
| 39 | 45 | ): { items: FeedItem[]; next_cursor: string | null } { |
| 40 | 46 | const now = options.now ?? Date.now(); |
| 41 | 47 | const limit = Math.min(options.limit ?? FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX); |
| @@ -65,7 +71,9 @@ export function readFeed( | ||
| 65 | 71 | c.kind === "choose" || |
| 66 | 72 | !muted.has(c.repoId)), |
| 67 | 73 | ); |
| 68 | let ranked = rank(candidates, affinityFor(db, viewer, now), now); | |
| 74 | const pageable = | |
| 75 | options.withoutAsks === true ? candidates.filter((c) => c.kind !== "ask") : candidates; | |
| 76 | let ranked = rank(pageable, affinityFor(db, viewer, now), now); | |
| 69 | 77 | const cursor = options.cursor; |
| 70 | 78 | if (cursor) { |
| 71 | 79 | const at = ranked.findIndex((i) => i.id === cursor.id); |
src/web/pages/site.ts +2 −120
| @@ -9,32 +9,21 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { limitValue } from "../../settings/limits.ts"; |
| 12 | import { mayView } from "../../core/sealed.ts"; | |
| 13 | 12 | import { signOutHere } from "../../actions/signins.ts"; |
| 14 | 13 | import { clientAddress } from "../../http/peer.ts"; |
| 15 | 14 | import { verifyEmail } from "../../actions/verify.ts"; |
| 16 | 15 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 17 | 16 | import { createRepo } from "../../actions/repos.ts"; |
| 18 | 17 | import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, signUp } from "../../actions/users.ts"; |
| 19 | import { listAssignedTo, type Issue } from "../../core/issues.ts"; | |
| 20 | import { joinMany } from "../../core/pulls.ts"; | |
| 21 | import { conflictsOf } from "../../crew/stale_asks.ts"; | |
| 22 | 18 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 23 | 19 | import { listPins } from "../../core/pins.ts"; |
| 24 | import { | |
| 25 | canRead, | |
| 26 | getReposByIds, | |
| 27 | listReposVisibleTo, | |
| 28 | type Repo, | |
| 29 | } from "../../core/repos.ts"; | |
| 20 | import { getReposByIds } from "../../core/repos.ts"; | |
| 30 | 21 | import { createSession } from "../../core/sessions.ts"; |
| 31 | 22 | import { hasAnyUser, verifyPassword, type User } from "../../core/users.ts"; |
| 32 | 23 | import { isThemeName } from "../../design/themes.ts"; |
| 33 | 24 | import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts"; |
| 34 | 25 | import { studioDoor, studioHome } from "../studio/home.ts"; |
| 35 | import { homeTop, onYouBulk } from "../studio/asks_page.ts"; | |
| 36 | import { latestUndoable } from "../../actions/asks_bulk.ts"; | |
| 37 | import { mutedRepos } from "../../core/interactions.ts"; | |
| 26 | import { homeTop } from "../studio/asks_page.ts"; | |
| 38 | 27 | import { edgeLoginUrl } from "../../http/edge.ts"; |
| 39 | 28 | import { sshCommand } from "../../ssh/command.ts"; |
| 40 | 29 | import { hostOf, pageHead, signInFirst } from "../chrome.ts"; |
| @@ -52,7 +41,6 @@ import { | ||
| 52 | 41 | listCard, |
| 53 | 42 | row, |
| 54 | 43 | selectField, |
| 55 | time, | |
| 56 | 44 | } from "../ui.ts"; |
| 57 | 45 | import { existsSync } from "node:fs"; |
| 58 | 46 | import { join } from "node:path"; |
| @@ -176,112 +164,6 @@ export function downloadsCard(ctx: Ctx, user: User): Html { | ||
| 176 | 164 | ); |
| 177 | 165 | } |
| 178 | 166 | |
| 179 | const threadPath = (repo: Repo, issue: Issue): string => | |
| 180 | `/${repo.ownerName}/${repo.name}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`; | |
| 181 | ||
| 182 | /** Open issues and pulls assigned to the viewer, anywhere they can see. */ | |
| 183 | export function onYouCard(ctx: Ctx, user: User): Html | undefined { | |
| 184 | const db = ctx.app.db; | |
| 185 | const assigned = listAssignedTo(db, user.id, 20); | |
| 186 | if (assigned.length === 0) return undefined; | |
| 187 | const repos = getReposByIds( | |
| 188 | db, | |
| 189 | assigned.map((issue) => issue.repoId), | |
| 190 | ); | |
| 191 | // `listAssignedTo` has no visibility filter of its own (an assignment can | |
| 192 | // outlive the access that made it), so each repo needs a real `canRead`. | |
| 193 | // Checking it straight would cost one query per row for any repo the | |
| 194 | // viewer does not own outright — `canRead` resolves org ownership from | |
| 195 | // the database. `listReposVisibleTo` answers the common case with one | |
| 196 | // query for the whole page; `canRead` only runs for the rare repo it | |
| 197 | // did not reach (its own cap), so the cost stays flat as rows grow. | |
| 198 | const visible = new Set( | |
| 199 | listReposVisibleTo(db, user, { limit: 200 }).map((repo) => repo.id), | |
| 200 | ); | |
| 201 | const muted = mutedRepos(db, user.id); | |
| 202 | const shown: { issue: Issue; repo: Repo }[] = []; | |
| 203 | // Muted repositories that hold work assigned to the viewer: said, never silent. | |
| 204 | const silenced = new Map<string, Repo>(); | |
| 205 | for (const issue of assigned) { | |
| 206 | const repo = repos.get(issue.repoId); | |
| 207 | if (!repo) continue; | |
| 208 | if (!visible.has(repo.id) && !canRead(db, repo, user)) continue; | |
| 209 | if (!mayView(db, repo, issue, user)) continue; | |
| 210 | if (muted.has(repo.id)) { | |
| 211 | silenced.set(repo.id, repo); | |
| 212 | continue; | |
| 213 | } | |
| 214 | shown.push({ issue, repo }); | |
| 215 | } | |
| 216 | // Two or more can be given one move: a box each, and one form for the set. | |
| 217 | const bulk = shown.length >= 2; | |
| 218 | const heads = new Map( | |
| 219 | joinMany( | |
| 220 | db, | |
| 221 | shown.map((s) => s.issue).filter((i) => i.kind === "pull"), | |
| 222 | ).map((p) => [p.id, p.headOid]), | |
| 223 | ); | |
| 224 | const rows = shown.map(({ issue, repo }) => { | |
| 225 | // A pull the last sweep found unable to merge is never shown as ready. | |
| 226 | const conflicts = conflictsOf(db, issue.id, heads.get(issue.id) ?? ""); | |
| 227 | return row({ | |
| 228 | ...(bulk ? {} : { href: threadPath(repo, issue), nav: true as const }), | |
| 229 | state: "open", | |
| 230 | lead: bulk | |
| 231 | ? html`<input | |
| 232 | type="checkbox" | |
| 233 | class="ask-pick" | |
| 234 | name="item" | |
| 235 | value="issue:${issue.id}" | |
| 236 | form="onyou-bulk" | |
| 237 | data-repo="${repo.id}" | |
| 238 | data-kind="${issue.kind}" | |
| 239 | aria-label="Select ${issue.title}" | |
| 240 | />` | |
| 241 | : icon(issue.kind === "pull" ? "pull" : "issue"), | |
| 242 | title: bulk | |
| 243 | ? html`<a href="${threadPath(repo, issue)}">${issue.title}</a>` | |
| 244 | : issue.title, | |
| 245 | titleAttr: issue.title, | |
| 246 | meta: html`${repo.ownerName}/${repo.name} #${issue.number} · updated | |
| 247 | ${time(issue.updatedAt)}${conflicts > 0 | |
| 248 | ? html` · <span class="conflicts">Conflicts in ${conflicts} | |
| 249 | ${conflicts === 1 ? "file" : "files"}</span>` | |
| 250 | : null}`, | |
| 251 | }); | |
| 252 | }); | |
| 253 | if (rows.length === 0 && silenced.size === 0) return undefined; | |
| 254 | const last = latestUndoable(ctx.app, user); | |
| 255 | const byKind = [...new Set(shown.map((s) => s.issue.kind))].map((k) => ({ | |
| 256 | id: k, | |
| 257 | label: k === "pull" ? "Pulls" : "Issues", | |
| 258 | })); | |
| 259 | const byRepo = [...new Map(shown.map((s) => [s.repo.id, s.repo])).values()].map( | |
| 260 | (r) => ({ id: r.id, label: `${r.ownerName}/${r.name}` }), | |
| 261 | ); | |
| 262 | return listCard(rows, { | |
| 263 | head: html`${icon("user")} | |
| 264 | <h2>On you</h2>`, | |
| 265 | ...(bulk || last !== undefined || silenced.size > 0 | |
| 266 | ? { | |
| 267 | foot: onYouBulk( | |
| 268 | byKind, | |
| 269 | byRepo, | |
| 270 | last === undefined | |
| 271 | ? undefined | |
| 272 | : { | |
| 273 | id: last.id, | |
| 274 | verb: last.verb, | |
| 275 | done: last.rows.filter((r) => r.outcome === "ok").length, | |
| 276 | }, | |
| 277 | bulk, | |
| 278 | [...silenced.values()].map((r) => ({ id: r.id, label: `${r.ownerName}/${r.name}` })), | |
| 279 | ), | |
| 280 | } | |
| 281 | : {}), | |
| 282 | }); | |
| 283 | } | |
| 284 | ||
| 285 | 167 | export function orgsCard(ctx: Ctx, user: User): Html { |
| 286 | 168 | const orgs = listOrgsForUser(ctx.app.db, user.id); |
| 287 | 169 | // A new one is the "+" menu's; with none, there is nothing to show. |
src/web/studio/asks.ts +76 −8
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | listOpenPullsVisibleTo, |
| 30 | 30 | type Issue, |
| 31 | 31 | } from "../../core/issues.ts"; |
| 32 | import { mayView } from "../../core/sealed.ts"; | |
| 32 | 33 | import { looksForApps } from "../../core/looks.ts"; |
| 33 | 34 | import { closesFromBody, getPull, joinMany, type Pull } from "../../core/pulls.ts"; |
| 34 | 35 | import { |
| @@ -104,7 +105,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) { | ||
| 104 | 105 | ); |
| 105 | 106 | } |
| 106 | 107 | |
| 107 | export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover"; | |
| 108 | export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned"; | |
| 108 | 109 | |
| 109 | 110 | /** Dollars as a person reads them: cents when small, whole when not. */ |
| 110 | 111 | const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0)); |
| @@ -172,6 +173,11 @@ export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = { | ||
| 172 | 173 | options: ["Answer the planner"], |
| 173 | 174 | ifNothing: "the planner plans nothing more for this app", |
| 174 | 175 | }, |
| 176 | assigned: { | |
| 177 | decision: "This is assigned to you: act on it?", | |
| 178 | options: ["Open it"], | |
| 179 | ifNothing: "it stays assigned to you", | |
| 180 | }, | |
| 175 | 181 | paused: { |
| 176 | 182 | decision: "Raise today's ceiling?", |
| 177 | 183 | options: ["Raise it in settings"], |
| @@ -199,6 +205,7 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = { | ||
| 199 | 205 | handover: { score: 55, why: "three reviews without an approval: handed to a person" }, |
| 200 | 206 | parked: { score: 40, why: "parked by the crew" }, |
| 201 | 207 | look: { score: 30, why: "green, waiting" }, |
| 208 | assigned: { score: 20, why: "assigned to you" }, | |
| 202 | 209 | }; |
| 203 | 210 | |
| 204 | 211 | /** When a person last spoke on a conversation, or its start. */ |
| @@ -432,6 +439,36 @@ function askCandidatesFor( | ||
| 432 | 439 | who: "the planner", |
| 433 | 440 | decline: { runId: d.runId, words: d.words }, |
| 434 | 441 | }); |
| 442 | // Open work assigned to the viewer that no ask above already covers: a | |
| 443 | // pull that is an ask and assigned to them is one row, the ask's. | |
| 444 | const taken = new Set(out.flatMap((a) => [a.pull?.id, a.issue?.id].filter((i) => i !== undefined))); | |
| 445 | const visible = new Map(allRepos.map((r) => [r.id, r])); | |
| 446 | const mine = listAssignedTo(db, user.id, 50).filter((i) => !taken.has(i.id)); | |
| 447 | const elsewhere = getReposByIds( | |
| 448 | db, | |
| 449 | [...new Set(mine.map((i) => i.repoId))].filter((id) => !visible.has(id)), | |
| 450 | ); | |
| 451 | // An assignment can outlive the access that made it: each repo needs a real read. | |
| 452 | const readable = (repo: Repo | undefined): repo is Repo => | |
| 453 | repo !== undefined && (visible.has(repo.id) || canRead(db, repo, user)); | |
| 454 | const joinedMine = new Map( | |
| 455 | joinMany(db, mine.filter((i) => i.kind === "pull")).map((p) => [p.id, p]), | |
| 456 | ); | |
| 457 | for (const issue of mine) { | |
| 458 | const repo = visible.get(issue.repoId) ?? elsewhere.get(issue.repoId); | |
| 459 | if (!readable(repo) || !mayView(db, repo, issue, user)) continue; | |
| 460 | const pull = joinedMine.get(issue.id); | |
| 461 | if (issue.kind === "pull" && pull === undefined) continue; | |
| 462 | out.push({ | |
| 463 | kind: "assigned", | |
| 464 | id: `assigned:${issue.id}`, | |
| 465 | since: issue.updatedAt, | |
| 466 | repo, | |
| 467 | ...(pull === undefined ? { issue } : { pull }), | |
| 468 | who: issue.authorName, | |
| 469 | authorIsAgent: issue.authorIsAgent, | |
| 470 | }); | |
| 471 | } | |
| 435 | 472 | return out; |
| 436 | 473 | } |
| 437 | 474 | |
| @@ -509,8 +546,9 @@ function consequencesOf( | ||
| 509 | 546 | } |
| 510 | 547 | |
| 511 | 548 | /** The row an ask collapses into: one kind in one repository. */ |
| 512 | const groupId = (a: { kind: DecisionKind; repo: Repo }): string => | |
| 513 | `${a.kind}:${a.repo.id}`; | |
| 549 | const groupId = (a: { kind: DecisionKind; repo: Repo; id: string }): string => | |
| 550 | // Work assigned to you is each its own row: they share no decision. | |
| 551 | a.kind === "assigned" ? a.id : `${a.kind}:${a.repo.id}`; | |
| 514 | 552 | |
| 515 | 553 | /** |
| 516 | 554 | * The asks the viewer has not set aside: a muted repository's asks are all |
| @@ -682,6 +720,10 @@ export function endOf( | ||
| 682 | 720 | if (pull !== undefined && pull.state !== "open") return "acted"; |
| 683 | 721 | return getHold(db, what)?.heldBy === viewerId ? "acted" : "expired"; |
| 684 | 722 | } |
| 723 | if (kind === "assigned") { | |
| 724 | const issue = getIssueById(db, what); | |
| 725 | return issue !== undefined && issue.state !== "open" ? "acted" : "expired"; | |
| 726 | } | |
| 685 | 727 | if (kind === "parked") { |
| 686 | 728 | const issue = getIssueById(db, what); |
| 687 | 729 | if (issue === undefined) return "expired"; |
| @@ -749,6 +791,8 @@ export function recordAskLedger(db: Database, now = new Date()): void { | ||
| 749 | 791 | /** One ask in a line, without its repository's name (the row adds that). */ |
| 750 | 792 | function lineOf(c: AskCandidate): string { |
| 751 | 793 | if (c.kind === "paused") return "is paused at its ceiling"; |
| 794 | if (c.kind === "assigned") | |
| 795 | return `"${c.pull?.title ?? c.issue?.title ?? ""}" is assigned to you`; | |
| 752 | 796 | if (c.kind === "parked") |
| 753 | 797 | return `the crew parked #${c.issue!.number} after three failed runs`; |
| 754 | 798 | if (c.kind === "declined") |
| @@ -782,6 +826,7 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> = | ||
| 782 | 826 | handover: (n) => `${n} changes had three reviews asking for changes and wait for you`, |
| 783 | 827 | declined: (n) => `the planner declined ${n} times and waits for you`, |
| 784 | 828 | paused: (n) => `${n} pauses at the ceiling`, |
| 829 | assigned: (n) => `${n} things assigned to you`, | |
| 785 | 830 | }; |
| 786 | 831 | |
| 787 | 832 | /** Every kind has its own: a row never says "green changes" of something that is not one. */ |
| @@ -842,7 +887,7 @@ export function askLinesFor( | ||
| 842 | 887 | const line = `${row.repo.name}${only.kind === "paused" ? " " : ": "}${lineOf(only)}`; |
| 843 | 888 | if (only.kind === "paused") |
| 844 | 889 | return { ...about, line, link: `${base}/settings/autonomy` }; |
| 845 | if (only.kind === "parked" || only.kind === "declined") | |
| 890 | if (only.pull === undefined && only.issue !== undefined) | |
| 846 | 891 | return { |
| 847 | 892 | ...about, |
| 848 | 893 | line, |
| @@ -894,6 +939,18 @@ export function decisionsFor( | ||
| 894 | 939 | ); |
| 895 | 940 | }; |
| 896 | 941 | const worded = (c: Scored): Omit<Decision, "consequence" | "waiting"> => { |
| 942 | if (c.kind === "assigned") { | |
| 943 | const thing = c.pull ?? c.issue!; | |
| 944 | return { | |
| 945 | kind: "assigned", | |
| 946 | id: `assigned:${thing.id}`, | |
| 947 | repo: c.repo, | |
| 948 | ...(c.pull === undefined ? { issue: c.issue } : { pull: c.pull }), | |
| 949 | who: c.who, | |
| 950 | says: `"${thing.title}" is assigned to you.`, | |
| 951 | short: `assigned to you · updated ${thing.updatedAt.slice(0, 10)}`, | |
| 952 | }; | |
| 953 | } | |
| 897 | 954 | if (c.kind === "parked" && c.issue) |
| 898 | 955 | return { |
| 899 | 956 | kind: "parked", |
| @@ -1060,6 +1117,15 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others: | ||
| 1060 | 1117 | }), |
| 1061 | 1118 | ], |
| 1062 | 1119 | }; |
| 1120 | if (d.kind === "assigned" && (d.pull ?? d.issue)) | |
| 1121 | return { | |
| 1122 | primary: btn("Open", { | |
| 1123 | href: pull ? changeHref(base, pull.number) : `${base}/issues/${d.issue!.number}`, | |
| 1124 | size: "sm", | |
| 1125 | variant: "primary", | |
| 1126 | }), | |
| 1127 | others: [], | |
| 1128 | }; | |
| 1063 | 1129 | if (d.kind === "declined" && d.issue) |
| 1064 | 1130 | return { |
| 1065 | 1131 | primary: btn("Answer", { |
| @@ -1146,7 +1212,7 @@ export function asksAnswer(ctx: Ctx, user: User) { | ||
| 1146 | 1212 | |
| 1147 | 1213 | /** |
| 1148 | 1214 | * The asks an issue or pull can raise, as dismissal items: a pull is looked |
| 1149 | * at or stopped on, an issue is parked. What a bulk dismissal records, and | |
| 1215 | * at or stopped on, an issue is parked, and either can be assigned to you. What a bulk dismissal records, and | |
| 1150 | 1216 | * what its undo takes back (`fleet/verbs.ts`). |
| 1151 | 1217 | */ |
| 1152 | 1218 | export function askItemsOf(issue: Issue): string[] { |
| @@ -1156,8 +1222,9 @@ export function askItemsOf(issue: Issue): string[] { | ||
| 1156 | 1222 | `ask:review:${issue.id}`, |
| 1157 | 1223 | `ask:stopped:${issue.id}`, |
| 1158 | 1224 | `ask:handover:${issue.id}`, |
| 1225 | `ask:assigned:${issue.id}`, | |
| 1159 | 1226 | ] |
| 1160 | : [`ask:parked:${issue.id}`]; | |
| 1227 | : [`ask:parked:${issue.id}`, `ask:assigned:${issue.id}`]; | |
| 1161 | 1228 | } |
| 1162 | 1229 | |
| 1163 | 1230 | /** |
| @@ -1207,8 +1274,9 @@ export function askTargetsFor( | ||
| 1207 | 1274 | ); |
| 1208 | 1275 | const issueIds: string[] = []; |
| 1209 | 1276 | const refused: { item: string; reason: string }[] = []; |
| 1210 | // `issue:<id>` names an issue or pull assigned to the viewer — what the | |
| 1211 | // home's On you card holds — rather than an ask it raises. | |
| 1277 | // `issue:<id>` names an issue or pull assigned to the viewer. The home no | |
outdated
This comment is muddled: |
||
| 1278 | // longer hands it out (its assigned rows are `assigned:<id>`); it is kept | |
| 1279 | // because the API and the MCP's `asks_act` document it to their callers. | |
| 1212 | 1280 | const assigned = new Map( |
| 1213 | 1281 | listAssignedTo(db, user.id, 200).map((i) => [`issue:${i.id}`, i]), |
| 1214 | 1282 | ); |
src/web/studio/asks_page.ts +21 −50
| @@ -11,6 +11,7 @@ import type { WaitingFor } from "../../core/waiting_for.ts"; | ||
| 11 | 11 | import { |
| 12 | 12 | applyAsksBulk, |
| 13 | 13 | BULK_MAX, |
| 14 | latestUndoable, | |
| 14 | 15 | ownAsksOperation, |
| 15 | 16 | undoAsksBulk, |
| 16 | 17 | previewAsksBulk, |
| @@ -60,15 +61,16 @@ const ASK_KIND_WORDS: Readonly<Record<string, string>> = { | ||
| 60 | 61 | review: "Reviews", |
| 61 | 62 | stopped: "Stopped", |
| 62 | 63 | parked: "Parked", |
| 64 | assigned: "Assigned", | |
| 63 | 65 | }; |
| 64 | 66 | |
| 65 | 67 | /** The kinds a bulk action takes: a ceiling and a planner's decline are decided where they live. */ |
| 66 | const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "stopped", "handover", "parked"]); | |
| 68 | const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "stopped", "handover", "parked", "assigned"]); | |
| 67 | 69 | |
| 68 | 70 | /** A row's box for the bulk form, which sits apart (`form="asks-bulk"`): forms do not nest. */ |
| 69 | 71 | const pick = (d: Decision, picked: ReadonlySet<string>): Html | null => |
| 70 | 72 | BULK_KINDS.has(d.kind) |
| 71 | ? html`<input | |
| 73 | ? html`<label class="ask-pick-hit"><input | |
| 72 | 74 | type="checkbox" |
| 73 | 75 | class="ask-pick" |
| 74 | 76 | name="item" |
| @@ -78,7 +80,7 @@ const pick = (d: Decision, picked: ReadonlySet<string>): Html | null => | ||
| 78 | 80 | data-kind="${d.kind}" |
| 79 | 81 | ${picked.has(d.id) ? html`checked` : null} |
| 80 | 82 | aria-label="Select ${d.pull?.title ?? d.issue?.title ?? d.id}" |
| 81 | />` | |
| 83 | /></label>` | |
| 82 | 84 | : null; |
| 83 | 85 | |
| 84 | 86 | /** |
| @@ -166,6 +168,7 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = { | ||
| 166 | 168 | parked: "Parked", |
| 167 | 169 | declined: "Declined", |
| 168 | 170 | paused: "Paused", |
| 171 | assigned: "Assigned", | |
| 169 | 172 | }; |
| 170 | 173 | |
| 171 | 174 | /** |
| @@ -266,12 +269,12 @@ function rowCard(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Ht | ||
| 266 | 269 | id: askAnchor(r.id), |
| 267 | 270 | extra: "ask-card", |
| 268 | 271 | head: html`${BULK_KINDS.has(r.kind) |
| 269 | ? html`<input | |
| 272 | ? html`<label class="ask-pick-hit"><input | |
| 270 | 273 | type="checkbox" |
| 271 | 274 | class="ask-pick" |
| 272 | 275 | data-select-all |
| 273 | 276 | aria-label="Select all ${r.items.length}" |
| 274 | />` | |
| 277 | /></label>` | |
| 275 | 278 | : null} |
| 276 | 279 | <h2>${rowHead(r.kind, r.items.length)}</h2> |
| 277 | 280 | <span class="muted small">${r.repo.ownerName}/${r.repo.name}</span>${waitChip(r.items[0]!.waiting)}`, |
| @@ -374,10 +377,19 @@ export function needsYouSection( | ||
| 374 | 377 | > |
| 375 | 378 | <button type="submit" class="btn btn-sm btn-ghost">Listen again</button> |
| 376 | 379 | </form>`; |
| 380 | const last = latestUndoable(ctx.app, user); | |
| 381 | const undo = | |
| 382 | last === undefined | |
| 383 | ? null | |
| 384 | : html`<form class="bar" method="post" action="/asks/bulk/${last.id}/undo" role="status"> | |
| 385 | <span class="small">${VERB_WORDS[last.verb] ?? last.verb}: ${last.rows.filter((r) => r.outcome === "ok").length} done</span> | |
| 386 | ${btn("Undo", { size: "sm" })} | |
| 387 | <span class="muted small">kept for a day</span> | |
| 388 | </form>`; | |
| 377 | 389 | const count = rows.length + needs.length; |
| 378 | 390 | if (count === 0 && top === undefined) |
| 379 | 391 | return { |
| 380 | body: html`<h2 id="asks-sentence" class="sr-only">${askSentence(0)}</h2>${silencedNote}`, | |
| 392 | body: html`<h2 id="asks-sentence" class="sr-only">${askSentence(0)}</h2>${silencedNote}${undo}`, | |
| 381 | 393 | scriptJs: "", |
| 382 | 394 | }; |
| 383 | 395 | // The forge's own health first, then the few asks that matter most; |
| @@ -428,6 +440,7 @@ export function needsYouSection( | ||
| 428 | 440 | ${reasonInput(top?.reason)} ${btn("Preview", { size: "sm" })} |
| 429 | 441 | <span class="muted small">on the selected asks</span> |
| 430 | 442 | </form>`} |
| 443 | ${silencedNote} ${undo} | |
| 431 | 444 | ${decisions.map((d) => decisionForm(d, ctx.url.pathname))} |
| 432 | 445 | </section>`; |
| 433 | 446 | return { body, scriptJs: SELECT_JS }; |
| @@ -452,11 +465,8 @@ const VERB_WORDS: Readonly<Record<string, string>> = { | ||
| 452 | 465 | replan: "Hand back to the planner", |
| 453 | 466 | }; |
| 454 | 467 | |
| 455 | /** What the asks below can be given in one move; mute is the On you card's. */ | |
| 456 | const ASK_VERBS = ["decline", "ship", "close", "replan"] as const; | |
| 457 | ||
| 458 | /** What the On you card's set can be given. */ | |
| 459 | const CARD_VERBS = ["close", "mute", "ship"] as const; | |
| 468 | /** What the asks below can be given in one move. */ | |
| 469 | const ASK_VERBS = ["decline", "ship", "close", "replan", "mute"] as const; | |
| 460 | 470 | |
| 461 | 471 | /** One reason, written on each pull or issue closed. */ |
| 462 | 472 | const reasonInput = (value = ""): Html => |
| @@ -495,45 +505,6 @@ export function pickControls( | ||
| 495 | 505 | </span>`; |
| 496 | 506 | } |
| 497 | 507 | |
| 498 | /** | |
| 499 | * The On you card's one form: a verb for the set, one reason, one request. | |
| 500 | * `receipt` is the day's undo link for the last set acted on. | |
| 501 | */ | |
| 502 | export function onYouBulk( | |
| 503 | kinds: readonly { id: string; label: string }[], | |
| 504 | repos: readonly { id: string; label: string }[], | |
| 505 | receipt: { id: string; verb: string; done: number } | undefined, | |
| 506 | withForm = true, | |
| 507 | muted: readonly { id: string; label: string }[] = [], | |
| 508 | ): Html { | |
| 509 | return html`${muted.length === 0 | |
| 510 | ? null | |
| 511 | : html`<form class="bar" method="post" action="/asks/listen"> | |
| 512 | ${muted.map((r) => html`<input type="hidden" name="repo" value="${r.id}" />`)} | |
| 513 | <span class="muted small" title="${muted.map((r) => r.label).join(", ")}" | |
| 514 | >${muted.length} ${muted.length === 1 ? "repository" : "repositories"} muted</span | |
| 515 | > | |
| 516 | <button type="submit" class="btn btn-sm btn-ghost">Listen again</button> | |
| 517 | </form>`} | |
| 518 | ${receipt === undefined | |
| 519 | ? null | |
| 520 | : html`<form class="bar" method="post" action="/asks/bulk/${receipt.id}/undo" role="status"> | |
| 521 | <span class="small">${VERB_WORDS[receipt.verb] ?? receipt.verb}: ${receipt.done} done</span> | |
| 522 | ${btn("Undo", { size: "sm" })} | |
| 523 | <span class="muted small">kept for a day</span> | |
| 524 | </form>`} | |
| 525 | ${withForm | |
| 526 | ? html`<form id="onyou-bulk" method="post" action="/asks/bulk" class="bar" data-bulk> | |
| 527 | <input type="hidden" name="apply" value="1" /> | |
| 528 | ${pickControls("onyou-bulk", kinds, repos)} | |
| 529 | <select name="action" class="input" aria-label="What to do with the selected"> | |
| 530 | ${CARD_VERBS.map((v) => html`<option value="${v}">${VERB_WORDS[v]}</option>`)} | |
| 531 | </select> | |
| 532 | ${reasonInput()} ${btn("Do it", { size: "sm" })} | |
| 533 | </form>` | |
| 534 | : null}`; | |
| 535 | } | |
| 536 | ||
| 537 | 508 | const shown = (v: FleetValue): string => |
| 538 | 509 | v === null ? "nobody" : Array.isArray(v) ? v.join(", ") : String(v); |
| 539 | 510 |
src/web/studio/feed.ts +1 −3
| @@ -241,9 +241,7 @@ function chipsBar(user: User, ctx: Ctx, items: readonly FeedItem[]): Html { | ||
| 241 | 241 | export function feedRegion(user: User, ctx: Ctx): Html { |
| 242 | 242 | // What needs you is pinned above the timeline (`needsYouSection`), so an |
| 243 | 243 | // ask is never a second time a post below it; the feed API keeps them. |
| 244 | const items = readFeed(ctx.app.db, user, { limit: FEED_FIRST_PAGE }).items.filter( | |
| 245 | (item) => item.kind !== "ask", | |
| 246 | ); | |
| 244 | const items = readFeed(ctx.app.db, user, { limit: FEED_FIRST_PAGE, withoutAsks: true }).items; | |
| 247 | 245 | const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString()); |
| 248 | 246 | const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id)); |
| 249 | 247 | // Who each poster is, asked once for the page (`core/marks.ts`). |
src/web/studio/home.ts +9 −6
| @@ -40,7 +40,6 @@ import { | ||
| 40 | 40 | } from "../ui.ts"; |
| 41 | 41 | import { |
| 42 | 42 | downloadsCard, |
| 43 | onYouCard, | |
| 44 | 43 | orgsCard, |
| 45 | 44 | terminalCard, |
| 46 | 45 | } from "../pages/site.ts"; |
| @@ -127,14 +126,19 @@ const appsRegion = (ctx: Ctx, user: User): Html => | ||
| 127 | 126 | ${yourApps(ctx, user)} |
| 128 | 127 | </div>`; |
| 129 | 128 | |
| 130 | const onYouRegion = (ctx: Ctx, user: User, note?: string): Html => | |
| 129 | const onYouRegion = ( | |
| 130 | ctx: Ctx, | |
| 131 | user: User, | |
| 132 | note?: string, | |
| 133 | section: Html = needsYouSection(ctx, user).body, | |
| 134 | ): Html => | |
| 131 | 135 | html`<div |
| 132 | 136 | id="live-on-you" |
| 133 | 137 | data-live="${ON_YOU_LIVE}" |
| 134 | 138 | data-live-src="/live/on-you" |
| 135 | 139 | > |
| 136 | 140 | ${note === undefined ? null : html`<p class="small" role="status">${note}</p>`} |
| 137 | ${onYouCard(ctx, user) ?? null} | |
| 141 | ${section} | |
| 138 | 142 | </div>`; |
| 139 | 143 | |
| 140 | 144 | // GET /live/apps — your apps alone, for the region that shows them. |
| @@ -145,7 +149,7 @@ export function liveAppsFragment(ctx: Ctx): Response { | ||
| 145 | 149 | } |
| 146 | 150 | |
| 147 | 151 | /** |
| 148 | * What a bulk step on the On you card answers its own script with: the | |
| 152 | * What a bulk step on the On you list answers its own script with: the | |
| 149 | 153 | * region as it is now and the top bar's Asks link with its new count. |
| 150 | 154 | */ |
| 151 | 155 | export function onYouAnswer(ctx: Ctx, user: User, note?: string): Response { |
| @@ -220,7 +224,7 @@ export async function studioHome( | ||
| 220 | 224 | ${homeTabs("feed")} |
| 221 | 225 | <div class="dash"> |
| 222 | 226 | <div class="dash-main stack"> |
| 223 | ${needsYou.body} ${composer(ctx, user)} | |
| 227 | ${onYouRegion(ctx, user, undefined, needsYou.body)} ${composer(ctx, user)} | |
| 224 | 228 | <div class="bar feed-head"> |
| 225 | 229 | <span class="spacer"></span> |
| 226 | 230 | <a class="small muted" href="/asked">What you asked for</a> |
| @@ -237,7 +241,6 @@ export async function studioHome( | ||
| 237 | 241 | user.isAdmin || appRepos.length > 0 ? crewKeyNote(ctx) : null |
| 238 | 242 | } |
| 239 | 243 | ${appsRegion(ctx, user)} |
| 240 | ${onYouRegion(ctx, user)} | |
| 241 | 244 | ${terminalCard(ctx)} ${downloadsCard(ctx, user)} |
| 242 | 245 | ${orgsCard(ctx, user)} |
| 243 | 246 | </aside> |
src/web/style/content.ts +7 −2
| @@ -1204,7 +1204,10 @@ textarea.editor:focus { outline: none; } | ||
| 1204 | 1204 | } |
| 1205 | 1205 | /* What needs you, pinned on the home: the bulk step's select as wide as |
| 1206 | 1206 | its words, not the column. */ |
| 1207 | .needs-you #asks-bulk select { width: auto; } | |
| 1207 | .needs-you #asks-bulk select { width: auto; max-width: 100%; } | |
| 1208 | .needs-you #asks-bulk .input[name="reason"] { flex: 1 1 12rem; width: auto; min-width: 0; } | |
| 1209 | /* A row's box sits in a label, so a phone's thumb has 44 px to hit. */ | |
| 1210 | .ask-pick-hit { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; } | |
| 1208 | 1211 | /* A phone shows the top asks and one "N more" that opens in place to all |
| 1209 | 1212 | of them; a wider screen shows ASKS_SHOWN and its own "N more" past that. |
| 1210 | 1213 | Only where a details element can be held open: elsewhere both summaries |
| @@ -1256,7 +1259,9 @@ textarea.editor:focus { outline: none; } | ||
| 1256 | 1259 | .ask-card .ask-kind::after { content: " ·"; font-weight: 400; } |
| 1257 | 1260 | .ask-card .ask-short { display: none; } |
| 1258 | 1261 | .ask-card .ask-waiting { order: 3; flex: 0 1 auto; margin-left: 0; min-width: 0; max-width: 100%; white-space: normal; } |
| 1259 | .ask-card .ask-pick { order: 0; min-width: 24px; min-height: 24px; } | |
| 1262 | .ask-card .ask-pick-hit { order: 0; } | |
| 1263 | .needs-you .ask-pick-hit { min-width: 44px; min-height: 44px; } | |
| 1264 | .ask-card .ask-pick { min-width: 24px; min-height: 24px; } | |
| 1260 | 1265 | .ask-card .ask-group > summary { padding: 10px 16px; min-height: 44px; } |
| 1261 | 1266 | .ask-card .ask-group .row { flex-wrap: wrap; padding-inline: 16px; } |
| 1262 | 1267 | .ask-card .ask-group .row-main { flex-basis: 100%; } |
test/asks-bulk.test.ts +13 −10
| @@ -21,7 +21,7 @@ import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; | ||
| 21 | 21 | import { getIssueById, listComments, type Issue } from "../src/core/issues.ts"; |
| 22 | 22 | import { assignIssue } from "../src/actions/issues.ts"; |
| 23 | 23 | import { mutedRepos, recordInteraction, removeMutes } from "../src/core/interactions.ts"; |
| 24 | import { onYouCard } from "../src/web/pages/site.ts"; | |
| 24 | import { needsYouSection } from "../src/web/studio/asks_page.ts"; | |
| 25 | 25 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 26 | 26 | import type { Repo } from "../src/core/repos.ts"; |
| 27 | 27 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| @@ -634,12 +634,14 @@ describe("acting on many asks at once", () => { | ||
| 634 | 634 | const issue = getIssueById(app.db, pull.id)!; |
| 635 | 635 | expect(assignIssue(app, alice, open, issue, alice).ok).toBe(true); |
| 636 | 636 | } |
| 637 | const ctx = (user: User) => ({ app, auth: { user } }) as never; | |
| 638 | const card = (user: User) => onYouCard(ctx(user), user)?.text ?? ""; | |
| 637 | const ctx = (user: User) => ({ app, auth: { user }, url: new URL("http://x/") }) as never; | |
| 638 | const card = (user: User) => needsYouSection(ctx(user), user).body.text; | |
| 639 | 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 + '"'); | |
| 640 | // The list offers the set: a box each, the kinds, one form. | |
| 641 | expect(card(alice)).toContain('form="asks-bulk"'); | |
| 642 | // A green pull assigned to alice is an ask: one row, under the ask's id. | |
| 643 | expect(card(alice)).toContain('value="look:' + a.id + '"'); | |
| 644 | expect(card(alice)).not.toContain('value="assigned:' + a.id + '"'); | |
| 643 | 645 | expect(card(alice)).toContain("data-pick"); |
| 644 | 646 | const done = await api(aliceRw, "/api/v1/asks/bulk", { |
| 645 | 647 | action: "mute", |
| @@ -713,8 +715,9 @@ describe("acting on many asks at once", () => { | ||
| 713 | 715 | const b = await greenPull(open, "Muted two"); |
| 714 | 716 | for (const pull of [a, b]) |
| 715 | 717 | expect(assignIssue(app, alice, open, getIssueById(app.db, pull.id)!, alice).ok).toBe(true); |
| 716 | const card = () => onYouCard({ app, auth: { user: alice } } as never, alice)?.text ?? ""; | |
| 717 | expect(card()).not.toContain("muted"); | |
| 718 | const card = () => | |
| 719 | needsYouSection({ app, auth: { user: alice }, url: new URL("http://x/") } as never, alice).body.text; | |
| 720 | expect(card()).not.toContain("muted repositor"); | |
| 718 | 721 | const done = await api(aliceRw, "/api/v1/asks/bulk", { |
| 719 | 722 | action: "mute", |
| 720 | 723 | items: [`issue:${a.id}`, `issue:${b.id}`], |
| @@ -722,7 +725,7 @@ describe("acting on many asks at once", () => { | ||
| 722 | 725 | }); |
| 723 | 726 | expect(done.status).toBe(201); |
| 724 | 727 | // Past the day's undo, the card still says it, and offers the way back. |
| 725 | expect(card()).toContain("1 repository muted"); | |
| 728 | expect(card()).toContain("1 muted repository still asks"); | |
| 726 | 729 | expect(card()).toContain('action="/asks/listen"'); |
| 727 | 730 | const headers = { cookie: `${aliceCookie}; forge_mode=studio`, origin: servers.url }; |
| 728 | 731 | const res = await fetch(`${servers.url}/asks/listen`, { |
| @@ -734,7 +737,7 @@ describe("acting on many asks at once", () => { | ||
| 734 | 737 | expect(res.status).toBe(303); |
| 735 | 738 | expect(mutedRepos(app.db, alice.id).has(open.id)).toBe(false); |
| 736 | 739 | expect(card()).toContain("Muted one"); |
| 737 | expect(card()).not.toContain("repository muted"); | |
| 740 | expect(card()).not.toContain("muted repository still asks"); | |
| 738 | 741 | }); |
| 739 | 742 | test("on the card, a refusal answers 422 with its message, and a partial set says what was not done", async () => { |
| 740 | 743 | const a = await greenPull(open, "Refuse one"); |
test/asks-expiry.test.ts +4 −2
| @@ -247,15 +247,17 @@ describe("asks that expire, hold and collapse", () => { | ||
| 247 | 247 | ), |
| 248 | 248 | ).toBe(false); |
| 249 | 249 | expect(JSON.stringify(asksAnswer({ app, auth: { user: aliceUser } } as never, aliceUser))).not.toContain("is green"); |
| 250 | // Assigned to alice, the home's On you says it conflicts, never ready. | |
| 250 | // Assigned to alice, the home's one list says it conflicts, never ready. | |
| 251 | 251 | setAssignee(app.db, plums.id, aliceUser.id); |
| 252 | 252 | const home = await get("/"); |
| 253 | expect(home).toContain("Conflicts in 1 file"); | |
| 253 | expect(home).toContain("conflicts with main"); | |
| 254 | expect(askLinesFor(app.db, aliceUser).map((l) => l.waitingFor.word)).toEqual(["conflicts with main"]); | |
| 254 | 255 | // Its own page still says why it cannot ship. |
| 255 | 256 | const change = await get( |
| 256 | 257 | `/${repo.ownerName}/${repo.name}/pulls/${plums.number}`, |
| 257 | 258 | ); |
| 258 | 259 | expect(change).toContain("README.md"); |
| 260 | setAssignee(app.db, plums.id, null); | |
| 259 | 261 | }); |
| 260 | 262 | test("a row dismissed while a pull conflicted asks again when it merges again, with no new check", async () => { |
| 261 | 263 | await tick(); |
test/asks-one-list.test.ts added +233
| @@ -0,0 +1,233 @@ | ||
| 1 | /** | |
| 2 | * What is assigned to you and what asks you are one list (#1826): one row | |
| 3 | * source, so the home, the count and the MCP's `asks` count the same rows, | |
| 4 | * and a pull that is both is one row. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { recordInteraction, removeMutes } from "../src/core/interactions.ts"; | |
| 16 | import { getIssueById, setAssignee } from "../src/core/issues.ts"; | |
| 17 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 19 | import { createToken } from "../src/core/tokens.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { applyAsksBulk, undoAsksBulk } from "../src/actions/asks_bulk.ts"; | |
| 23 | import { askCountFor, askLinesFor } from "../src/web/studio/asks.ts"; | |
| 24 | import { readFeed } from "../src/feed/feed.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | describe("one list", () => { | |
| 30 | let dataDir = ""; | |
| 31 | let app: App; | |
| 32 | let servers: Servers; | |
| 33 | let alice: User; | |
| 34 | let repo: Repo; | |
| 35 | let cookie = ""; | |
| 36 | let token = ""; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-asks-one-list-")); | |
| 40 | app = createApp({ dataDir, port: 0 }); | |
| 41 | servers = await serve(app); | |
| 42 | const made = await createUser(app.db, { | |
| 43 | trusted: true, | |
| 44 | username: "alice", | |
| 45 | email: "[email protected]", | |
| 46 | password: "a-long-one-list-passphrase", | |
| 47 | adminIfFirst: true, | |
| 48 | }); | |
| 49 | if (!made.ok) throw new Error(made.error); | |
| 50 | alice = made.value; | |
| 51 | const res = await fetch(`${servers.url}/signin`, { | |
| 52 | method: "POST", | |
| 53 | body: new URLSearchParams({ username: "alice", password: "a-long-one-list-passphrase" }), | |
| 54 | redirect: "manual", | |
| 55 | }); | |
| 56 | cookie = `${(res.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=studio`; | |
| 57 | token = createToken(app.db, alice.id, "ro", "ro").token; | |
| 58 | const made2 = await createRepo(app, alice, { name: "one", visibility: "public" }); | |
| 59 | if (!made2.ok) throw new Error(String(made2.error)); | |
| 60 | repo = made2.value; | |
| 61 | await commitToBranch(app, alice, repo, { | |
| 62 | message: "seed", | |
| 63 | files: [{ path: "README.md", content: "# one\n" }], | |
| 64 | }); | |
| 65 | }, 30_000); | |
| 66 | ||
| 67 | afterAll(async () => { | |
| 68 | await servers.stop(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | /** The three doors' counts of the same rows. */ | |
| 73 | const counts = async () => { | |
| 74 | const mcp = await fetch(`${servers.url}/mcp`, { | |
| 75 | method: "POST", | |
| 76 | headers: { | |
| 77 | authorization: `Bearer ${token}`, | |
| 78 | "content-type": "application/json", | |
| 79 | accept: "application/json, text/event-stream", | |
| 80 | }, | |
| 81 | body: JSON.stringify({ | |
| 82 | jsonrpc: "2.0", | |
| 83 | id: 1, | |
| 84 | method: "tools/call", | |
| 85 | params: { name: "asks", arguments: {} }, | |
| 86 | }), | |
| 87 | }); | |
| 88 | const body = JSON.parse(await mcp.text().then((t) => t.slice(t.indexOf("{")))) as { | |
| 89 | result: { content: { text: string }[] }; | |
| 90 | }; | |
| 91 | const answer = JSON.parse(body.result.content[0]!.text) as { rows: unknown[]; forge: unknown[] }; | |
| 92 | const home = await (await fetch(`${servers.url}/live/on-you`, { headers: { cookie } })).text(); | |
| 93 | const said = /(\d+) things? needs? you/.exec(home); | |
| 94 | return { | |
| 95 | header: askCountFor({ app, auth: { user: alice } } as never, alice), | |
| 96 | mcp: answer.rows.length + answer.forge.length, | |
| 97 | home: said === null ? 0 : Number(said[1]), | |
| 98 | html: home, | |
| 99 | }; | |
| 100 | }; | |
| 101 | ||
| 102 | test("an issue assigned to you is a row that says so, and every door counts it", async () => { | |
| 103 | const made = openIssue(app, alice, repo, { title: "Sweep <the> path", body: "" }); | |
| 104 | if (!made.ok) throw new Error(String(made.error)); | |
| 105 | const before = await counts(); | |
| 106 | setAssignee(app.db, made.value.id, alice.id); | |
| 107 | const after = await counts(); | |
| 108 | expect(after.header).toBe(before.header + 1); | |
| 109 | expect(after.mcp).toBe(after.header); | |
| 110 | expect(after.home).toBe(after.header); | |
| 111 | expect(after.html).toContain('data-waiting="assigned to you"'); | |
| 112 | expect(after.html).toContain("Sweep <the> path"); | |
| 113 | const row = askLinesFor(app.db, alice).find((l) => l.id === `assigned:${made.value.id}`); | |
| 114 | expect(row?.waitingFor).toEqual({ word: "assigned to you", on: "you" }); | |
| 115 | // Mute silences it for this person; listening again brings it back. | |
| 116 | recordInteraction(app.db, { | |
| 117 | viewerId: alice.id, | |
| 118 | itemId: `repo:${repo.id}`, | |
| 119 | kind: "muted", | |
| 120 | repoId: repo.id, | |
| 121 | actorId: null, | |
| 122 | }); | |
| 123 | expect(askLinesFor(app.db, alice).some((l) => l.id === `assigned:${made.value.id}`)).toBe(false); | |
| 124 | expect((await counts()).header).toBe(before.header); | |
| 125 | removeMutes(app.db, alice.id, [`repo:${repo.id}`]); | |
| 126 | expect((await counts()).header).toBe(after.header); | |
| 127 | setAssignee(app.db, made.value.id, null); | |
| 128 | }); | |
| 129 | ||
| 130 | test("a green pull that is an ask and assigned to you counts once", async () => { | |
| 131 | await commitToBranch(app, alice, repo, { | |
| 132 | branch: "feat", | |
| 133 | base: "main", | |
| 134 | message: "Feature", | |
| 135 | files: [{ path: "F.md", content: "f\n" }], | |
| 136 | }); | |
| 137 | const pull = await openPull(app, alice, repo, { | |
| 138 | title: "Feature", | |
| 139 | body: "", | |
| 140 | baseBranch: "main", | |
| 141 | headBranch: "feat", | |
| 142 | }); | |
| 143 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 144 | const run = createRun(app.db, { | |
| 145 | repoId: repo.id, | |
| 146 | kind: "check", | |
| 147 | issueId: pull.value.id, | |
| 148 | env: `pull-${pull.value.number}`, | |
| 149 | commitOid: pull.value.headOid, | |
| 150 | reason: "pull", | |
| 151 | }); | |
| 152 | claimRun(app.db, run.id); | |
| 153 | finishRun(app.db, run.id, { state: "ok", summary: "green" }); | |
| 154 | const unassigned = await counts(); | |
| 155 | setAssignee(app.db, pull.value.id, alice.id); | |
| 156 | const assigned = await counts(); | |
| 157 | expect(assigned.header).toBe(unassigned.header); | |
| 158 | expect(assigned.mcp).toBe(assigned.header); | |
| 159 | expect(assigned.home).toBe(assigned.header); | |
| 160 | const ids = askLinesFor(app.db, alice).flatMap((l) => l.items); | |
| 161 | expect(ids.filter((i) => i.endsWith(pull.value.id))).toEqual([`look:${pull.value.id}`]); | |
| 162 | expect(assigned.html).toContain('data-waiting="ready to land"'); | |
| 163 | expect(assigned.html).not.toContain('data-waiting="assigned to you"'); | |
| 164 | }); | |
| 165 | test("declining an assigned row sets it aside, and undo brings it back", async () => { | |
| 166 | const made = openIssue(app, alice, repo, { title: "Set me aside", body: "" }); | |
| 167 | if (!made.ok) throw new Error(String(made.error)); | |
| 168 | setAssignee(app.db, made.value.id, alice.id); | |
| 169 | const id = `assigned:${made.value.id}`; | |
| 170 | const listed = () => askLinesFor(app.db, alice).some((l) => l.id === id); | |
| 171 | expect(listed()).toBe(true); | |
| 172 | const done = await applyAsksBulk(app, alice, "decline", [id]); | |
| 173 | if (!done.ok) throw new Error(done.error.message); | |
| 174 | expect(done.value.skipped).toEqual([]); | |
| 175 | expect(listed()).toBe(false); | |
| 176 | expect((await counts()).home).toBe(askCountFor({ app, auth: { user: alice } } as never, alice)); | |
| 177 | expect((await undoAsksBulk(app, alice, done.value.id)).ok).toBe(true); | |
| 178 | expect(listed()).toBe(true); | |
| 179 | setAssignee(app.db, made.value.id, null); | |
| 180 | }); | |
| 181 | ||
| 182 | test("close, mute and ship act on assigned rows; ship refuses a plain issue", async () => { | |
| 183 | const made = openIssue(app, alice, repo, { title: "Close or ship me", body: "" }); | |
| 184 | if (!made.ok) throw new Error(String(made.error)); | |
| 185 | setAssignee(app.db, made.value.id, alice.id); | |
| 186 | const id = `assigned:${made.value.id}`; | |
| 187 | const listed = () => askLinesFor(app.db, alice).some((l) => l.id === id); | |
| 188 | ||
| 189 | const shipped = await applyAsksBulk(app, alice, "ship", [id]); | |
| 190 | if (!shipped.ok) throw new Error(shipped.error.message); | |
| 191 | expect(shipped.value.skipped).toEqual([]); | |
| 192 | expect(shipped.value.rows.map((r) => [r.outcome, r.reason])).toEqual([ | |
| 193 | ["refused", "not a change"], | |
| 194 | ]); | |
| 195 | expect(getIssueById(app.db, made.value.id)!.state).toBe("open"); | |
| 196 | expect(listed()).toBe(true); | |
| 197 | ||
| 198 | const closed = await applyAsksBulk(app, alice, "close", [id], "done elsewhere"); | |
| 199 | if (!closed.ok) throw new Error(closed.error.message); | |
| 200 | expect(closed.value.rows.map((r) => r.outcome)).toEqual(["ok"]); | |
| 201 | expect(getIssueById(app.db, made.value.id)!.state).toBe("closed"); | |
| 202 | expect(listed()).toBe(false); | |
| 203 | expect((await undoAsksBulk(app, alice, closed.value.id)).ok).toBe(true); | |
| 204 | expect(getIssueById(app.db, made.value.id)!.state).toBe("open"); | |
| 205 | expect(listed()).toBe(true); | |
| 206 | ||
| 207 | const muted = await applyAsksBulk(app, alice, "mute", [id]); | |
| 208 | if (!muted.ok) throw new Error(muted.error.message); | |
| 209 | expect(muted.value.rows.map((r) => r.outcome)).toEqual(["ok"]); | |
| 210 | expect(listed()).toBe(false); | |
| 211 | expect((await undoAsksBulk(app, alice, muted.value.id)).ok).toBe(true); | |
| 212 | expect(listed()).toBe(true); | |
| 213 | setAssignee(app.db, made.value.id, null); | |
| 214 | }); | |
| 215 | ||
| 216 | test("the home's feed page is never filled by the asks pinned above it", async () => { | |
| 217 | const opened: string[] = []; | |
| 218 | for (let n = 0; n < 4; n++) { | |
| 219 | const made = openIssue(app, alice, repo, { title: `Pinned ${n}`, body: "" }); | |
| 220 | if (!made.ok) throw new Error(String(made.error)); | |
| 221 | setAssignee(app.db, made.value.id, alice.id); | |
| 222 | opened.push(made.value.id); | |
| 223 | } | |
| 224 | const limit = 3; | |
| 225 | // Without the option, the asks fill the page: the test bites. | |
| 226 | const all = readFeed(app.db, alice, { limit }).items; | |
| 227 | expect(all.filter((i) => i.kind === "ask").length).toBe(limit); | |
| 228 | const page = readFeed(app.db, alice, { limit, withoutAsks: true }).items; | |
| 229 | expect(page.length).toBeGreaterThan(0); | |
| 230 | expect(page.some((i) => i.kind === "ask" || i.id.startsWith("ask:"))).toBe(false); | |
| 231 | for (const id of opened) setAssignee(app.db, id, null); | |
| 232 | }); | |
| 233 | }); |
test/asks.test.ts +1 −1
| @@ -99,7 +99,7 @@ describe("the asks page", () => { | ||
| 99 | 99 | const page = await get(alice, "/"); |
| 100 | 100 | expect(sentence(page)).toBe("All fine"); |
| 101 | 101 | expect(headerCount(page)).toBe(0); |
| 102 | expect(await get(alice, "/")).not.toContain("<h2>Needs you</h2>"); | |
| 102 | expect(await get(alice, "/")).not.toContain('<section id="needs-you"'); | |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | 105 | test("/asks lands on the home's pinned needs-you, and a signed-out reader there gets the door, no asks", async () => { |
test/home-feed.test.ts +8 −7
| @@ -362,7 +362,7 @@ describe("home is the feed", () => { | ||
| 362 | 362 | expect(second.items.map((i: any) => i.id)).toEqual(whole.slice(2, 4)); |
| 363 | 363 | }); |
| 364 | 364 | |
| 365 | test("On you can be cleared in one move: a box each, select all, by kind and by repository, one form", async () => { | |
| 365 | test("The one list can be cleared in one move: a box each, select all, by kind and by repository, one form", async () => { | |
| 366 | 366 | const one = openIssue(app, alice, garden, { title: "Sweep the path" }); |
| 367 | 367 | const two = openIssue(app, alice, vault, { title: "Oil the hinges" }); |
| 368 | 368 | if (!one.ok || !two.ok) throw new Error("issues"); |
| @@ -370,14 +370,15 @@ describe("home is the feed", () => { | ||
| 370 | 370 | expect(assignIssue(app, alice, repo, issue, alice).ok).toBe(true); |
| 371 | 371 | const text = await (await get("/", aliceCookie)).text(); |
| 372 | 372 | const card = text.slice(text.indexOf('id="live-on-you"')).replace(/\s+/g, " "); |
| 373 | expect(card).toContain("On you"); | |
| 374 | expect(card).toContain(`value="issue:${one.value.id}"`); | |
| 375 | expect(card).toContain(`value="issue:${two.value.id}"`); | |
| 376 | expect((card.match(/form="onyou-bulk"/g) ?? []).length).toBeGreaterThanOrEqual(2); | |
| 373 | expect(card).toContain("things need you"); | |
| 374 | expect(card).toContain("assigned to you"); | |
| 375 | expect(card).toContain(`value="assigned:${one.value.id}"`); | |
| 376 | expect(card).toContain(`value="assigned:${two.value.id}"`); | |
| 377 | expect((card.match(/form="asks-bulk"/g) ?? []).length).toBeGreaterThanOrEqual(2); | |
| 377 | 378 | expect(card).toContain('data-pick="all"'); |
| 378 | 379 | expect(card).toContain("data-pick-repo"); |
| 379 | 380 | expect(card).toContain('<option value="' + garden.id + '">alice/garden</option>'); |
| 380 | expect(card).toContain('id="onyou-bulk"'); | |
| 381 | expect(card).toContain('id="asks-bulk"'); | |
| 381 | 382 | expect(card).toContain('action="/asks/bulk"'); |
| 382 | 383 | expect(card).toContain('name="reason"'); |
| 383 | 384 | expect(card).toContain(">Mute repository<"); |
| @@ -386,6 +387,6 @@ describe("home is the feed", () => { | ||
| 386 | 387 | expect(text).toContain("data-pick"); |
| 387 | 388 | // Alone, a single row stays a plain link. |
| 388 | 389 | const bobCard = await (await get("/", bobCookie)).text(); |
| 389 | expect(bobCard).not.toContain('id="onyou-bulk"'); | |
| 390 | expect(bobCard).not.toContain('id="asks-bulk"'); | |
| 390 | 391 | }); |
| 391 | 392 | }); |
test/web-apps.test.ts +6 −4
| @@ -817,15 +817,17 @@ describe("apps, runs and the crew on the web", () => { | ||
| 817 | 817 | ); |
| 818 | 818 | if (check?.state !== "ok") expect(page).toContain("Waiting on checks."); |
| 819 | 819 | |
| 820 | // On you: assigned to the viewer, the pull shows on the dashboard. | |
| 820 | // On you: assigned to the viewer, the pull is a row of the home's one | |
| 821 | // list, under the sentence that counts it. | |
| 821 | 822 | const assigned = await web(alice, `/alice/hello/pulls/${number}/assignee`, { |
| 822 | 823 | form: { assignee: "alice" }, |
| 823 | 824 | }); |
| 824 | 825 | expect(assigned.status).toBe(303); |
| 825 | 826 | const dash = await bodyOf(alice, "/"); |
| 826 | expect(dash).toContain("<h2>On you</h2>"); | |
| 827 | expect(dash).toContain('<section id="needs-you"'); | |
| 828 | expect(dash).toMatch(/<h2 id="asks-sentence">\d+ things? needs? you<\/h2>/); | |
| 827 | 829 | expect(dash).toContain("Say hello"); |
| 828 | expect(await bodyOf(bob, "/")).not.toContain("<h2>On you</h2>"); | |
| 830 | expect(await bodyOf(bob, "/")).not.toContain('<section id="needs-you"'); | |
| 829 | 831 | |
| 830 | 832 | // Needs you: a pull the crew opened, in a repository alice can write. |
| 831 | 833 | expect(addToCrew(app, aliceUser, hello, "bot").ok).toBe(true); |
| @@ -846,7 +848,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 846 | 848 | // racing to finish here: when it does, the dashboard links to /asks |
| 847 | 849 | // rather than listing it (test/asks.test.ts holds that page to account). |
| 848 | 850 | // Bob can read the public repo but not write it: not his inbox. |
| 849 | expect(await bodyOf(bob, "/")).not.toContain("<h2>Needs you</h2>"); | |
| 851 | expect(await bodyOf(bob, "/")).not.toContain('<section id="needs-you"'); | |
| 850 | 852 | }); |
| 851 | 853 | |
| 852 | 854 | test("the assignee select offers the people who can read the repository", async () => { |
new above
offline
withoutAsksis untested. Add a test that fails if asks fill the first page of the home feed.