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

A ship, decline or say taken on /next can be undone for 60 seconds from the page it was taken on #11827

Merged opened this

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

Merged

mason/forge:mason/11814-a-ship-decline-or-say-taken-on-next-can → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/11814-a-ship-decline-or-say-taken-on-next-can was merged into main as c4ad86c.

Review

Sign in to review this change.
7 files changed +184 −21
src/actions/asks_bulk.ts +39 −8
@@ -9,12 +9,14 @@
99 */
1010
1111 import type { App } from "../app.ts";
12 import { listOperations, type Operation } from "../core/fleet_ops.ts";
12 import { latestDecision, type Operation } from "../core/fleet_ops.ts";
1313 import type { User } from "../core/users.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
1515 import { askTargetsFor } from "../web/studio/asks.ts";
1616 import {
1717 applyFleetIds,
18 ASK_VERBS,
19 ASKS_QUERY_PREFIX,
1820 getFleetOp,
1921 isAsksOperation,
2022 UNDO_WINDOW_MS,
@@ -23,7 +25,7 @@ import {
2325 type FleetError,
2426 type Plan,
2527 } from "./fleet.ts";
26 import { NO_SUCH_OPERATION, receiptSentence, SIGN_IN_TO_ACT } from "./asks_words.ts";
28 import { DONE, DONE_LINE_MS, DONE_NO_UNDO, DONE_UNDO, NO_SUCH_OPERATION, receiptSentence, SIGN_IN_TO_ACT } from "./asks_words.ts";
2729
2830 /**
2931 * What a bulk action on asks may be: the fleet verb each one is. Decline is
@@ -92,7 +94,7 @@ function resolve(
9294 : verb,
9395 ids: issueIds,
9496 skipped: refused,
95 query: `asks: ${list.length} selected`,
97 query: `${ASKS_QUERY_PREFIX}${list.length} selected`,
9698 });
9799 }
98100
@@ -125,7 +127,7 @@ export async function applyAsksBulk(
125127 action: unknown,
126128 items: unknown,
127129 reason?: unknown,
128 ): Promise<Result<Operation & { skipped: Skipped[] }, FleetError>> {
130 ): Promise<Result<Operation & { skipped: Skipped[]; line: string }, FleetError>> {
129131 const r = resolve(app, actor, action, items, reason);
130132 if (!r.ok) return r;
131133 const op = await applyFleetIds(
@@ -137,7 +139,7 @@ export async function applyAsksBulk(
137139 r.value.verb,
138140 );
139141 if (!op.ok) return op;
140 return ok({ ...op.value, skipped: r.value.skipped });
142 return ok({ ...op.value, skipped: r.value.skipped, line: doneWords(op.value.reversible) });
141143 }
142144
143145 /**
@@ -166,6 +168,10 @@ export const undoable = (op: Operation, now = Date.now()): boolean =>
166168 op.undoneBy === null &&
167169 now - Date.parse(op.createdAt) <= UNDO_WINDOW_MS;
168170
171 /** The viewer's newest bulk action on asks that is not an undo, read by kind, never from a window of recent receipts. */
172 const latestAsksDecision = (app: App, actor: User): Operation | undefined =>
173 latestDecision(app.db, actor.id, { kind: "issue", queryPrefix: ASKS_QUERY_PREFIX, verbs: ASK_VERBS });
174
169175 /**
170176 * The viewer's latest bulk action on asks that can still be undone — the
171177 * one undo link the home's On you list keeps for a day. Nothing when none.
@@ -176,13 +182,38 @@ export function latestUndoable(
176182 now = Date.now(),
177183 ): Operation | undefined {
178184 if (!actor) return undefined;
179 const op = listOperations(app.db, actor.id, 5).find(
180 (o) => isAsksOperation(o) && o.undoOf === null,
181 );
185 const op = latestAsksDecision(app, actor);
182186 if (op === undefined || !undoable(op, now)) return undefined;
183187 return ownAsksOperation(app, actor, op.id);
184188 }
185189
190 /** Whether a decision taken at `createdAt` is still inside the minute its Done line is kept. */
191 export const inDoneMinute = (createdAt: string, now = Date.now()): boolean =>
192 now - Date.parse(createdAt) <= DONE_LINE_MS;
193
194 /** The line a decision leaves: `Done. Undo` when it can be taken back, the reason it cannot when a merge landed. */
195 export const doneWords = (reversible: boolean): string => (reversible ? DONE_UNDO : DONE_NO_UNDO);
196
197 /**
198 * The one line a decision leaves for a minute (#11814), for the page and the
outdated

Audit: the decision and undo are not shown to extend #11754 trail; add that and a test. listOperations(...,5) then find can miss the latest decision if five undos intervene; query by kind instead.

199 * terminal alike. Keyed to the operation's row, so a restart keeps it;
200 * nothing once it was undone, changed nothing, or the minute has passed.
201 * `undo` says whether the Undo is offered; `say` is what the line says apart
202 * from the Undo button.
203 */
204 export function doneLine(
205 app: App,
206 actor: User | undefined,
207 now = Date.now(),
208 ): { operation: Operation; line: string; say: string; undo: boolean } | undefined {
209 if (!actor) return undefined;
210 const op = latestAsksDecision(app, actor);
211 if (op === undefined || op.undoneBy !== null || !inDoneMinute(op.createdAt, now)) return undefined;
212 const own = ownAsksOperation(app, actor, op.id);
213 if (own === undefined || !own.rows.some((r) => r.outcome === "ok")) return undefined;
214 return { operation: own, line: doneWords(own.reversible), say: own.reversible ? DONE : DONE_NO_UNDO, undo: own.reversible };
215 }
216
186217 /** Undo one's own bulk action on asks, once, within a day (`undoFleetOp` holds the window). */
187218 export async function undoAsksBulk(
188219 app: App,
src/actions/asks_words.ts +15
@@ -39,3 +39,18 @@ export function receiptSentence(verb: string, done: number, undo: boolean): stri
3939 const word = Object.hasOwn(VERB_WORDS, verb) ? VERB_WORDS[verb]! : verb;
4040 return undo ? `${word} undone: ${done}` : `${word}: ${done} done`;
4141 }
42
43 /** How long, after a decision, the page keeps its one "Done. Undo" line (#11814). */
44 export const DONE_LINE_MS = 60_000;
45
46 /** The first word of the line after a decision, and what the line says alone when nothing can be taken back. */
47 export const DONE = "Done.";
48
49 /** The label of the button (and the terminal's key) that takes a decision back. */
50 export const UNDO_LABEL = "Undo";
51
52 /** The line after a decision that can be taken back, as MCP returns it: the page shows `DONE` and a button labelled `UNDO_LABEL`. */
53 export const DONE_UNDO = `${DONE} ${UNDO_LABEL}`;
54
55 /** The line after a decision that cannot be taken back: a landed merge stays landed. */
56 export const DONE_NO_UNDO = `${DONE} A merge that has landed cannot be taken back, so there is no Undo.`;
src/actions/fleet.ts +5 −2
@@ -601,12 +601,15 @@ export async function applyPreviewed(
601601 /** How long a bulk action on asks can be undone: a day. */
602602 export const UNDO_WINDOW_MS = 86_400_000;
603603
604 const ASK_VERBS: readonly string[] = ["dismiss", "mute", "ship", "close", "replan"];
604 export const ASK_VERBS: readonly string[] = ["dismiss", "mute", "ship", "close", "replan"];
605
606 /** The query a bulk action on asks is receipted under: "asks: n selected". */
607 export const ASKS_QUERY_PREFIX = "asks: ";
605608
606609 /** Whether a receipt is a bulk action on asks: named one by one, "asks: n selected". */
607610 export function isAsksOperation(op: Operation): boolean {
608611 return (
609 op.kind === "issue" && op.query.startsWith("asks: ") && ASK_VERBS.includes(op.verb)
612 op.kind === "issue" && op.query.startsWith(ASKS_QUERY_PREFIX) && ASK_VERBS.includes(op.verb)
610613 );
611614 }
612615
src/core/fleet_ops.ts +22
@@ -181,6 +181,28 @@ export function listOperations(
181181 .map((op) => toOperation(op, []));
182182 }
183183
184 /**
185 * An actor's newest operation that is not an undo, of one kind, whose query
186 * starts with `queryPrefix` and whose verb is one of `verbs`; asked of the
187 * table by those columns, so any number of undos between never hides it.
188 */
189 export function latestDecision(
190 db: Database,
191 actorId: string,
192 where: { kind: string; queryPrefix: string; verbs: readonly string[] },
193 ): Operation | undefined {
194 const marks = where.verbs.map((_, i) => `?${i + 4}`).join(", ");
195 const op = db
196 .query<OperationRecord, (string | number)[]>(
197 `${SELECT} WHERE fleet_operation.actor_id = ?1 AND fleet_operation.kind = ?2
198 AND fleet_operation.undo_of IS NULL AND substr(fleet_operation.query, 1, length(?3)) = ?3
199 AND fleet_operation.verb IN (${marks})
200 ORDER BY fleet_operation.id DESC LIMIT 1`,
201 )
202 .get(actorId, where.kind, where.queryPrefix, ...where.verbs);
203 return op === null || op === undefined ? undefined : toOperation(op, []);
204 }
205
184206 function toOperation(op: OperationRecord, rows: RowRecord[]): Operation {
185207 return {
186208 id: op.id,
src/tui/screens/dashboard.ts +9 −1
@@ -53,6 +53,8 @@ import type { Proposal } from "../../core/proposals.ts";
5353 import type { App } from "../../app.ts";
5454 import {
5555 applyAsksBulk,
56 doneLine,
57 inDoneMinute,
5658 BULK_MAX,
5759 latestUndoable,
5860 previewAsksBulk,
@@ -200,6 +202,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
200202 /** The rows marked for one verb, by issue id. */
201203 const marked = new Set<string>();
202204 let lastSet: Operation | undefined;
205 /** The decision's Done line, as the page keeps it for a minute (`doneLine`). */
206 let doneSet: ReturnType<typeof doneLine>;
203207 /** What Home and /review count: live asks waiting on the viewer, and the stale ones folded apart. */
204208 let waitingSet: ReviewSet | undefined;
205209 /** The repositories marked for archiving, by id; only ones the viewer administers. */
@@ -432,6 +436,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
432436 for (const id of [...marked])
433437 if (!onYou.some((item) => item.issue.id === id)) marked.delete(id);
434438 lastSet = f.user ? latestUndoable(f.app, f.user) : undefined;
439 doneSet = f.user ? doneLine(f.app, f.user) : undefined;
435440 const now = new Date();
436441 holds = new Map(
437442 all.flatMap((repo) => {
@@ -1497,7 +1502,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
14971502 parts.push(
14981503 `${silenced.size} ${silenced.size === 1 ? "repository" : "repositories"} muted · L listen again`,
14991504 );
1500 if (lastSet !== undefined) parts.push(`u undo ${lastSet.verb}`);
1505 // The minute's Done line leads the undo, the page's words and gate; after the minute the day's undo stays.
1506 const doneNow = doneSet !== undefined && inDoneMinute(doneSet.operation.createdAt) ? doneSet : undefined;
1507 if (doneNow !== undefined && !doneNow.undo) parts.push(doneNow.line);
1508 else if (lastSet !== undefined) parts.push(`${doneNow === undefined ? "" : `${doneNow.line} · `}u undo ${lastSet.verb}`);
15011509 if (asideWaves.length > 0) parts.push(`${asideWaves.length} ${asideWaves.length === 1 ? "wave" : "waves"} put aside · B bring back`);
15021510 return parts.length > 0 ? parts.join(" · ") : undefined;
15031511 }
src/web/studio/asks_page.ts +25 −10
@@ -7,12 +7,13 @@
77 * (`actions/asks_bulk.ts`). `/asks` was its own screen; it lands here now.
88 */
99
10 import { VERB_WORDS } from "../../actions/asks_words.ts";
10 import { UNDO_LABEL, VERB_WORDS } from "../../actions/asks_words.ts";
1111 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1212 import type { WaitingFor } from "../../core/waiting_for.ts";
1313 import {
1414 applyAsksBulk,
1515 BULK_MAX,
16 doneLine,
1617 latestUndoable,
1718 ownAsksOperation,
1819 undoAsksBulk,
@@ -425,6 +426,28 @@ function handedTop(ctx: Ctx, decisionId: string): AsksTop | undefined {
425426 };
426427 }
427428
429 /**
430 * The one status bar a decision leaves, always in this slot so nothing moves:
431 * for a minute the Done line (`Done.` and an Undo, or why a landed merge has
432 * none), then, up to a day, the receipt with its Undo.
433 */
434 function undoSlot(ctx: Ctx, user: User | undefined): Html | null {
435 const done = doneLine(ctx.app, user);
436 if (done !== undefined) return undoBar(done.say, done.undo ? done.operation.id : undefined);
437 const last = latestUndoable(ctx.app, user);
438 return last === undefined ? null : undoBar(receiptOf(last), last.id, "kept for a day");
439 }
440
441 /** One bar: what was done, and, when it can be taken back, the Undo (a same-origin POST) and a note. */
442 function undoBar(say: string, operationId?: string, note?: string): Html {
443 if (operationId === undefined) return html`<p class="bar small" role="status">${say}</p>`;
444 return html`<form class="bar" method="post" action="/asks/bulk/${operationId}/undo" role="status">
445 <span class="small">${say}</span>
446 ${btn(UNDO_LABEL, { size: "sm" })}
447 ${note === undefined ? null : html`<span class="muted small">${note}</span>`}
448 </form>`;
449 }
450
428451 /** What needs you, as the home's pinned section: what asks you, with a
429452 * bulk step's preview or receipt on top. Nothing on screen when nothing
430453 * asks — the header's Asks link says so — but the sentence stays for a
@@ -471,15 +494,7 @@ export function needsYouSection(
471494 </form>`,
472495 )}
473496 </details>`;
474 const last = latestUndoable(ctx.app, user);
475 const undo =
476 last === undefined
477 ? null
478 : html`<form class="bar" method="post" action="/asks/bulk/${last.id}/undo" role="status">
479 <span class="small">${receiptOf(last)}</span>
480 ${btn("Undo", { size: "sm" })}
481 <span class="muted small">kept for a day</span>
482 </form>`;
497 const undo = undoSlot(ctx, user);
483498 const count = rows.length + needs.length;
484499 if (count === 0 && top === undefined)
485500 return {
test/asks-bulk.test.ts +69
@@ -22,6 +22,9 @@ import { getAgentByName } from "../src/core/agents.ts";
2222 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
2323 import { assignIssue } from "../src/actions/issues.ts";
2424 import { mutedRepos, recordInteraction, removeMutes } from "../src/core/interactions.ts";
25 import { listOperations } from "../src/core/fleet_ops.ts";
26 import { doneLine } from "../src/actions/asks_bulk.ts";
27 import { DONE_NO_UNDO, DONE_UNDO } from "../src/actions/asks_words.ts";
2528 import { needsYouSection } from "../src/web/studio/asks_page.ts";
2629 import { getPull, type Pull } from "../src/core/pulls.ts";
2730 import type { Repo } from "../src/core/repos.ts";
@@ -767,6 +770,72 @@ describe("acting on many asks at once", () => {
767770 expect(getIssueById(app.db, a.id)!.state).toBe("open");
768771 });
769772
773 describe("the one Done line after a decision (#11814)", () => {
774 const page = async () =>
775 (await fetch(`${servers.url}/live/on-you`, { headers: { cookie: `${aliceCookie}; forge_mode=studio`, origin: servers.url, "x-forge-fragment": "1" } })).text();
776 const ageIt = (id: string, ms: number) =>
777 app.db.query("UPDATE fleet_operation SET created_at = ?1 WHERE id = ?2").run(new Date(Date.now() - ms).toISOString(), id);
778
779 test("decline: Done. Undo for a minute, kept by the row; its undo writes its own receipt", async () => {
780 const pull = await greenPull(open, "Done line decline");
781 const ask = items(alice).filter((i) => i.endsWith(pull.id));
782 const done = await api(aliceRw, "/api/v1/asks/bulk", { action: "decline", items: ask, preview: false });
783 expect(done.body.line).toBe(DONE_UNDO);
784 expect(doneLine(app, alice)?.line).toBe(DONE_UNDO);
785 expect(await page()).toContain(`/asks/bulk/${done.body.id}/undo`);
786 // After the minute the Done line is gone (the day's undo stays).
787 ageIt(done.body.id, 61_000);
788 expect(doneLine(app, alice)).toBeUndefined();
789 ageIt(done.body.id, 1_000);
790 const undone = await api(aliceRw, `/api/v1/asks/bulk/${done.body.id}/undo`, {});
791 expect(undone.status).toBe(201);
792 expect(undone.body.id).not.toBe(done.body.id);
793 expect(undone.body.undoOf).toBe(done.body.id);
794 expect(doneLine(app, alice)).toBeUndefined();
795 });
796
797 test("the operation log keeps the decision and its undo, and the line finds the decision past any run of undos", async () => {
798 const pull = await greenPull(open, "Done line trail");
799 const ask = items(alice).filter((i) => i.endsWith(pull.id));
800 const done = await api(aliceRw, "/api/v1/asks/bulk", { action: "decline", items: ask, preview: false });
801 const undone = await api(aliceRw, `/api/v1/asks/bulk/${done.body.id}/undo`, {});
802 const log = listOperations(app.db, alice.id, 50);
803 expect(log.find((o) => o.id === done.body.id)).toMatchObject({ undoneBy: undone.body.id, undoOf: null });
804 expect(log.find((o) => o.id === undone.body.id)).toMatchObject({ undoOf: done.body.id });
805 // Six undo receipts after a fresh decision: it is still the latest decision, read by kind.
806 const fresh = await api(aliceRw, "/api/v1/asks/bulk", { action: "decline", items: ask, preview: false });
807 expect(fresh.status).toBe(201);
808 for (let i = 0; i < 6; i++)
809 app.db
810 .query(
811 `INSERT INTO fleet_operation (id, actor_id, kind, query, verb, value, reversible, undo_of, created_at)
812 VALUES (?1, ?2, 'issue', 'asks: 1 selected', 'dismiss', '', 0, ?3, ?4)`,
813 )
814 .run(`zz-undo-${i}-${fresh.body.id}`, alice.id, done.body.id, new Date().toISOString());
815 expect(doneLine(app, alice)?.operation.id).toBe(fresh.body.id);
816 });
817
818 test("close: the same line and the same undo", async () => {
819 const pull = await greenPull(open, "Done line close");
820 const ask = items(alice).filter((i) => i.endsWith(pull.id));
821 const done = await api(aliceRw, "/api/v1/asks/bulk", { action: "close", items: ask, reason: "not now", preview: false });
822 expect(done.body.line).toBe(DONE_UNDO);
823 expect(doneLine(app, alice)?.operation.id).toBe(done.body.id);
824 });
825
826 test("ship: a landed merge shows no Undo and says why in one sentence", async () => {
827 const pull = await greenPull(open, "Done line ship");
828 const ask = items(alice).filter((i) => i.endsWith(pull.id));
829 const done = await api(aliceRw, "/api/v1/asks/bulk", { action: "ship", items: ask, preview: false });
830 expect(done.status).toBe(201);
831 expect(done.body.line).toBe(DONE_NO_UNDO);
832 expect(doneLine(app, alice)).toMatchObject({ line: DONE_NO_UNDO, undo: false });
833 const html = await page();
834 expect(html).toContain("cannot be taken back");
835 expect(html).not.toContain(`/asks/bulk/${done.body.id}/undo`);
836 });
837 });
838
770839 test("undoing a bulk mute leaves a mute made elsewhere", () => {
771840 const item = `repo:${quiet.id}`;
772841 recordInteraction(app.db, { viewerId: alice.id, itemId: item, kind: "muted", repoId: quiet.id, actorId: null });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs