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

Merged opened this

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

Merged

mason/forge:mason/1969-on-you-and-needs-you-are-one-list-on-the → main 7 commits fork 2 threads · 2 unresolved

  • Merged. mason/1969-on-you-and-needs-you-are-one-list-on-the was merged into main as b14f6d2.

Review

Sign in to review this change.
18 files changed +398 −221
docs/DESIGN.md +1 −1
@@ -202,7 +202,7 @@ specification.
202202 | **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 |
203203 | **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 |
204204 | **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 |
206206
207207 Two things are deliberately **not** in the vocabulary: skeleton loaders (the
208208 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`.
132132
133133 ## Clearing what is on you in one move
134134
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
136136 set: tick boxes, or *Select all*, a kind, or a repository, then one verb for
137137 all of it — **close** (one `reason`, written on each as "Closed: …"),
138138 **mute** (the repository goes quiet in your feed and On you; asks are never
139139 muted away), **ship**, or on asks **decline** (dismiss) and hand back. It is
140140 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
143143 (`POST /api/v1/asks/bulk/<id>/undo`) is kept for a day. The MCP's `asks_act`
144144 takes the same set, verb and reason.
scripts/gate.ts +1
@@ -210,6 +210,7 @@ export const GATE: readonly string[] = [
210210 "test/review-wait.test.ts",
211211 "test/asks-bulk.test.ts",
212212 "test/asks-waiting-for.test.ts",
213 "test/asks-one-list.test.ts",
213214 "test/hand-decider.test.ts",
214215 "test/decisions-by-doors.test.ts",
215216 "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 =>
167167
168168 /**
169169 * 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.
171171 */
172172 export function latestUndoable(
173173 app: App,
src/core/waiting_for.ts +1 −1
@@ -18,7 +18,7 @@ export interface WaitingFor {
1818
1919 export interface WaitFacts {
2020 /** 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";
2222 /** Files stopping the pull from merging, as the last sweep found it. */
2323 readonly conflicts: number;
2424 /** 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(
3535 export function readFeed(
3636 db: Database,
3737 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 } = {},
3945 ): { items: FeedItem[]; next_cursor: string | null } {
4046 const now = options.now ?? Date.now();
4147 const limit = Math.min(options.limit ?? FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX);
@@ -65,7 +71,9 @@ export function readFeed(
6571 c.kind === "choose" ||
6672 !muted.has(c.repoId)),
6773 );
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);

withoutAsks is untested. Add a test that fails if asks fill the first page of the home feed.

6977 const cursor = options.cursor;
7078 if (cursor) {
7179 const at = ranked.findIndex((i) => i.id === cursor.id);
src/web/pages/site.ts +2 −120
@@ -9,32 +9,21 @@
99 */
1010
1111 import { limitValue } from "../../settings/limits.ts";
12 import { mayView } from "../../core/sealed.ts";
1312 import { signOutHere } from "../../actions/signins.ts";
1413 import { clientAddress } from "../../http/peer.ts";
1514 import { verifyEmail } from "../../actions/verify.ts";
1615 import { STRANGER_MAX } from "../../http/limits.ts";
1716 import { createRepo } from "../../actions/repos.ts";
1817 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";
2218 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
2319 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";
3021 import { createSession } from "../../core/sessions.ts";
3122 import { hasAnyUser, verifyPassword, type User } from "../../core/users.ts";
3223 import { isThemeName } from "../../design/themes.ts";
3324 import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts";
3425 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";
3827 import { edgeLoginUrl } from "../../http/edge.ts";
3928 import { sshCommand } from "../../ssh/command.ts";
4029 import { hostOf, pageHead, signInFirst } from "../chrome.ts";
@@ -52,7 +41,6 @@ import {
5241 listCard,
5342 row,
5443 selectField,
55 time,
5644 } from "../ui.ts";
5745 import { existsSync } from "node:fs";
5846 import { join } from "node:path";
@@ -176,112 +164,6 @@ export function downloadsCard(ctx: Ctx, user: User): Html {
176164 );
177165 }
178166
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
285167 export function orgsCard(ctx: Ctx, user: User): Html {
286168 const orgs = listOrgsForUser(ctx.app.db, user.id);
287169 // 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 {
2929 listOpenPullsVisibleTo,
3030 type Issue,
3131 } from "../../core/issues.ts";
32 import { mayView } from "../../core/sealed.ts";
3233 import { looksForApps } from "../../core/looks.ts";
3334 import { closesFromBody, getPull, joinMany, type Pull } from "../../core/pulls.ts";
3435 import {
@@ -104,7 +105,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) {
104105 );
105106 }
106107
107 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover";
108 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned";
108109
109110 /** Dollars as a person reads them: cents when small, whole when not. */
110111 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>> = {
172173 options: ["Answer the planner"],
173174 ifNothing: "the planner plans nothing more for this app",
174175 },
176 assigned: {
177 decision: "This is assigned to you: act on it?",
178 options: ["Open it"],
179 ifNothing: "it stays assigned to you",
180 },
175181 paused: {
176182 decision: "Raise today's ceiling?",
177183 options: ["Raise it in settings"],
@@ -199,6 +205,7 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = {
199205 handover: { score: 55, why: "three reviews without an approval: handed to a person" },
200206 parked: { score: 40, why: "parked by the crew" },
201207 look: { score: 30, why: "green, waiting" },
208 assigned: { score: 20, why: "assigned to you" },
202209 };
203210
204211 /** When a person last spoke on a conversation, or its start. */
@@ -432,6 +439,36 @@ function askCandidatesFor(
432439 who: "the planner",
433440 decline: { runId: d.runId, words: d.words },
434441 });
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 }
435472 return out;
436473 }
437474
@@ -509,8 +546,9 @@ function consequencesOf(
509546 }
510547
511548 /** 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}`;
514552
515553 /**
516554 * The asks the viewer has not set aside: a muted repository's asks are all
@@ -682,6 +720,10 @@ export function endOf(
682720 if (pull !== undefined && pull.state !== "open") return "acted";
683721 return getHold(db, what)?.heldBy === viewerId ? "acted" : "expired";
684722 }
723 if (kind === "assigned") {
724 const issue = getIssueById(db, what);
725 return issue !== undefined && issue.state !== "open" ? "acted" : "expired";
726 }
685727 if (kind === "parked") {
686728 const issue = getIssueById(db, what);
687729 if (issue === undefined) return "expired";
@@ -749,6 +791,8 @@ export function recordAskLedger(db: Database, now = new Date()): void {
749791 /** One ask in a line, without its repository's name (the row adds that). */
750792 function lineOf(c: AskCandidate): string {
751793 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`;
752796 if (c.kind === "parked")
753797 return `the crew parked #${c.issue!.number} after three failed runs`;
754798 if (c.kind === "declined")
@@ -782,6 +826,7 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> =
782826 handover: (n) => `${n} changes had three reviews asking for changes and wait for you`,
783827 declined: (n) => `the planner declined ${n} times and waits for you`,
784828 paused: (n) => `${n} pauses at the ceiling`,
829 assigned: (n) => `${n} things assigned to you`,
785830 };
786831
787832 /** Every kind has its own: a row never says "green changes" of something that is not one. */
@@ -842,7 +887,7 @@ export function askLinesFor(
842887 const line = `${row.repo.name}${only.kind === "paused" ? " " : ": "}${lineOf(only)}`;
843888 if (only.kind === "paused")
844889 return { ...about, line, link: `${base}/settings/autonomy` };
845 if (only.kind === "parked" || only.kind === "declined")
890 if (only.pull === undefined && only.issue !== undefined)
846891 return {
847892 ...about,
848893 line,
@@ -894,6 +939,18 @@ export function decisionsFor(
894939 );
895940 };
896941 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 }
897954 if (c.kind === "parked" && c.issue)
898955 return {
899956 kind: "parked",
@@ -1060,6 +1117,15 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others:
10601117 }),
10611118 ],
10621119 };
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 };
10631129 if (d.kind === "declined" && d.issue)
10641130 return {
10651131 primary: btn("Answer", {
@@ -1146,7 +1212,7 @@ export function asksAnswer(ctx: Ctx, user: User) {
11461212
11471213 /**
11481214 * 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
11501216 * what its undo takes back (`fleet/verbs.ts`).
11511217 */
11521218 export function askItemsOf(issue: Issue): string[] {
@@ -1156,8 +1222,9 @@ export function askItemsOf(issue: Issue): string[] {
11561222 `ask:review:${issue.id}`,
11571223 `ask:stopped:${issue.id}`,
11581224 `ask:handover:${issue.id}`,
1225 `ask:assigned:${issue.id}`,
11591226 ]
1160 : [`ask:parked:${issue.id}`];
1227 : [`ask:parked:${issue.id}`, `ask:assigned:${issue.id}`];
11611228 }
11621229
11631230 /**
@@ -1207,8 +1274,9 @@ export function askTargetsFor(
12071274 );
12081275 const issueIds: string[] = [];
12091276 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: issue:<id> is still accepted, but the home no longer emits it. Say that plainly (kept for the API and MCP callers), or drop the branch if nothing uses it.

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.
12121280 const assigned = new Map(
12131281 listAssignedTo(db, user.id, 200).map((i) => [`issue:${i.id}`, i]),
12141282 );
src/web/studio/asks_page.ts +21 −50
@@ -11,6 +11,7 @@ import type { WaitingFor } from "../../core/waiting_for.ts";
1111 import {
1212 applyAsksBulk,
1313 BULK_MAX,
14 latestUndoable,
1415 ownAsksOperation,
1516 undoAsksBulk,
1617 previewAsksBulk,
@@ -60,15 +61,16 @@ const ASK_KIND_WORDS: Readonly<Record<string, string>> = {
6061 review: "Reviews",
6162 stopped: "Stopped",
6263 parked: "Parked",
64 assigned: "Assigned",
6365 };
6466
6567 /** 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"]);
6769
6870 /** A row's box for the bulk form, which sits apart (`form="asks-bulk"`): forms do not nest. */
6971 const pick = (d: Decision, picked: ReadonlySet<string>): Html | null =>
7072 BULK_KINDS.has(d.kind)
71 ? html`<input
73 ? html`<label class="ask-pick-hit"><input
7274 type="checkbox"
7375 class="ask-pick"
7476 name="item"
@@ -78,7 +80,7 @@ const pick = (d: Decision, picked: ReadonlySet<string>): Html | null =>
7880 data-kind="${d.kind}"
7981 ${picked.has(d.id) ? html`checked` : null}
8082 aria-label="Select ${d.pull?.title ?? d.issue?.title ?? d.id}"
81 />`
83 /></label>`
8284 : null;
8385
8486 /**
@@ -166,6 +168,7 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = {
166168 parked: "Parked",
167169 declined: "Declined",
168170 paused: "Paused",
171 assigned: "Assigned",
169172 };
170173
171174 /**
@@ -266,12 +269,12 @@ function rowCard(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Ht
266269 id: askAnchor(r.id),
267270 extra: "ask-card",
268271 head: html`${BULK_KINDS.has(r.kind)
269 ? html`<input
272 ? html`<label class="ask-pick-hit"><input
270273 type="checkbox"
271274 class="ask-pick"
272275 data-select-all
273276 aria-label="Select all ${r.items.length}"
274 />`
277 /></label>`
275278 : null}
276279 <h2>${rowHead(r.kind, r.items.length)}</h2>
277280 <span class="muted small">${r.repo.ownerName}/${r.repo.name}</span>${waitChip(r.items[0]!.waiting)}`,
@@ -374,10 +377,19 @@ export function needsYouSection(
374377 >
375378 <button type="submit" class="btn btn-sm btn-ghost">Listen again</button>
376379 </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>`;
377389 const count = rows.length + needs.length;
378390 if (count === 0 && top === undefined)
379391 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}`,
381393 scriptJs: "",
382394 };
383395 // The forge's own health first, then the few asks that matter most;
@@ -428,6 +440,7 @@ export function needsYouSection(
428440 ${reasonInput(top?.reason)} ${btn("Preview", { size: "sm" })}
429441 <span class="muted small">on the selected asks</span>
430442 </form>`}
443 ${silencedNote} ${undo}
431444 ${decisions.map((d) => decisionForm(d, ctx.url.pathname))}
432445 </section>`;
433446 return { body, scriptJs: SELECT_JS };
@@ -452,11 +465,8 @@ const VERB_WORDS: Readonly<Record<string, string>> = {
452465 replan: "Hand back to the planner",
453466 };
454467
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;
460470
461471 /** One reason, written on each pull or issue closed. */
462472 const reasonInput = (value = ""): Html =>
@@ -495,45 +505,6 @@ export function pickControls(
495505 </span>`;
496506 }
497507
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
537508 const shown = (v: FleetValue): string =>
538509 v === null ? "nobody" : Array.isArray(v) ? v.join(", ") : String(v);
539510
src/web/studio/feed.ts +1 −3
@@ -241,9 +241,7 @@ function chipsBar(user: User, ctx: Ctx, items: readonly FeedItem[]): Html {
241241 export function feedRegion(user: User, ctx: Ctx): Html {
242242 // What needs you is pinned above the timeline (`needsYouSection`), so an
243243 // 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;
247245 const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString());
248246 const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id));
249247 // Who each poster is, asked once for the page (`core/marks.ts`).
src/web/studio/home.ts +9 −6
@@ -40,7 +40,6 @@ import {
4040 } from "../ui.ts";
4141 import {
4242 downloadsCard,
43 onYouCard,
4443 orgsCard,
4544 terminalCard,
4645 } from "../pages/site.ts";
@@ -127,14 +126,19 @@ const appsRegion = (ctx: Ctx, user: User): Html =>
127126 ${yourApps(ctx, user)}
128127 </div>`;
129128
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 =>
131135 html`<div
132136 id="live-on-you"
133137 data-live="${ON_YOU_LIVE}"
134138 data-live-src="/live/on-you"
135139 >
136140 ${note === undefined ? null : html`<p class="small" role="status">${note}</p>`}
137 ${onYouCard(ctx, user) ?? null}
141 ${section}
138142 </div>`;
139143
140144 // GET /live/apps — your apps alone, for the region that shows them.
@@ -145,7 +149,7 @@ export function liveAppsFragment(ctx: Ctx): Response {
145149 }
146150
147151 /**
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
149153 * region as it is now and the top bar's Asks link with its new count.
150154 */
151155 export function onYouAnswer(ctx: Ctx, user: User, note?: string): Response {
@@ -220,7 +224,7 @@ export async function studioHome(
220224 ${homeTabs("feed")}
221225 <div class="dash">
222226 <div class="dash-main stack">
223 ${needsYou.body} ${composer(ctx, user)}
227 ${onYouRegion(ctx, user, undefined, needsYou.body)} ${composer(ctx, user)}
224228 <div class="bar feed-head">
225229 <span class="spacer"></span>
226230 <a class="small muted" href="/asked">What you asked for</a>
@@ -237,7 +241,6 @@ export async function studioHome(
237241 user.isAdmin || appRepos.length > 0 ? crewKeyNote(ctx) : null
238242 }
239243 ${appsRegion(ctx, user)}
240 ${onYouRegion(ctx, user)}
241244 ${terminalCard(ctx)} ${downloadsCard(ctx, user)}
242245 ${orgsCard(ctx, user)}
243246 </aside>
src/web/style/content.ts +7 −2
@@ -1204,7 +1204,10 @@ textarea.editor:focus { outline: none; }
12041204 }
12051205 /* What needs you, pinned on the home: the bulk step's select as wide as
12061206 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; }
12081211 /* A phone shows the top asks and one "N more" that opens in place to all
12091212 of them; a wider screen shows ASKS_SHOWN and its own "N more" past that.
12101213 Only where a details element can be held open: elsewhere both summaries
@@ -1256,7 +1259,9 @@ textarea.editor:focus { outline: none; }
12561259 .ask-card .ask-kind::after { content: " ·"; font-weight: 400; }
12571260 .ask-card .ask-short { display: none; }
12581261 .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; }
12601265 .ask-card .ask-group > summary { padding: 10px 16px; min-height: 44px; }
12611266 .ask-card .ask-group .row { flex-wrap: wrap; padding-inline: 16px; }
12621267 .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";
2121 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
2222 import { assignIssue } from "../src/actions/issues.ts";
2323 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";
2525 import { getPull, type Pull } from "../src/core/pulls.ts";
2626 import type { Repo } from "../src/core/repos.ts";
2727 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
@@ -634,12 +634,14 @@ describe("acting on many asks at once", () => {
634634 const issue = getIssueById(app.db, pull.id)!;
635635 expect(assignIssue(app, alice, open, issue, alice).ok).toBe(true);
636636 }
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;
639639 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 + '"');
643645 expect(card(alice)).toContain("data-pick");
644646 const done = await api(aliceRw, "/api/v1/asks/bulk", {
645647 action: "mute",
@@ -713,8 +715,9 @@ describe("acting on many asks at once", () => {
713715 const b = await greenPull(open, "Muted two");
714716 for (const pull of [a, b])
715717 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");
718721 const done = await api(aliceRw, "/api/v1/asks/bulk", {
719722 action: "mute",
720723 items: [`issue:${a.id}`, `issue:${b.id}`],
@@ -722,7 +725,7 @@ describe("acting on many asks at once", () => {
722725 });
723726 expect(done.status).toBe(201);
724727 // 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");
726729 expect(card()).toContain('action="/asks/listen"');
727730 const headers = { cookie: `${aliceCookie}; forge_mode=studio`, origin: servers.url };
728731 const res = await fetch(`${servers.url}/asks/listen`, {
@@ -734,7 +737,7 @@ describe("acting on many asks at once", () => {
734737 expect(res.status).toBe(303);
735738 expect(mutedRepos(app.db, alice.id).has(open.id)).toBe(false);
736739 expect(card()).toContain("Muted one");
737 expect(card()).not.toContain("repository muted");
740 expect(card()).not.toContain("muted repository still asks");
738741 });
739742 test("on the card, a refusal answers 422 with its message, and a partial set says what was not done", async () => {
740743 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", () => {
247247 ),
248248 ).toBe(false);
249249 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.
251251 setAssignee(app.db, plums.id, aliceUser.id);
252252 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"]);
254255 // Its own page still says why it cannot ship.
255256 const change = await get(
256257 `/${repo.ownerName}/${repo.name}/pulls/${plums.number}`,
257258 );
258259 expect(change).toContain("README.md");
260 setAssignee(app.db, plums.id, null);
259261 });
260262 test("a row dismissed while a pull conflicted asks again when it merges again, with no new check", async () => {
261263 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 &lt;the&gt; 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", () => {
9999 const page = await get(alice, "/");
100100 expect(sentence(page)).toBe("All fine");
101101 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"');
103103 });
104104
105105 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", () => {
362362 expect(second.items.map((i: any) => i.id)).toEqual(whole.slice(2, 4));
363363 });
364364
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 () => {
366366 const one = openIssue(app, alice, garden, { title: "Sweep the path" });
367367 const two = openIssue(app, alice, vault, { title: "Oil the hinges" });
368368 if (!one.ok || !two.ok) throw new Error("issues");
@@ -370,14 +370,15 @@ describe("home is the feed", () => {
370370 expect(assignIssue(app, alice, repo, issue, alice).ok).toBe(true);
371371 const text = await (await get("/", aliceCookie)).text();
372372 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);
377378 expect(card).toContain('data-pick="all"');
378379 expect(card).toContain("data-pick-repo");
379380 expect(card).toContain('<option value="' + garden.id + '">alice/garden</option>');
380 expect(card).toContain('id="onyou-bulk"');
381 expect(card).toContain('id="asks-bulk"');
381382 expect(card).toContain('action="/asks/bulk"');
382383 expect(card).toContain('name="reason"');
383384 expect(card).toContain(">Mute repository<");
@@ -386,6 +387,6 @@ describe("home is the feed", () => {
386387 expect(text).toContain("data-pick");
387388 // Alone, a single row stays a plain link.
388389 const bobCard = await (await get("/", bobCookie)).text();
389 expect(bobCard).not.toContain('id="onyou-bulk"');
390 expect(bobCard).not.toContain('id="asks-bulk"');
390391 });
391392 });
test/web-apps.test.ts +6 −4
@@ -817,15 +817,17 @@ describe("apps, runs and the crew on the web", () => {
817817 );
818818 if (check?.state !== "ok") expect(page).toContain("Waiting on checks.");
819819
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.
821822 const assigned = await web(alice, `/alice/hello/pulls/${number}/assignee`, {
822823 form: { assignee: "alice" },
823824 });
824825 expect(assigned.status).toBe(303);
825826 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>/);
827829 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"');
829831
830832 // Needs you: a pull the crew opened, in a repository alice can write.
831833 expect(addToCrew(app, aliceUser, hello, "bot").ok).toBe(true);
@@ -846,7 +848,7 @@ describe("apps, runs and the crew on the web", () => {
846848 // racing to finish here: when it does, the dashboard links to /asks
847849 // rather than listing it (test/asks.test.ts holds that page to account).
848850 // 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"');
850852 });
851853
852854 test("the assignee select offers the people who can read the repository", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs