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

Fleet queries grow comparisons and age: `cost:>0.5`, `created:<7d`, `state:queued,running` #10137

Merged opened this

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

Merged

scout/forge:scout/566-fleet-queries-grow-comparisons-and-age-c → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/566-fleet-queries-grow-comparisons-and-age-c was merged into main as 7f94b83.

Review

Sign in to review this change.
8 files changed +351 −36
docs/features/fleet.md +14 −7
@@ -14,19 +14,26 @@ Before a fleet operation acts, see the rows its query line selects. There are tw
1414 - `/` writes a new line. `s` sorts by the next column and `r` reverses the sort. `j` and `k` scroll, and `esc` goes back.
1515 - A line that does not parse keeps the prompt open with its reason.
1616
17 **The query line:** a kind, then `field:value` terms that must all hold. A value starting with `!` negates its term.
17 **The query line:** a kind, then `field:value` terms that must all hold. Both doors show the same grammar under the line: one sentence, then each kind with its fields (`GRAMMAR` and `fieldsOf` in `src/fleet/selection.ts`).
18
19 - A value starting with `!` negates its term: `repo:!trevato/forge`.
20 - A comma means any of: `state:queued,running`.
21 - `cost` and `number` take a number, and may compare it with `>`, `<`, `>=` or `<=`: `cost:>0.5`.
22 - `created` and `updated` take an age: a whole number of minutes, hours, days or weeks (`30m`, `12h`, `7d`, `2w`). `created:<7d` is younger than seven days, `created:>7d` older, and a bare `created:7d` means within seven days.
23 - A term that does not fit its field is refused, naming the field and what it wanted: "cost wants a number", "created wants an age like 30m, 12h, 7d or 2w", "state takes no comparison; only a value or values". Nothing is selected.
24 - `issue number:` now refuses text it used to accept. `number:abc` once selected nothing without a word, and now it says "number wants a number".
1825
1926 | Kind | Fields | Columns |
2027 | --- | --- | --- |
21 | `repo` | `owner`, `name`, `repo`, `autonomy`, `visibility` | repository, autonomy, visibility, app |
22 | `run` | `state`, `kind`, `agent`, `issue`, `repo` | started, repository, kind, agent, state, cost |
23 | `issue` | `number`, `state`, `assignee` (`none` for unassigned), `label`, `limb`, `repo` | repository, #, title, state, assignee |
28 | `repo` | `owner`, `name`, `repo`, `autonomy`, `visibility`, `created` | repository, autonomy, visibility, app |
29 | `run` | `state`, `kind`, `agent`, `issue`, `repo`, `cost`, `created` | started, repository, kind, agent, state, cost |
30 | `issue` | `number`, `state`, `assignee` (`none` for unassigned), `label`, `limb`, `repo`, `created`, `updated` | repository, #, title, state, assignee |
2431
25 Every kind also has `id`, and `repo` and `issue` share `owner` and `name`. Some examples:
32 Every kind also has `id`, and all three share `owner` and `name`. Some examples:
2633
2734 - `repo autonomy:autonomous`
28 - `run state:queued repo:!trevato/forge`
29 - `issue repo:engine label:bug assignee:none`
35 - `run state:queued,running cost:>0.5 repo:!trevato/forge`
36 - `issue repo:engine label:bug assignee:none created:<7d`
3037
3138 **Shared code and rules:**
3239
src/fleet/selection.ts +117 −24
@@ -11,6 +11,15 @@
1111 * Only rows the viewer may read are ever selected — a repository you cannot
1212 * see is not refused, it is absent, the same ambiguity `not_found` keeps.
1313 *
14 * Comparisons: a numeric field (`cost`, `number`) takes `>`, `<`, `>=` or
15 * `<=` before a number — `run cost:>0.5`. A time field (`created`,
16 * `updated`) takes a relative age, `30m`, `12h`, `7d`, `2w`: `created:<7d`
17 * is younger than seven days, `created:>7d` older, and a bare `created:7d`
18 * means within. A comma means any of: `run state:queued,running`. A term
19 * that does not fit its field is refused naming the field and what it
20 * wanted ("cost wants a number"), and nothing is selected. Every value
21 * reaches SQL as a bound parameter, never as text in the statement.
22 *
1423 * `limb:<number>` on issues selects a limb (`core/milestones.ts`): the
1524 * issue of that number and everything beneath it that only it needs, top
1625 * down — never what other work outside the limb also waits for. It needs
@@ -33,10 +42,16 @@ export const MAX_ROWS = 500;
3342 const MAX_QUERY = 500;
3443 const MAX_TERMS = 20;
3544
45 export type Op = "=" | ">" | "<" | ">=" | "<=";
46
3647 export interface Term {
3748 readonly field: string;
49 /** The value as written, after `!` and the comparison. */
3850 readonly value: string;
3951 readonly negate: boolean;
52 readonly op: Op;
53 /** `value` split on commas: the term holds when any of them does. */
54 readonly values: readonly string[];
4055 }
4156
4257 export interface FleetQuery {
@@ -69,6 +84,25 @@ const repoFields: Record<string, Predicate> = {
6984 name: (p) => `repo.name = ${p} COLLATE NOCASE`,
7085 };
7186
87 /** A column compared with `op`: numbers bound as numbers, ages as instants. */
88 type Compared = { readonly type: "number" | "time"; readonly column: string };
89
90 const created = (table: string): Compared => ({ type: "time", column: `${table}.created_at` });
91
92 const COMPARED: Record<FleetKind, Record<string, Compared>> = {
93 repo: { created: created("repo") },
94 run: { cost: { type: "number", column: "run.cost_usd" }, created: created("run") },
95 issue: {
96 number: { type: "number", column: "issue.number" },
97 created: created("issue"),
98 updated: { type: "time", column: "issue.updated_at" },
99 },
100 };
101
102 const AGE_UNITS: Record<string, number> = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
103 const NUMBER = /^-?\d+(\.\d+)?$/;
104 const AGE = /^(\d{1,6})([mhdw])$/;
105
72106 const FIELDS: Record<FleetKind, Record<string, Predicate>> = {
73107 repo: {
74108 ...repoFields,
@@ -90,7 +124,6 @@ const FIELDS: Record<FleetKind, Record<string, Predicate>> = {
90124 issue: {
91125 ...repoFields,
92126 id: (p) => `issue.id = ${p}`,
93 number: (p) => `issue.number = CAST(${p} AS INTEGER)`,
94127 state: (p) => `issue.state = ${p} COLLATE NOCASE`,
95128 assignee: (p) =>
96129 `(CASE WHEN ${p} = 'none' THEN issue.assignee_id IS NULL
@@ -114,7 +147,7 @@ function limbIds(
114147 term: Term,
115148 ): Result<string[], QueryError> {
116149 const named = query.terms.filter(
117 (t) => t.field === "repo" && !t.negate && t.value.includes("/"),
150 (t) => t.field === "repo" && !t.negate && t.values.length === 1 && t.value.includes("/"),
118151 );
119152 if (named.length !== 1)
120153 return bad("limb:<number> needs the repository: repo:owner/name");
@@ -163,22 +196,77 @@ export function parseQuery(text: string): Result<FleetQuery, QueryError> {
163196 if (rest.length > MAX_TERMS) return bad(`a query has at most ${MAX_TERMS} terms`);
164197 const terms: Term[] = [];
165198 for (const word of rest) {
166 const colon = word.indexOf(":");
167 const field = colon > 0 ? word.slice(0, colon).toLowerCase() : "";
168 let value = colon > 0 ? word.slice(colon + 1) : "";
169 const negate = value.startsWith("!");
170 if (negate) value = value.slice(1);
171 if (field === "" || value === "")
172 return bad(`"${word}" is not field:value`);
173 if (field !== "repo" && FIELDS[kind][field] === undefined)
174 return bad(
175 `${kind} has no field "${field}"; it has repo, ${Object.keys(FIELDS[kind]).join(", ")}`,
176 );
177 terms.push({ field, value, negate });
199 const term = parseTerm(kind, word);
200 if (!term.ok) return term;
201 terms.push(term.value);
178202 }
179203 return ok({ kind, terms });
180204 }
181205
206 /** Every field a kind's query may name, for errors and for the grammar both doors show. */
207 export function fieldsOf(kind: FleetKind): string[] {
208 return ["repo", ...Object.keys(FIELDS[kind]), ...Object.keys(COMPARED[kind])];
209 }
210
211 /** The kinds a query line starts with, in the order the grammar lists them. */
212 export const FLEET_KINDS: readonly FleetKind[] = ["repo", "run", "issue"];
213
214 /** The grammar in one sentence, the same on the web page and the terminal screen. */
215 export const GRAMMAR =
216 "A kind, then field:value terms that all hold; ! negates, a comma means any of, and cost, number or an age (30m, 12h, 7d, 2w) can be more or less with > or <.";
217
218 /** Two lines that show the grammar at work. */
219 export const GRAMMAR_EXAMPLES: readonly string[] = [
220 "run state:queued,running cost:>0.5",
221 "issue created:<7d assignee:none",
222 ];
223
224 /** One `field:[!][op]value[,value…]` word, checked against what its field takes. */
225 function parseTerm(kind: FleetKind, word: string): Result<Term, QueryError> {
226 const colon = word.indexOf(":");
227 const field = colon > 0 ? word.slice(0, colon).toLowerCase() : "";
228 let value = colon > 0 ? word.slice(colon + 1) : "";
229 const negate = value.startsWith("!");
230 if (negate) value = value.slice(1);
231 const op = (/^[<>]=?/.exec(value)?.[0] ?? "=") as Op;
232 value = value.slice(op === "=" ? 0 : op.length);
233 if (field === "" || value === "") return bad(`"${word}" is not field:value`);
234 const compared = COMPARED[kind][field];
235 if (field !== "repo" && FIELDS[kind][field] === undefined && compared === undefined)
236 return bad(
237 `${kind} has no field "${field}"; it has ${fieldsOf(kind).join(", ")}`,
238 );
239 const values = value.split(",");
240 if (values.some((v) => v === "")) return bad(`${field} has an empty value in "${value}"`);
241 if (compared === undefined) {
242 if (op !== "=") return bad(`${field} takes no comparison; only a value or values`);
243 if (field === "limb" && values.length > 1) return bad("limb takes the number of one issue");
244 } else {
245 if (op !== "=" && values.length > 1) return bad(`${field} compares with one value, not a list`);
246 if (compared.type === "number" && !values.every((v) => NUMBER.test(v)))
247 return bad(`${field} wants a number`);
248 if (compared.type === "time" && !values.every((v) => AGE.test(v)))
249 return bad(`${field} wants an age like 30m, 12h, 7d or 2w`);
250 }
251 return ok({ field, value, negate, op, values });
252 }
253
254 /** `created:<7d` is younger than 7 days: a later instant, so the comparison flips. */
255 const FLIP: Record<Op, Op> = { "=": ">=", "<": ">", "<=": ">=", ">": "<", ">=": "<=" };
256
257 function comparedSql(
258 compared: Compared,
259 op: Op,
260 value: string,
261 bind: (value: string | number) => string,
262 ): string {
263 if (compared.type === "number")
264 return `${compared.column} ${op} ${bind(Number(value))}`;
265 const [, n, unit] = AGE.exec(value)!;
266 const at = new Date(Date.now() - Number(n) * AGE_UNITS[unit!]!).toISOString();
267 return `${compared.column} ${FLIP[op]} ${bind(at)}`;
268 }
269
182270 /**
183271 * The ids a query selects for `viewer` (undefined: a signed-out visitor),
184272 * in a stable order. More than
@@ -192,7 +280,7 @@ export function selectRows(
192280 ): Result<string[], QueryError> {
193281 // Signed out, `?1` is an id no account has: only public repositories hold.
194282 const params: (string | number)[] = [viewer?.id ?? ""];
195 const bind = (value: string): string => {
283 const bind = (value: string | number): string => {
196284 params.push(value);
197285 return `?${params.length}`;
198286 };
@@ -216,15 +304,20 @@ export function selectRows(
216304 ids.value.length === 0
217305 ? "0"
218306 : `issue.id IN (${ids.value.map((id) => bind(id)).join(",")})`;
219 } else if (term.field === "repo") {
220 // `owner/name`, or a bare name in any owner.
221 const slash = term.value.indexOf("/");
222 sql =
223 slash < 0
224 ? repoFields.name!(bind(term.value))
225 : `${repoFields.owner!(bind(term.value.slice(0, slash)))}
226 AND ${repoFields.name!(bind(term.value.slice(slash + 1)))}`;
227 } else sql = FIELDS[query.kind][term.field]!(bind(term.value));
307 } else {
308 const compared = COMPARED[query.kind][term.field];
309 const one = (value: string): string => {
310 if (compared !== undefined) return comparedSql(compared, term.op, value, bind);
311 if (term.field !== "repo") return FIELDS[query.kind][term.field]!(bind(value));
312 // `owner/name`, or a bare name in any owner.
313 const slash = value.indexOf("/");
314 return slash < 0
315 ? repoFields.name!(bind(value))
316 : `${repoFields.owner!(bind(value.slice(0, slash)))}
317 AND ${repoFields.name!(bind(value.slice(slash + 1)))}`;
318 };
319 sql = term.values.map((v) => `(${one(v)})`).join(" OR ");
320 }
228321 // COALESCE: a NULL comparison negated is still NULL, and `!x` must
229322 // select the rows where x does not hold, including the empty ones.
230323 const held = `COALESCE((${sql}), 0)`;
src/tui/screens/fleet.ts +24 −2
@@ -9,13 +9,13 @@
99
1010 import { cellText, COLUMNS, sortRows, type Sort } from "../../fleet/columns.ts";
1111 import { viewFleet, type FleetView } from "../../fleet/rows.ts";
12 import type { FleetKind } from "../../fleet/selection.ts";
12 import { fieldsOf, GRAMMAR, GRAMMAR_EXAMPLES, FLEET_KINDS, type FleetKind } from "../../fleet/selection.ts";
1313 import type { FleetRow } from "../../fleet/rows.ts";
1414 import type { Ctx, Screen } from "../app/app.ts";
1515 import { binding } from "../app/keys.ts";
1616 import type { Session } from "../session.ts";
1717 import { style } from "../term/style.ts";
18 import { pad, truncate } from "../term/width.ts";
18 import { pad, truncate, wrap } from "../term/width.ts";
1919 import { promptScreen, Scroll } from "./common.ts";
2020
2121 /** No column is padded past this, so a long title does not push the rest away. */
@@ -45,6 +45,20 @@ export function fleetLines(
4545 ];
4646 }
4747
48 /**
49 * The grammar as the web page shows it (`fleet/selection.ts`): the sentence
50 * wrapped to `width`, one line a kind naming its fields, then the examples.
51 */
52 export function grammarLines(width: number): string[] {
53 return [
54 ...wrap(GRAMMAR, width),
55 "",
56 ...FLEET_KINDS.map((kind) => `${pad(kind, 6)} ${fieldsOf(kind).join(", ")}`),
57 "",
58 ...GRAMMAR_EXAMPLES.map((e) => `e.g. ${e}`),
59 ];
60 }
61
4862 /** The next column to sort by: the one after the current, ascending; none sorted, the first. */
4963 export function nextSort(kind: FleetKind, sort: Sort | undefined): Sort {
5064 const keys = COLUMNS[kind].map((c) => c.key);
@@ -81,6 +95,13 @@ export function fleetScreen(
8195 ? `${query} ${view.rows.length} ${view.rows.length === 1 ? "row" : "rows"}`
8296 : `${query} ${view.error}`;
8397 s.text(area.x + 1, area.y, truncate(head, area.w - 2), style(view?.ok === false ? c.danger : c.mutedFg, c.bg), area.x + area.w);
98 if (view?.ok !== true) {
99 // No rows to show: the grammar fills the space, as it sits under the web form.
100 grammarLines(Math.max(20, area.w - 2)).forEach((text, i) => {
101 if (2 + i < area.h)
102 s.text(area.x + 1, area.y + 2 + i, truncate(text, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w);
103 });
104 }
84105 lines = view?.ok ? fleetLines(view.kind, view.rows, sort) : [];
85106 const [header, ...body] = lines;
86107 if (header !== undefined && area.h > 2)
@@ -108,6 +129,7 @@ export function fleetScreen(
108129 promptScreen(ctx, screen, {
109130 title: "Fleet query",
110131 label: "kind, then field:value terms",
132 hint: `e.g. ${GRAMMAR_EXAMPLES[0]}`,
111133 placeholder: "repo autonomy:autonomous",
112134 value: query,
113135 onSubmit: (text) => {
src/web/pages/fleet.ts +12 −1
@@ -9,7 +9,7 @@
99 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1010 import { readSort } from "../../fleet/columns.ts";
1111 import { viewFleet, type FleetView } from "../../fleet/rows.ts";
12 import { MAX_ROWS } from "../../fleet/selection.ts";
12 import { fieldsOf, GRAMMAR, GRAMMAR_EXAMPLES, FLEET_KINDS, MAX_ROWS } from "../../fleet/selection.ts";
1313 import type { Ctx } from "../../http/context.ts";
1414 import { fleetTable } from "../fleet-table.ts";
1515 import { html } from "../html.ts";
@@ -50,6 +50,17 @@ export function fleetPage(ctx: Ctx): Response {
5050 })}
5151 ${btn("Select", { type: "submit", variant: "secondary" })}
5252 </form>
53 <div class="muted small mb" data-grammar>

Principles 1 and 7: two paragraphs explain the grammar twice (prose with examples, then a field list). Say it in one sentence and list fields per kind as dl rows, not a middot-joined paragraph. Plain words per docs/WRITING.md: spell out the operators less, show two examples.

54 <p>${GRAMMAR} For example, ${GRAMMAR_EXAMPLES.map(
55 (e, i) => html`${i === 0 ? "" : " or "}<code>${e}</code>`,
56 )}.</p>
57 <dl class="status-rows">
58 ${FLEET_KINDS.map(
59 (kind) =>
60 html`<div class="status-row"><dt class="mono">${kind}</dt><dd>${fieldsOf(kind).join(", ")}</dd></div>`,
61 )}
62 </dl>
63 </div>
5364 ${shown === undefined
5465 ? null
5566 : html`<p class="muted" data-count="${shown.rows.length}">
test/fleet-ops.test.ts +2 −2
@@ -111,8 +111,8 @@ describe("fleet operations", () => {
111111 expect(q.ok && q.value).toEqual({
112112 kind: "repo",
113113 terms: [
114 { field: "autonomy", value: "assisted", negate: false },
115 { field: "repo", value: "alice/tools", negate: true },
114 { field: "autonomy", value: "assisted", negate: false, op: "=", values: ["assisted"] },
115 { field: "repo", value: "alice/tools", negate: true, op: "=", values: ["alice/tools"] },
116116 ],
117117 });
118118 for (const bad of ["", "pulls", "repo autonomy", "repo spent:4", "run label:x"]) {
test/fleet-selection.test.ts added +165
@@ -0,0 +1,165 @@
1 /**
2 * The fleet query grammar (`fleet/selection.ts`), table-driven: every
3 * accepted line and what it parses to, every refused line and the message
4 * that names its field, then the comparisons, ages and lists against real
5 * rows, and hostile values (quotes, `%`, `;`) through every field — bound,
6 * never spliced, so they select nothing and break nothing.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { createIssue } from "../src/core/issues.ts";
15 import { createRun } from "../src/core/runs.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { fieldsOf, parseQuery, selectRows, type FleetKind } from "../src/fleet/selection.ts";
18 import { promote } from "./helpers/members.ts";
19
20 describe("parseQuery", () => {
21 const accepted: [string, { field: string; op: string; values: string[]; negate?: boolean }][] = [
22 ["run cost:>0.5", { field: "cost", op: ">", values: ["0.5"] }],
23 ["run cost:<=2", { field: "cost", op: "<=", values: ["2"] }],
24 ["run cost:!>=1", { field: "cost", op: ">=", values: ["1"], negate: true }],
25 ["issue number:<10", { field: "number", op: "<", values: ["10"] }],
26 ["issue number:3,5,8", { field: "number", op: "=", values: ["3", "5", "8"] }],
27 ["run created:<7d", { field: "created", op: "<", values: ["7d"] }],
28 ["repo created:>30m", { field: "created", op: ">", values: ["30m"] }],
29 ["issue updated:12h", { field: "updated", op: "=", values: ["12h"] }],
30 ["run state:queued,running", { field: "state", op: "=", values: ["queued", "running"] }],
31 ["repo repo:alice/a,bob/b", { field: "repo", op: "=", values: ["alice/a", "bob/b"] }],
32 ["issue state:!closed", { field: "state", op: "=", values: ["closed"], negate: true }],
33 ];
34 for (const [line, want] of accepted)
35 test(`reads ${line}`, () => {
36 const parsed = parseQuery(line);
37 if (!parsed.ok) throw new Error(parsed.error.message);
38 const term = parsed.value.terms[0]!;
39 expect({ field: term.field, op: term.op as string, values: [...term.values] }).toEqual({
40 field: want.field,
41 op: want.op,
42 values: want.values,
43 });
44 expect(term.negate).toBe(want.negate ?? false);
45 });
46
47 const refused: [string, string][] = [
48 ["run cost:abc", "cost wants a number"],
49 ["run cost:>abc", "cost wants a number"],
50 ["run cost:1,x", "cost wants a number"],
51 ["issue number:>1e3", "number wants a number"],
52 ["run created:7", "created wants an age"],
53 ["run created:<7y", "created wants an age"],
54 ["issue updated:>soon", "updated wants an age"],
55 ["run state:>queued", "state takes no comparison"],
56 ["run cost:>1,2", "cost compares with one value"],
57 ["run state:queued,", "state has an empty value"],
58 ["issue limb:3,4", "limb takes the number of one issue"],
59 ["run color:red", 'run has no field "color"'],
60 ["run cost:>", "is not field:value"],
61 ];
62 for (const [line, message] of refused)
63 test(`refuses ${line}: ${message}`, () => {
64 const parsed = parseQuery(line);
65 expect(parsed.ok).toBe(false);
66 if (!parsed.ok) expect(parsed.error.message).toContain(message);
67 });
68
69 test("one bad term refuses the whole line: no partial selection", () => {
70 expect(parseQuery("run state:queued cost:abc").ok).toBe(false);
71 });
72 });
73
74 describe("selectRows with comparisons, ages and lists", () => {
75 let dataDir: string;
76 let app: App;
77 let alice: User;
78 const ids: Record<string, string> = {};
79 const select = (line: string): string[] => {
80 const parsed = parseQuery(line);
81 if (!parsed.ok) throw new Error(parsed.error.message);
82 const rows = selectRows(app.db, alice, parsed.value);
83 if (!rows.ok) throw new Error(rows.error.message);
84 return rows.value;
85 };
86 const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
87 const DAY = 86_400_000;
88
89 beforeAll(async () => {
90 dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-selection-"));
91 app = createApp({ dataDir, port: 0 });
92 const made = await createUser(app.db, {
93 origin: "seed",
94 trusted: true,
95 username: "alice",
96 email: "[email protected]",
97 password: "a-good-passphrase",
98 adminIfFirst: true,
99 });
100 if (!made.ok) throw new Error(made.error);
101 alice = made.value;
102 promote(app.db, alice);
103 const repo = await createRepo(app, alice, { name: "engine", visibility: "public" });
104 if (!repo.ok) throw new Error(String(repo.error));
105 const run = (name: string, state: string, cost: number, age: number) => {
106 const r = createRun(app.db, { repoId: repo.value.id, kind: "agent" });
107 app.db
108 .query("UPDATE run SET state = ?1, cost_usd = ?2, created_at = ?3 WHERE id = ?4")
109 .run(state, cost, ago(age), r.id);
110 ids[name] = r.id;
111 };
112 run("cheapNew", "queued", 0.1, 60_000);
113 run("dearNew", "running", 0.9, 2 * 3_600_000);
114 run("dearOld", "ok", 2, 10 * DAY);
115 for (const [n, age] of [[1, DAY], [2, 30 * DAY]] as const) {
116 const i = createIssue(app.db, { repoId: repo.value.id, authorId: alice.id, title: `i${n}` });
117 if (!i.ok) throw new Error(i.error);
118 app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(ago(age), i.value.id);
119 ids[`issue${n}`] = i.value.id;
120 }
121 }, 30_000);
122
123 afterAll(async () => {
124 app.close();
125 await rm(dataDir, { recursive: true, force: true });
126 });
127
128 const sorted = (names: string[]) => names.map((n) => ids[n]!).sort();
129 const cases: [string, string[]][] = [
130 ["run cost:>0.5", ["dearNew", "dearOld"]],
131 ["run cost:>=2", ["dearOld"]],
132 ["run cost:<0.5", ["cheapNew"]],
133 ["run cost:<=0.9", ["cheapNew", "dearNew"]],
134 ["run cost:!>0.5", ["cheapNew"]],
135 ["run cost:0.1,2", ["cheapNew", "dearOld"]],
136 ["run created:<1h", ["cheapNew"]],
137 ["run created:<7d", ["cheapNew", "dearNew"]],
138 ["run created:7d", ["cheapNew", "dearNew"]],
139 ["run created:>7d", ["dearOld"]],
140 ["run state:queued,running", ["cheapNew", "dearNew"]],
141 ["run state:!queued,running", ["dearOld"]],
142 ["run state:queued,running cost:>0.5", ["dearNew"]],
143 ["issue created:<7d", ["issue1"]],
144 ["issue created:>2w", ["issue2"]],
145 ["issue number:>1", ["issue2"]],
146 ["issue number:1,2", ["issue1", "issue2"]],
147 ];
148 for (const [line, want] of cases)
149 test(line, () => expect([...select(line)].sort()).toEqual(sorted(want)));
150
151 test("quotes, % and ; through every field are bound: nothing selected, nothing broken", () => {
152 const hostile = [`'`, `"`, `%`, `;`, `x';DROP TABLE run;--`, `%'%`, `a"b;c`];
153 for (const kind of ["repo", "run", "issue"] as FleetKind[])
154 for (const field of fieldsOf(kind))
155 for (const value of hostile) {
156 const parsed = parseQuery(`${kind} ${field}:${value}`);
157 // A typed field refuses it at the boundary; a text field binds it.
158 if (!parsed.ok) continue;
159 const rows = selectRows(app.db, alice, parsed.value);
160 if (field === "limb") expect(rows.ok).toBe(false);
161 else expect(rows).toEqual({ ok: true, value: [] });
162 }
163 expect(select("run state:queued,running,ok").length).toBe(3);
164 });
165 });
test/fleet-table.test.ts +8
@@ -89,6 +89,14 @@ describe("the fleet table", () => {
8989 const body = await (await get("/fleet")).text();
9090 expect(body).toContain('name="q"');
9191 expect(body).not.toContain("data-count=");
92 // The grammar is listed under the query line (#566).
93 const grammar = body.slice(body.indexOf("data-grammar"));
94 expect(grammar).toContain("<code>run state:queued,running cost:&gt;0.5</code>");
95 expect(grammar).toContain("<code>issue created:&lt;7d assignee:none</code>");
96 // One row a kind naming its fields, never a middot-joined paragraph.
97 expect(grammar).toMatch(/<dt class="mono">run<\/dt><dd>repo, [^<]*cost, created<\/dd>/);
98 expect(grammar).toMatch(/<dt class="mono">issue<\/dt><dd>repo, [^<]*number, created, updated<\/dd>/);
99 expect(grammar.slice(0, grammar.indexOf("</div>"))).not.toContain("·");
92100 });
93101
94102 test("a query that does not parse shows its error beside the line, status 200, nothing selected", async () => {
test/tui-fleet.test.ts +9
@@ -93,6 +93,15 @@ test("the owner sees all three rows; a guest does not see the private one", asyn
9393 expect(guest.text()).toContain("alice/tools");
9494 });
9595
96 test("the screen shows the same grammar as the web page, fields per kind (#566)", async () => {
97 const t = boot(getUserByName(forge.db, "alice"));
98 await t.select("run cost:abc");
99 expect(t.text()).toContain("cost wants a number");
100 expect(t.text()).toContain("a comma means any of");
101 expect(t.text()).toMatch(/run +repo, [^\n]*cost, created/);
102 expect(t.text()).toContain("e.g. run state:queued,running cost:>0.5");
103 });
104
96105 test("a line that does not parse keeps the prompt open with its error", async () => {
97106 const t = boot(getUserByName(forge.db, "alice"));
98107 await t.select("repo spent:4");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs