agent-dealer 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -105,16 +105,72 @@ export function getIssue(id) {
105
105
  export function listIssues(status) {
106
106
  const db = getDb();
107
107
  if (!status) {
108
- const rows = db.prepare("SELECT * FROM issues ORDER BY updated_at DESC").all();
108
+ const rows = db
109
+ .prepare("SELECT * FROM issues ORDER BY updated_at DESC, rowid DESC")
110
+ .all();
109
111
  return rows.map(rowToIssue);
110
112
  }
111
113
  const statuses = Array.isArray(status) ? status : [status];
112
114
  const placeholders = statuses.map(() => "?").join(",");
113
115
  const rows = db
114
- .prepare(`SELECT * FROM issues WHERE status IN (${placeholders}) ORDER BY updated_at DESC`)
116
+ .prepare(`SELECT * FROM issues WHERE status IN (${placeholders}) ORDER BY updated_at DESC, rowid DESC`)
115
117
  .all(...statuses);
116
118
  return rows.map(rowToIssue);
117
119
  }
120
+ /** NOT-228: page-size bounds for the Issues list (matches the report default/max). */
121
+ export const ISSUES_LIST_DEFAULT_LIMIT = 25;
122
+ export const ISSUES_LIST_MAX_LIMIT = 100;
123
+ /** Escape the LIKE metacharacters in a user query so `%`/`_` match literally. */
124
+ function escapeLikePattern(raw) {
125
+ return raw.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
126
+ }
127
+ /**
128
+ * NOT-228: server-side filtering + stable pagination for the Issues list.
129
+ * Ordering is `updated_at DESC, rowid DESC` so pages are repeatable when many
130
+ * rows share a timestamp; the filter applies before pagination so `total` and
131
+ * `totalPages` describe the full matching cohort.
132
+ */
133
+ export function queryIssues(query = {}) {
134
+ const db = getDb();
135
+ const conditions = [];
136
+ const params = [];
137
+ const rawStatuses = query.status === undefined ? [] : Array.isArray(query.status) ? query.status : [query.status];
138
+ const statuses = rawStatuses.map((s) => String(s).trim()).filter(Boolean);
139
+ if (statuses.length > 0) {
140
+ conditions.push(`status IN (${statuses.map(() => "?").join(",")})`);
141
+ params.push(...statuses);
142
+ }
143
+ const search = query.search?.trim();
144
+ if (search) {
145
+ const pattern = `%${escapeLikePattern(search).toLowerCase()}%`;
146
+ conditions.push(`(LOWER(title) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(external_label, '')) LIKE ? ESCAPE '\\')`);
147
+ params.push(pattern, pattern);
148
+ }
149
+ const repo = query.repo?.trim();
150
+ if (repo) {
151
+ conditions.push(`repo = ?`);
152
+ params.push(repo);
153
+ }
154
+ if (query.needsAttention) {
155
+ conditions.push(`EXISTS (SELECT 1 FROM human_actions ha WHERE ha.issue_id = issues.id AND ha.status = 'open')`);
156
+ }
157
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
158
+ const rawLimit = query.limit === undefined ? ISSUES_LIST_DEFAULT_LIMIT : Math.floor(query.limit);
159
+ const limit = !Number.isFinite(rawLimit) || rawLimit < 1
160
+ ? ISSUES_LIST_DEFAULT_LIMIT
161
+ : Math.min(rawLimit, ISSUES_LIST_MAX_LIMIT);
162
+ const rawPage = query.page === undefined ? 1 : Math.floor(query.page);
163
+ const page = !Number.isFinite(rawPage) || rawPage < 1 ? 1 : rawPage;
164
+ const { total } = db
165
+ .prepare(`SELECT COUNT(*) AS total FROM issues ${where}`)
166
+ .get(...params);
167
+ const totalPages = total === 0 ? 0 : Math.ceil(total / limit);
168
+ const offset = (page - 1) * limit;
169
+ const rows = db
170
+ .prepare(`SELECT * FROM issues ${where} ORDER BY updated_at DESC, rowid DESC LIMIT ? OFFSET ?`)
171
+ .all(...params, limit, offset);
172
+ return { rows: rows.map(rowToIssue), page, limit, total, totalPages };
173
+ }
118
174
  /**
119
175
  * Every issue ever imported for this external id, newest first. `(source, external_id)` is
120
176
  * deliberately non-unique (`idx_issues_external` is a plain index): one ticket may need a
@@ -4,9 +4,10 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-issues-"));
7
- const { migrate } = await import("../db/index.js");
7
+ const { migrate, getDb } = await import("../db/index.js");
8
8
  const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
9
- const { createIssue, getIssue, listIssues, transitionIssue, incrementIssueRound, findActiveIssueByExternalId, listIssuesByExternalId } = await import("./issues.js");
9
+ const { createIssue, getIssue, listIssues, transitionIssue, incrementIssueRound, findActiveIssueByExternalId, listIssuesByExternalId, queryIssues, ISSUES_LIST_DEFAULT_LIMIT, ISSUES_LIST_MAX_LIMIT } = await import("./issues.js");
10
+ const { createHumanAction, resolveHumanAction } = await import("./human-actions.js");
10
11
  before(() => {
11
12
  migrate();
12
13
  });
@@ -67,3 +68,93 @@ test("terminal-scoped lookup is per (source, externalId) pair", () => {
67
68
  assert.equal(findActiveIssueByExternalId("linear", "SHARED-1")?.id, linear.id);
68
69
  assert.equal(findActiveIssueByExternalId("manual", "SHARED-1"), null);
69
70
  });
71
+ test("NOT-228: search matches title case-insensitively and ignores unrelated rows", () => {
72
+ const hit = createIssue(makeInput("NOT228 Quokka migration wobble"));
73
+ createIssue(makeInput("NOT228 unrelated zebra"));
74
+ const res = queryIssues({ search: " WOBBLE " });
75
+ assert.ok(res.rows.some((i) => i.id === hit.id), "title match survives case/whitespace");
76
+ assert.ok(res.rows.every((i) => !i.title.includes("unrelated zebra")));
77
+ assert.equal(res.page, 1);
78
+ assert.equal(res.limit, ISSUES_LIST_DEFAULT_LIMIT);
79
+ });
80
+ test("NOT-228: search matches the external label case-insensitively", () => {
81
+ const labeled = createIssue({
82
+ ...makeInput("A plain title with no ticket in it"),
83
+ source: "linear",
84
+ externalId: "ext-228-label",
85
+ externalLabel: "NOT-175",
86
+ });
87
+ const res = queryIssues({ search: "not-175" });
88
+ assert.ok(res.rows.some((i) => i.id === labeled.id), "external label match");
89
+ assert.ok(res.rows.every((i) => i.id === labeled.id || (i.title + (i.externalLabel ?? "")).toLowerCase().includes("not-175")), "unrelated rows are excluded");
90
+ });
91
+ test("NOT-228: search treats LIKE metacharacters literally", () => {
92
+ const literal = createIssue(makeInput("NOT228 100% literal percent"));
93
+ createIssue(makeInput("NOT228 1000 literal thousand"));
94
+ const res = queryIssues({ search: "100%" });
95
+ assert.ok(res.rows.some((i) => i.id === literal.id));
96
+ assert.ok(res.rows.every((i) => (i.title + (i.externalLabel ?? "")).toLowerCase().includes("100%")));
97
+ });
98
+ test("NOT-228: status, repo, and search combine before pagination; totals describe the cohort", () => {
99
+ const repo = "github.com/not228/combo";
100
+ const a = createIssue({ ...makeInput("NOT228 combo alpha"), repo });
101
+ const b = createIssue({ ...makeInput("NOT228 combo beta"), repo });
102
+ createIssue({ ...makeInput("NOT228 combo other-repo"), repo: "github.com/not228/other" });
103
+ transitionIssue(b.id, "developing");
104
+ const res = queryIssues({ search: "combo", status: "ready", repo, limit: 1, page: 1 });
105
+ assert.equal(res.total, 1);
106
+ assert.equal(res.totalPages, 1);
107
+ assert.deepEqual(res.rows.map((i) => i.id), [a.id]);
108
+ const both = queryIssues({ search: "combo", repo });
109
+ assert.equal(both.total, 2, "status omitted: both statuses match");
110
+ });
111
+ test("NOT-228: equal updated_at rows paginate repeatably with rowid DESC as tiebreaker", () => {
112
+ const stamp = "2026-09-01T00:00:00.000Z";
113
+ const ids = ["NOT228 tie one", "NOT228 tie two", "NOT228 tie three"].map((t) => createIssue(makeInput(t)).id);
114
+ for (const id of ids) {
115
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run(stamp, id);
116
+ }
117
+ const first = queryIssues({ search: "NOT228 tie", limit: 2, page: 1 });
118
+ const second = queryIssues({ search: "NOT228 tie", limit: 2, page: 2 });
119
+ assert.equal(first.total, 3);
120
+ assert.equal(first.totalPages, 2);
121
+ // Newest rowid first: creation order was one, two, three.
122
+ assert.deepEqual(first.rows.map((i) => i.id), [ids[2], ids[1]]);
123
+ assert.deepEqual(second.rows.map((i) => i.id), [ids[0]]);
124
+ const again = queryIssues({ search: "NOT228 tie", limit: 2, page: 1 });
125
+ assert.deepEqual(again.rows.map((i) => i.id), first.rows.map((i) => i.id), "repeatable");
126
+ });
127
+ test("NOT-228: page/limit bounds clamp instead of erroring", () => {
128
+ const res = queryIssues({ search: "NOT228 tie", page: 0, limit: 0 });
129
+ assert.equal(res.page, 1);
130
+ assert.equal(res.limit, ISSUES_LIST_DEFAULT_LIMIT);
131
+ const capped = queryIssues({ search: "NOT228 tie", limit: 10_000 });
132
+ assert.equal(capped.limit, ISSUES_LIST_MAX_LIMIT);
133
+ const pastEnd = queryIssues({ search: "NOT228 tie", limit: 2, page: 99 });
134
+ assert.deepEqual(pastEnd.rows, []);
135
+ assert.equal(pastEnd.total, 3, "an out-of-range page keeps the cohort total");
136
+ assert.equal(pastEnd.page, 99);
137
+ const empty = queryIssues({ search: "NOT228 no-such-title-xyz" });
138
+ assert.deepEqual(empty.rows, []);
139
+ assert.equal(empty.total, 0);
140
+ assert.equal(empty.totalPages, 0);
141
+ });
142
+ test("NOT-228: needsAttention returns only issues with an open human action", () => {
143
+ const flagged = createIssue(makeInput("NOT228 flagged attention"));
144
+ const quiet = createIssue(makeInput("NOT228 quiet no-action"));
145
+ const action = createHumanAction({
146
+ issueId: flagged.id,
147
+ actionType: "final_review",
148
+ reason: "Reviewer approved",
149
+ question: "Accept?",
150
+ responseOptions: ["complete", "repair", "close"],
151
+ });
152
+ let res = queryIssues({ search: "NOT228 flagged attention", needsAttention: true });
153
+ assert.deepEqual(res.rows.map((i) => i.id), [flagged.id]);
154
+ res = queryIssues({ search: "NOT228 quiet no-action", needsAttention: true });
155
+ assert.deepEqual(res.rows, [], "issues without open actions are excluded");
156
+ resolveHumanAction(action.id, "tester", { choice: "complete" });
157
+ res = queryIssues({ search: "NOT228 flagged attention", needsAttention: true });
158
+ assert.deepEqual(res.rows, [], "resolved actions no longer flag the issue");
159
+ assert.ok(queryIssues({ search: "NOT228 quiet no-action" }).rows.some((i) => i.id === quiet.id));
160
+ });
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import { CreateIssueInput, UpdateIssueInput, } from "@agent-dealer/shared";
4
4
  import { getDb } from "../db/index.js";
5
- import { createIssue, getIssue, listIssues, findActiveIssueByExternalId, listIssuesByExternalId, updateIssue, listRecentRepos } from "../repository/issues.js";
5
+ import { createIssue, getIssue, listIssues, findActiveIssueByExternalId, listIssuesByExternalId, queryIssues, updateIssue, listRecentRepos } from "../repository/issues.js";
6
6
  import { listWorkerSessionsForIssue, getActiveWorkerSessionForIssue } from "../repository/worker-sessions.js";
7
7
  import { getIssueArtifact, listArtifactsForIssue } from "../repository/artifacts-for-issue.js";
8
8
  import { listUsageEventsForIssue, summarizeIssueUsage } from "../repository/usage-events.js";
@@ -52,10 +52,8 @@ function readTraceTail(filePath, maxChars) {
52
52
  }
53
53
  export async function registerIssueRoutes(app) {
54
54
  app.get("/api/issues", async (req) => {
55
- const status = req.query.status;
56
- const issues = status ? listIssues(status.split(",")) : listIssues();
57
- const openActionIssueIds = new Set(listOpenHumanActions().map((a) => a.issueId));
58
- return issues.map((issue) => ({
55
+ const query = req.query;
56
+ const toRow = (openActionIssueIds) => (issue) => ({
59
57
  id: issue.id,
60
58
  title: issue.title,
61
59
  status: issue.status,
@@ -63,7 +61,35 @@ export async function registerIssueRoutes(app) {
63
61
  currentIntent: issue.currentIntent,
64
62
  updatedAt: issue.updatedAt,
65
63
  hasOpenHumanAction: openActionIssueIds.has(issue.id),
66
- }));
64
+ });
65
+ // NOT-228: paginated web requests pass `page` and/or `limit` and get rows
66
+ // plus `{ page, limit, total, totalPages }`. Legacy callers (notably the
67
+ // CLI status query) omit both and keep the existing unpaginated array.
68
+ if (query.page !== undefined || query.limit !== undefined) {
69
+ const statuses = query.status
70
+ ? query.status.split(",").map((s) => s.trim()).filter(Boolean)
71
+ : undefined;
72
+ const result = queryIssues({
73
+ search: query.q,
74
+ status: statuses,
75
+ repo: query.repo,
76
+ needsAttention: query.needsAttention === "1" || query.needsAttention === "true",
77
+ page: query.page !== undefined ? Number(query.page) : undefined,
78
+ limit: query.limit !== undefined ? Number(query.limit) : undefined,
79
+ });
80
+ const openActionIssueIds = new Set(listOpenHumanActions().map((a) => a.issueId));
81
+ return {
82
+ issues: result.rows.map(toRow(openActionIssueIds)),
83
+ page: result.page,
84
+ limit: result.limit,
85
+ total: result.total,
86
+ totalPages: result.totalPages,
87
+ };
88
+ }
89
+ const status = query.status;
90
+ const issues = status ? listIssues(status.split(",")) : listIssues();
91
+ const openActionIssueIds = new Set(listOpenHumanActions().map((a) => a.issueId));
92
+ return issues.map(toRow(openActionIssueIds));
67
93
  });
68
94
  /** Recent portable GitHub repo identities (legacy local paths excluded from create UI). */
69
95
  app.get("/api/issues/recent-repos", async () => {
@@ -16,6 +16,7 @@ const { migrate, getDb } = await import("../db/index.js");
16
16
  const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
17
17
  const { registerIssueRoutes } = await import("./issues.js");
18
18
  const { transitionIssue, listIssuesByExternalId, getIssue } = await import("../repository/issues.js");
19
+ const { createHumanAction } = await import("../repository/human-actions.js");
19
20
  const { createIssueArtifact } = await import("../repository/artifacts.js");
20
21
  const { claimWorkItem } = await import("../repository/work-items.js");
21
22
  const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
@@ -564,6 +565,41 @@ test("NOT-148: GET /api/issues/:id surfaces branchTipStatus with restart risk af
564
565
  assert.equal(idle.json().branchTipStatus, null);
565
566
  await app.close();
566
567
  });
568
+ // NOT-228: helpers + focused tests for Issues list filtering/pagination. Every
569
+ // test wipes `issues` in beforeEach, so the `R228` title marker scopes each
570
+ // cohort without cross-test interference.
571
+ const R228_PAYLOAD = {
572
+ repo: "acme/app",
573
+ baseBranch: "main",
574
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
575
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
576
+ };
577
+ async function r228Seed(app, title, extra = {}) {
578
+ const res = await app.inject({
579
+ method: "POST",
580
+ url: "/api/issues",
581
+ payload: { ...R228_PAYLOAD, ...extra, title },
582
+ });
583
+ assert.equal(res.statusCode, 200);
584
+ return res.json();
585
+ }
586
+ async function r228Get(app, qs) {
587
+ const res = await app.inject({ method: "GET", url: `/api/issues${qs}` });
588
+ assert.equal(res.statusCode, 200);
589
+ return res.json();
590
+ }
591
+ test("NOT-228: omitting page/limit keeps the legacy unpaginated array (CLI contract)", async () => {
592
+ const app = await buildApp();
593
+ const created = await r228Seed(app, "R228 legacy contract");
594
+ for (const url of ["/api/issues", "/api/issues?status=ready", "/api/issues?q=R228&repo=github.com%2Facme%2Fapp"]) {
595
+ const res = await app.inject({ method: "GET", url });
596
+ assert.equal(res.statusCode, 200);
597
+ const body = res.json();
598
+ assert.ok(Array.isArray(body), `${url} still answers an array`);
599
+ assert.ok(body.some((i) => i.id === created.id));
600
+ }
601
+ await app.close();
602
+ });
567
603
  test("NOT-217: PATCH agent assignments on a queued issue preserves position, refreshes the wait reason, and records issue.reassigned", async () => {
568
604
  const app = await buildApp();
569
605
  try {
@@ -623,6 +659,76 @@ test("NOT-217: PATCH agent assignments on a queued issue preserves position, ref
623
659
  }
624
660
  await app.close();
625
661
  });
662
+ test("NOT-228: page/limit paginate with a default of 25 and totals for the full cohort", async () => {
663
+ const app = await buildApp();
664
+ for (let n = 0; n < 30; n++)
665
+ await r228Seed(app, `R228 pageable ${n}`);
666
+ const q = encodeURIComponent("R228 pageable");
667
+ const first = await r228Get(app, `?q=${q}&limit=25`);
668
+ assert.equal(first.issues.length, 25);
669
+ assert.equal(first.page, 1);
670
+ assert.equal(first.limit, 25);
671
+ assert.equal(first.total, 30);
672
+ assert.equal(first.totalPages, 2);
673
+ const second = await r228Get(app, `?q=${q}&limit=25&page=2`);
674
+ assert.equal(second.issues.length, 5);
675
+ assert.equal(second.total, 30);
676
+ assert.deepEqual(new Set([...first.issues, ...second.issues].map((i) => i.id)).size, 30, "pages neither duplicate nor skip rows");
677
+ // `page` alone still paginates (default limit); `limit` alone starts at page 1.
678
+ const defaulted = await r228Get(app, `?q=${q}&page=2`);
679
+ assert.equal(defaulted.limit, 25);
680
+ assert.equal(defaulted.issues.length, 5);
681
+ await app.close();
682
+ });
683
+ test("NOT-228: search, status, repo, and attention filters apply before pagination", async () => {
684
+ const app = await buildApp();
685
+ const repo = "github.com/r228/filtered";
686
+ const keep = await r228Seed(app, "R228 filter keep me", { repo });
687
+ const moved = await r228Seed(app, "R228 filter moved on", { repo });
688
+ await r228Seed(app, "R228 filter other repo", { repo: "github.com/r228/elsewhere" });
689
+ transitionIssue(moved.id, "developing");
690
+ createHumanAction({
691
+ issueId: moved.id,
692
+ actionType: "final_review",
693
+ reason: "Reviewer approved",
694
+ question: "Accept?",
695
+ responseOptions: ["complete"],
696
+ });
697
+ const q = encodeURIComponent("R228 filter");
698
+ const byStatus = await r228Get(app, `?q=${q}&status=developing&page=1&limit=10`);
699
+ assert.equal(byStatus.total, 1);
700
+ assert.deepEqual(byStatus.issues.map((i) => i.id), [moved.id]);
701
+ const byRepo = await r228Get(app, `?q=${q}&repo=${encodeURIComponent(repo)}&page=1&limit=10`);
702
+ assert.equal(byRepo.total, 2);
703
+ const attention = await r228Get(app, `?q=${q}&needsAttention=1&page=1&limit=10`);
704
+ assert.equal(attention.total, 1);
705
+ assert.deepEqual(attention.issues.map((i) => i.id), [moved.id]);
706
+ assert.equal(attention.issues[0].hasOpenHumanAction, true);
707
+ // Search matches the external label, not just the title: no "R228 filter"
708
+ // title contains "not-228", so only the labeled row matches.
709
+ const labeled = await r228Seed(app, "R228 filter label-only title", {
710
+ source: "linear",
711
+ externalId: "R228-LBL-1",
712
+ externalLabel: "NOT-228",
713
+ });
714
+ const byLabel = await r228Get(app, `?q=${encodeURIComponent("not-228")}&page=1&limit=10`);
715
+ assert.equal(byLabel.total, 1);
716
+ assert.deepEqual(byLabel.issues.map((i) => i.id), [labeled.id]);
717
+ void keep;
718
+ await app.close();
719
+ });
720
+ test("NOT-228: limit clamps to 100 and out-of-range pages keep totals", async () => {
721
+ const app = await buildApp();
722
+ await r228Seed(app, "R228 bounds one");
723
+ const capped = await r228Get(app, `?q=${encodeURIComponent("R228 bounds")}&page=1&limit=500`);
724
+ assert.equal(capped.limit, 100);
725
+ assert.equal(capped.total, 1);
726
+ const pastEnd = await r228Get(app, `?q=${encodeURIComponent("R228 bounds")}&page=9&limit=10`);
727
+ assert.deepEqual(pastEnd.issues, []);
728
+ assert.equal(pastEnd.total, 1);
729
+ assert.equal(pastEnd.page, 9);
730
+ await app.close();
731
+ });
626
732
  test("NOT-217: PATCH reviewer-only edit records before/after reviewers without touching the developer", async () => {
627
733
  const app = await buildApp();
628
734
  const created = (await app.inject({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-dealer/shared": "1.1.5",
25
+ "@agent-dealer/shared": "1.1.6",
26
26
  "@fastify/cors": "^11.0.1",
27
27
  "@fastify/static": "^8.2.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",