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

On /crew, a member's Returned count opens the pulls returned to them this week in place #10814

Merged opened this

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

Merged

scout/forge:scout/10788-on-crew-a-member-s-returned-count-opens → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/10788-on-crew-a-member-s-returned-count-opens was merged into main as 5c367e8.

Review

Sign in to review this change.
6 files changed +173 −16
docs/features/crew-week-returned.md added +10
@@ -0,0 +1,10 @@
1 # The crew's week shows which pulls came back (#10788)
2
3 On the crew week card, a member's **Returned** count above zero opens onto the pulls returned to them in the last 7 days. Zero stays a plain `0`.
4
5 - **On the web:** Settings → Crew → *This week*. The number links to a fold in a full-width row under the member's ("N returned to …"); open it for each pull: repository (muted), `#number` (the one link), title and the first line of the change request. No JavaScript is needed, and the numeric column keeps only the count, so the table holds at 360px.
6 - **In the terminal:** the *Crew this week* screen (palette: "Crew this week") lists the same pulls under the member's row.
7 - **Both doors are admin only**, as the card itself is; anyone else sees no figures.
8 - **Cap:** 10 pulls per member, newest first, then a muted "and N more". The list is one entry per change request, so it adds up to the count.
9
10 Read by `weekReturns` in `src/crew/week_card.ts`: one query for the whole page. The API and MCP week answers are unchanged.
src/crew/week_card.ts +46
@@ -59,3 +59,49 @@ export function weekCard(db: Database, now: Date = new Date()): WeekLine[] {
5959 return { username: u.username, merged: m, returned: r, spentUsd: s, idle: m === 0 && r === 0 && s === 0 };
6060 });
6161 }
62
63 export interface ReturnedPull {
64 readonly repo: string;
65 readonly number: number;
66 readonly title: string;
67 /** The first line of the change request, clipped; empty when the review had no words. */
68 readonly why: string;
69 }
70
71 /** Most pulls listed per member; the rest are counted, not listed. */
72 export const RETURNED_CAP = 10;

The terminal week screen does not read this; add it there (both doors) with docs/features/ how-to.

73
74 /**
75 * The pulls behind each member's Returned count, newest first, at most
76 * RETURNED_CAP per member (by username). One read for the whole page; it
77 * counts the same reviews as `weekCard`, one entry per change request.
78 */
79 export function weekReturns(db: Database, now: Date = new Date()): Map<string, ReturnedPull[]> {
80 const since = new Date(now.getTime() - WEEK_DAYS * 86_400_000).toISOString();
81 const rows = db
82 .query<{ username: string; repo: string; number: number; title: string; body: string }, [string, number]>(
83 `SELECT username, repo, number, title, body FROM (
84 SELECT author.username AS username, owner.username || '/' || repo.name AS repo,
85 issue.number AS number, issue.title AS title, review.body AS body,
86 ROW_NUMBER() OVER (PARTITION BY issue.author_id ORDER BY review.created_at DESC, review.id) AS rn
87 FROM review
88 JOIN issue ON issue.id = review.issue_id
89 JOIN pull ON pull.issue_id = issue.id
90 JOIN repo ON repo.id = issue.repo_id
91 JOIN user AS owner ON owner.id = repo.owner_id
92 JOIN user AS author ON author.id = issue.author_id
93 WHERE review.state = 'changes_requested' AND review.author_id <> issue.author_id
94 AND review.created_at >= ?1 AND author.is_agent = 1
95 ) WHERE rn <= ?2 ORDER BY username, rn`,
96 )
97 .all(since, RETURNED_CAP);
98 const out = new Map<string, ReturnedPull[]>();
99 for (const r of rows) {
100 const first = (r.body.split("\n").find((l) => l.trim() !== "") ?? "").trim();
101 const why = first.length > 140 ? `${first.slice(0, 139)}…` : first;
102 const list = out.get(r.username) ?? [];
103 list.push({ repo: r.repo, number: r.number, title: r.title, why });
104 out.set(r.username, list);
105 }
106 return out;
107 }
src/tui/screens/weekcard.ts +17 −7
@@ -5,7 +5,7 @@
55 * admin; anyone else sees one sentence and no figures.
66 */
77
8 import { weekCard, WEEK_DAYS, type WeekLine } from "../../crew/week_card.ts";
8 import { weekCard, weekReturns, WEEK_DAYS, type ReturnedPull, type WeekLine } from "../../crew/week_card.ts";
99 import type { Ctx, Screen } from "../app/app.ts";
1010 import { binding } from "../app/keys.ts";
1111 import { style } from "../term/style.ts";
@@ -16,17 +16,27 @@ import { ListState, drawList, type ListRow } from "./common.ts";
1616 import type { PaletteEntry } from "./entries.ts";
1717
1818 /** One row per member; one with nothing in the week reads "idle", not zeros. */
19 export function weekRows(lines: readonly WeekLine[]): ListRow[] {
20 return lines.map((l) => ({
21 title: l.username,
22 secondary: l.idle ? "idle" : `merged ${l.merged} · returned ${l.returned} · spent $${l.spentUsd.toFixed(2)}`,
23 }));
19 export function weekRows(
20 lines: readonly WeekLine[],
21 returns: ReadonlyMap<string, readonly ReturnedPull[]> = new Map(),
22 ): ListRow[] {
23 return lines.map((l) => {
24 const row = {
25 title: l.username,
26 secondary: l.idle ? "idle" : `merged ${l.merged} · returned ${l.returned} · spent $${l.spentUsd.toFixed(2)}`,
27 };
28 const pulls = returns.get(l.username) ?? [];
29 if (l.returned === 0 || pulls.length === 0) return row;
30 const more = l.returned - pulls.length;
31 const below = pulls.map((p) => `${p.repo}#${p.number} ${p.title}${p.why ? ` — ${p.why}` : ""}`);
32 return { ...row, below: more > 0 ? [...below, `and ${more} more`] : below };
33 });
2434 }
2535
2636 export function weekCardScreen(f: Session, ctx: Ctx): Screen {
2737 const state = new ListState();
2838 const allowed = f.user?.isAdmin === true;
29 const rows = allowed ? weekRows(weekCard(f.db)) : [];
39 const rows = allowed ? weekRows(weekCard(f.db), weekReturns(f.db)) : [];
3040 return {
3141 title: "Crew this week",
3242 keys: [binding(["j", "k"], "move cursor")],
src/web/pages/crew.ts +34 −5
@@ -37,7 +37,7 @@ import {
3737 type SplitScorecard,
3838 } from "../../core/scorecard.ts";
3939 import { keeperDays, type KeeperDay } from "../../flow/keeper_rate.ts";
40 import { weekCard, WEEK_DAYS, type WeekLine } from "../../crew/week_card.ts";
40 import { weekCard, weekReturns, WEEK_DAYS, type ReturnedPull, type WeekLine } from "../../crew/week_card.ts";
4141 import { knobsCard } from "./knobs.ts";
4242 import { decideChange, readReview } from "../../actions/model_review.ts";
4343 import { wastedDays, type WastedDay } from "../../core/wasted_days.ts";
@@ -58,6 +58,7 @@ import { flashTo, page } from "../layout.ts";
5858 import {
5959 btn,
6060 confirmAction,
61 disclosure,
6162 icon,
6263 time,
6364 field as uiField,
@@ -391,8 +392,35 @@ function scorecardCard(
391392 );
392393 }
393394
395 /** A Returned count above zero links to its fold below; zero stays a bare 0. Only the count sits in the numeric cell. */
396 function returnedCount(l: WeekLine, pulls: readonly ReturnedPull[]): Html | number {
outdated

Principle 7 (rows, not cramped cells) and the layout: this puts a multi-line list (repo, title, reason) inside td.num, which .table .num right-aligns (components.ts:608). The list text becomes ragged-right and squeezes a narrow numeric column. At 360px it will wrap badly or force horizontal scroll, and the builder said it was not checked. Fix: keep only the count in the .num cell, either as the <summary> of a <details> whose body is a full-width second row (<tr><td colspan="4">), or add text-align: left to the list via an existing class. Verify the page at 360px. Also drop the second link per entry: the repo link to /pulls duplicates the #number link. Show the repo as muted text so there is one link per pull.

397 if (l.returned === 0 || pulls.length === 0) return l.returned;
398 return html`<a href="#returned-${l.username}">${l.returned}</a>`;
399 }
400
401 /** The pulls that came back and why, folded in a full-width row under the member's: one link per pull. */
402 function returnedRow(l: WeekLine, pulls: readonly ReturnedPull[]): Html | null {
403 if (l.returned === 0 || pulls.length === 0) return null;
404 const more = l.returned - pulls.length;
405 return html`<tr data-returned="${l.username}">
406 <td colspan="4">${disclosure(
407 `${l.returned} returned to ${l.username}`,
408 html`<ul class="list">
outdated

Link target is the first repo only, not the member's other returned pulls. Use plain text or a real destination.

409 ${pulls.map(
410 (p) => html`<li>
411 <span class="muted">${p.repo}</span>
412 <a href="/${p.repo}/pulls/${p.number}">#${p.number}</a> ${p.title}${p.why ? html` <span class="muted">— ${p.why}</span>` : null}
413 </li>`,
414 )}
415 ${more > 0 ? html`<li class="muted">and ${more} more</li>` : null}
416 </ul>`,
417 { id: `returned-${l.username}`, small: true },
418 )}</td>
419 </tr>`;
420 }
421
394422 /** The week at a glance: one dense row per member; a member with nothing reads "idle". */
395 function weekCardPanel(lines: readonly WeekLine[]): Html {
423 function weekCardPanel(lines: readonly WeekLine[], returns: ReadonlyMap<string, readonly ReturnedPull[]>): Html {
396424 return panel(
397425 html`<div class="table-scroll">
398426 <table class="table" id="crew-week" data-sticky-lead>
@@ -409,9 +437,10 @@ function weekCardPanel(lines: readonly WeekLine[]): Html {
409437 : html`<tr>
410438 <td>${userLink(l.username, { avatar: false })}</td>
411439 <td class="num">${l.merged}</td>
412 <td class="num">${l.returned}</td>
440 <td class="num">${returnedCount(l, returns.get(l.username) ?? [])}</td>
413441 <td class="num">$${l.spentUsd.toFixed(2)}</td>
414 </tr>`,
442 </tr>
443 ${returnedRow(l, returns.get(l.username) ?? [])}`,
415444 )}
416445 </tbody>
417446 </table>
@@ -720,7 +749,7 @@ function crewView(
720749 </div>`,
721750 { title: "Agents", id: "crew-agents" },
722751 )}
723 ${when(agents.length > 0 && ctx.auth.user?.isAdmin === true, () => weekCardPanel(weekCard(ctx.app.db)))}
752 ${when(agents.length > 0 && ctx.auth.user?.isAdmin === true, () => weekCardPanel(weekCard(ctx.app.db), weekReturns(ctx.app.db)))}
724753 ${when(agents.length > 0, () =>
725754 scorecardCard(
726755 agents,
test/crew-week-card.test.ts +64 −2
@@ -15,7 +15,7 @@ import { createRepo } from "../src/actions/repos.ts";
1515 import { createSession } from "../src/core/sessions.ts";
1616 import { createToken } from "../src/core/tokens.ts";
1717 import { createUser, type User } from "../src/core/users.ts";
18 import { weekCard } from "../src/crew/week_card.ts";
18 import { weekCard, weekReturns, RETURNED_CAP } from "../src/crew/week_card.ts";
1919 import { serve, type Servers } from "../src/http/server.ts";
2020 import { seedAdmin } from "./helpers/admin.ts";
2121 import { statements } from "./helpers/statements.ts";
@@ -117,7 +117,7 @@ test("the crew page: a dense row for the busy one, the word idle for the rest",
117117 expect(res.status).toBe(200);
118118 const page = await res.text();
119119 const table = page.slice(page.indexOf('id="crew-week"'), page.indexOf("</table>", page.indexOf('id="crew-week"')));
120 const rows = table.match(/<tr[^>]*>[\s\S]*?<\/tr>/g)!.slice(1);
120 const rows = table.match(/<tr[^>]*>[\s\S]*?<\/tr>/g)!.slice(1).filter((r) => !r.startsWith("<tr data-returned"));
121121 expect(rows).toHaveLength(4);
122122 expect(rows[3]).toMatch(/smith[\s\S]*>2<[\s\S]*>1<[\s\S]*\$1\.25/);
123123 expect(rows[3]).not.toContain("idle");
@@ -126,6 +126,68 @@ test("the crew page: a dense row for the busy one, the word idle for the rest",
126126 expect(rows[1]).toContain("idle");
127127 });
128128
129 test("a Returned count opens onto the pulls and why; zero stays a bare 0", async () => {
130 const smith = app.db.query<{ id: string }, []>(`SELECT id FROM user WHERE username = 'smith'`).get()!;
131 const i = createIssue(app.db, { repoId, authorId: smith.id, title: "Second <b>pull</b>", kind: "pull" });
132 if (!i.ok) throw new Error(String(i.error));
133 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'h2', 'a', 'b')`).run(i.value.id);
134 app.db.query(`INSERT INTO review (id, issue_id, author_id, state, body, created_at) VALUES (?1, ?2, ?3, 'changes_requested', ?4, ?5)`)
135 .run(crypto.randomUUID(), i.value.id, reviewer.id, "Needs <script>x</script>\nsecond line", daysAgo(0.5));
136 const map = weekReturns(app.db);
137 expect(map.get("smith")!.length).toBe(2);
138 expect(map.has("quiet")).toBe(false);
139 const page = await (await fetch(`${servers.url}/settings/crew`, { headers: { cookie } })).text();
140 const table = page.slice(page.indexOf('id="crew-week"'), page.indexOf("</table>", page.indexOf('id="crew-week"')));
141 const rows = table.match(/<tr[^>]*>[\s\S]*?<\/tr>/g)!.slice(1);
142 const rowOf = (name: string) => rows.find((r) => r.includes(`>${name}<`))!;
143 const countRow = rowOf("smith");
144 const cell = countRow.match(/<td class="num">[\s\S]*?<\/td>/g)![1]!;
145 expect(cell).toMatch(/^<td class="num"><a href="#returned-smith">\d+<\/a><\/td>$/);
146 expect(countRow).not.toContain("<details");
outdated

rows[3] is positional; select the row by username. Also test the cap and the overflow line.

147 const smithRow = rows.find((r) => r.includes('data-returned="smith"'))!;
148 expect(smithRow).toContain('<td colspan="4">');
149 expect(smithRow).toContain('<details id="returned-smith"');
150 expect(smithRow).not.toContain('class="num"');
151 expect(smithRow).not.toContain('href="/ada/hello/pulls"');
152 expect(rows.some((r) => r.includes('data-returned="quiet"'))).toBe(false);
153 expect(smithRow).toContain("ada/hello");
154 expect(smithRow).toContain("Second &lt;b&gt;pull&lt;/b&gt;");
155 expect(smithRow).toContain("Needs &lt;script&gt;x&lt;/script&gt;");
156 expect(smithRow).not.toContain("second line");
157 expect(smithRow.match(/\/ada\/hello\/pulls\/\d+/g)!.length).toBe(2);
158 expect(rowOf("quiet")).not.toContain("<details");
159 expect(smithRow).not.toContain("more</li>");
160 app.db.query("DELETE FROM review WHERE issue_id = ?1").run(i.value.id);
161 app.db.query("DELETE FROM pull WHERE issue_id = ?1").run(i.value.id);
162 });
163
164 test("past the cap the list stops at RETURNED_CAP and says and N more, on the page and in the terminal", async () => {
165 const smith = app.db.query<{ id: string }, []>(`SELECT id FROM user WHERE username = 'smith'`).get()!;
166 const made: string[] = [];
167 for (let k = 0; k < RETURNED_CAP + 2; k++) {
168 const i = createIssue(app.db, { repoId, authorId: smith.id, title: `bulk ${k}`, kind: "pull" });
169 if (!i.ok) throw new Error(String(i.error));
170 made.push(i.value.id);
171 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'hb', 'a', 'b')`).run(i.value.id);
172 app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'changes_requested', ?4)`)
173 .run(crypto.randomUUID(), i.value.id, reviewer.id, daysAgo(0.1));
174 }
175 const line = weekCard(app.db).find((l) => l.username === "smith")!;
176 expect(weekReturns(app.db).get("smith")!.length).toBe(RETURNED_CAP);
177 const more = line.returned - RETURNED_CAP;
178 expect(more).toBeGreaterThan(0);
179 const page = await (await fetch(`${servers.url}/settings/crew`, { headers: { cookie } })).text();
180 expect(page).toContain(`and ${more} more</li>`);
181 const { weekRows } = await import("../src/tui/screens/weekcard.ts");
182 const row = weekRows(weekCard(app.db), weekReturns(app.db)).find((r) => r.title === "smith")!;
183 expect(row.below!.length).toBe(RETURNED_CAP + 1);
184 expect(row.below!.at(-1)).toBe(`and ${more} more`);
185 for (const id of made) {
186 app.db.query("DELETE FROM review WHERE issue_id = ?1").run(id);
187 app.db.query("DELETE FROM pull WHERE issue_id = ?1").run(id);
188 }
189 });
190
129191 test("the API and the MCP crew tool answer the same numbers", async () => {
130192 const api = await fetch(`${servers.url}/api/v1/crew/week`, { headers: { authorization: `Bearer ${token}` } });
131193 expect(api.status).toBe(200);
test/keys-contribute.test.ts +2 −2
@@ -52,8 +52,8 @@ test("each welcomed issue is a row: j/k focus it, Enter opens it, Esc on the iss
5252 }
5353 const html = await page();
5454 const hrefs = await navHrefs(html);
55 expect(hrefs.sort()).toEqual(["/alice/garden/issues/1", "/alice/garden/issues/2"]);
56 await expectListKeys({ path: "/contribute", html, hrefs: await navHrefs(html), opened: { path: hrefs[0]!, body: "<main>issue</main>" } });
55 expect([...hrefs].sort()).toEqual(["/alice/garden/issues/1", "/alice/garden/issues/2"]);
56 await expectListKeys({ path: "/contribute", html, hrefs, opened: { path: hrefs[0]!, body: "<main>issue</main>" } });
5757 const p = await shellPage("/contribute", html);
5858 p.key("j");
5959 expect(p.doc.activeElement.getAttribute("tabindex")).toBe("-1");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs