@danypops/tickets 0.4.7 → 0.5.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.4.7",
3
+ "version": "0.5.1",
4
4
  "description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,8 +12,8 @@
12
12
  * client's `UserDetails.accountId` typing would make the fix straightforward
13
13
  * (see RESEARCH.md for the full analysis of the bug this leaves unfixed).
14
14
  */
15
- import { Version2Client } from "jira.js";
16
- import type { HttpException } from "jira.js";
15
+ import { AgileClient, Version2Client } from "jira.js";
16
+ import type { Config as JiraClientConfig, HttpException } from "jira.js";
17
17
  import type { AxiosAdapter } from "axios";
18
18
  import type { Comment, CreateInput, Issue, IssueLink, ListFilter, Status, UpdateInput } from "../domain/issue.js";
19
19
  import { parsePriority } from "../domain/issue.js";
@@ -119,6 +119,8 @@ interface JiraFieldDetails {
119
119
  export class JiraRepository {
120
120
  readonly name: string;
121
121
  private readonly client: Version2Client;
122
+ /** Same auth/host config Version2Client was built from -- reused lazily by agileClient() so the Agile API client (board/quickfilter resolution) authenticates identically without duplicating the OAuth-vs-basic branching in the constructor below. */
123
+ private readonly clientConfig: JiraClientConfig;
122
124
  private readonly project?: string;
123
125
  private readonly configDir?: string;
124
126
  /** display name (lowercased) -> { fieldId, schema type/items }, populated lazily from client.issueFields.getFields(). */
@@ -143,24 +145,26 @@ export class JiraRepository {
143
145
 
144
146
  if (isOAuthOptions(opts)) {
145
147
  if (!opts.accessToken || !opts.cloudId) throw new Error("jira: accessToken and cloudId are required for OAuth mode");
146
- this.client = new Version2Client({
148
+ this.clientConfig = {
147
149
  authentication: { oauth2: { accessToken: opts.accessToken, cloudId: opts.cloudId } },
148
150
  // axios's own native, tested timeout handling -- a stalled call fails predictably
149
151
  // instead of hanging (see RESEARCH.md for the octokit throttling-plugin hang this
150
152
  // migration found and fixed; jira.js/axios has no equivalent auto-retry-and-wait
151
153
  // behavior by default, but had no explicit timeout either until now).
152
154
  baseRequestConfig: { timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(opts.axiosAdapter ? { adapter: opts.axiosAdapter } : {}) },
153
- });
155
+ };
156
+ this.client = new Version2Client(this.clientConfig);
154
157
  return;
155
158
  }
156
159
 
157
160
  if (!opts.baseUrl) throw new Error("jira: baseUrl is required");
158
161
  if (!opts.email || !opts.token) throw new Error("jira: email and token are required");
159
- this.client = new Version2Client({
162
+ this.clientConfig = {
160
163
  host: opts.baseUrl,
161
164
  authentication: { basic: { email: opts.email, apiToken: opts.token } },
162
165
  baseRequestConfig: { timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(opts.axiosAdapter ? { adapter: opts.axiosAdapter } : {}) },
163
- });
166
+ };
167
+ this.client = new Version2Client(this.clientConfig);
164
168
  }
165
169
 
166
170
  async list(filter: ListFilter): Promise<Issue[]> {
@@ -264,6 +268,58 @@ export class JiraRepository {
264
268
  return commentToDomain(raw);
265
269
  }
266
270
 
271
+ /** RawQueryable -- runs a raw JQL string verbatim, for the "Saved query" feature (and anything else that already has real JQL rather than a ListFilter to build one from). */
272
+ async runQuery(query: string, limit = 50): Promise<Issue[]> {
273
+ return this.searchJql(query, limit);
274
+ }
275
+
276
+ /**
277
+ * BoardQuickFilterDiscoverable -- resolves a board's quick filter (the same object
278
+ * a board view's `quickFilter=` URL param and a backlog view's `customFilter=`
279
+ * param both reference -- Jira just spells the query param differently per view)
280
+ * to its real JQL clause, via the durable (non-deprecated)
281
+ * `GET /rest/agile/1.0/board/{boardId}/quickfilter/{id}` endpoint. This is a
282
+ * fragment, not a full query -- Jira itself ANDs it with the board's own project
283
+ * scope and a sprint clause when rendering a live board/backlog view. Combine it
284
+ * with `project = X AND sprint in openSprints()` (board/active-sprint view) or
285
+ * `project = X AND (sprint is EMPTY OR sprint in futureSprints())` (backlog view)
286
+ * to save as a plain runnable JQL string via query.save, same as any other saved
287
+ * query -- no separate "board view" execution path needed. jira.js's AgileClient
288
+ * wraps the Agile REST API surface; Version2Client (this.client) only covers the
289
+ * Platform REST API, hence the separate client here.
290
+ */
291
+ async discoverBoardQuickFilterJql(boardId: number, quickFilterId: number): Promise<string> {
292
+ const agile = this.agileClient();
293
+ const result = await this.call<{ jql?: string }>(() => agile.board.getQuickFilter({ boardId, quickFilterId }));
294
+ if (!result?.jql) throw new Error(`jira: quick filter ${quickFilterId} on board ${boardId} has no JQL`);
295
+ return result.jql;
296
+ }
297
+
298
+ /**
299
+ * BoardFilterDiscoverable -- resolves a board's own real base scope: its saved
300
+ * filter's JQL, via `GET /rest/agile/1.0/board/{boardId}/configuration` (filter
301
+ * id) then `GET /rest/api/2/filter/{id}` (that filter's JQL). This is the actual
302
+ * project/query scope a board tracks -- assuming a board scopes to a single named
303
+ * project (e.g. matching a URL path segment) is a guess, not a fact; a project can
304
+ * be renamed or a board's filter can span multiple projects, so this is always
305
+ * resolved from Jira, never inferred from a project key string.
306
+ */
307
+ async discoverBoardFilterJql(boardId: number): Promise<string> {
308
+ const config = await this.call<{ filter?: { id?: string } }>(() => this.agileClient().board.getConfiguration({ boardId }));
309
+ const filterId = config?.filter?.id;
310
+ if (!filterId) throw new Error(`jira: board ${boardId} has no configured filter`);
311
+ const filter = await this.call<{ jql?: string }>(() => this.client.filters.getFilter({ id: Number(filterId) }));
312
+ if (!filter?.jql) throw new Error(`jira: filter ${filterId} (board ${boardId}'s own filter) has no JQL`);
313
+ return filter.jql;
314
+ }
315
+
316
+ private agileClientInstance?: AgileClient;
317
+
318
+ private agileClient(): AgileClient {
319
+ this.agileClientInstance ??= new AgileClient(this.clientConfig);
320
+ return this.agileClientInstance;
321
+ }
322
+
267
323
  private async searchJql(jql: string, limit: number): Promise<Issue[]> {
268
324
  // searchForIssuesUsingJqlPost hits the deprecated /rest/api/2/search, which
269
325
  // Atlassian has sunset on Jira Cloud (410 Gone). The enhanced variant posts
@@ -8,8 +8,11 @@ import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../do
8
8
  import { parseRef } from "../domain/issue.js";
9
9
  import type { Template } from "../domain/template.js";
10
10
  import {
11
+ hasBoardFilterDiscovery,
12
+ hasBoardQuickFilterDiscovery,
11
13
  hasComments,
12
14
  hasFieldDiscovery,
15
+ hasRawQuery,
13
16
  hasStatusDiscovery,
14
17
  hasTemplateDiscovery,
15
18
  type IssueRepository,
@@ -117,4 +120,25 @@ export class TicketService {
117
120
  if (!hasTemplateDiscovery(repo)) throw new NotSupportedError(backend, "template discovery");
118
121
  return repo.discoverTemplate(project, issueType, sampleSize);
119
122
  }
123
+
124
+ /** Runs a raw query string (Jira JQL) verbatim against one backend -- the execution half of the "saved query" feature. */
125
+ async runQuery(backend: string, query: string, limit?: number): Promise<Issue[]> {
126
+ const repo = this.repo(backend);
127
+ if (!hasRawQuery(repo)) throw new NotSupportedError(backend, "raw queries");
128
+ return repo.runQuery(query, limit);
129
+ }
130
+
131
+ /** Resolves a Jira board's quick filter id to its JQL fragment -- the one-time step that turns a board/backlog view into a saved query. */
132
+ async discoverBoardQuickFilterJql(backend: string, boardId: number, quickFilterId: number): Promise<string> {
133
+ const repo = this.repo(backend);
134
+ if (!hasBoardQuickFilterDiscovery(repo)) throw new NotSupportedError(backend, "board quick filter discovery");
135
+ return repo.discoverBoardQuickFilterJql(boardId, quickFilterId);
136
+ }
137
+
138
+ /** Resolves a Jira board's own real base scope (its saved filter's JQL) -- never assume a board tracks one named project. */
139
+ async discoverBoardFilterJql(backend: string, boardId: number): Promise<string> {
140
+ const repo = this.repo(backend);
141
+ if (!hasBoardFilterDiscovery(repo)) throw new NotSupportedError(backend, "board filter discovery");
142
+ return repo.discoverBoardFilterJql(boardId);
143
+ }
120
144
  }
package/src/cli/index.ts CHANGED
@@ -230,6 +230,59 @@ discoverCmd
230
230
  );
231
231
  });
232
232
 
233
+ discoverCmd
234
+ .command("board_filter")
235
+ .description("resolve a Jira board's own real base scope -- its saved filter's JQL -- instead of assuming it tracks one named project")
236
+ .requiredOption("-b, --backend <name>", "backend name")
237
+ .requiredOption("--board <id>", "board id", (v) => Number.parseInt(v, 10))
238
+ .action(async (opts) => {
239
+ await withClient((client) => client.call("discover.board_filter", { backend: opts.backend, boardId: opts.board }));
240
+ });
241
+
242
+ const queryCmd = program.command("query").description("save and run named raw backend queries (Jira JQL) -- e.g. a board's sprint or backlog view");
243
+
244
+ queryCmd
245
+ .command("save <name>")
246
+ .description("save a raw query under a name; saving under an existing name updates it in place")
247
+ .requiredOption("-b, --backend <name>", "backend name")
248
+ .requiredOption("--jql <jql>", "the raw query string (Jira JQL)")
249
+ .option("--description <text>", "human-readable note about what this query is")
250
+ .action(async (name: string, opts) => {
251
+ await withClient((client) => client.call("query.save", { name, backend: opts.backend, query: opts.jql, description: opts.description }));
252
+ });
253
+
254
+ queryCmd
255
+ .command("list")
256
+ .description("list every saved query")
257
+ .action(async () => {
258
+ await withClient((client) => client.call("query.list", {}));
259
+ });
260
+
261
+ queryCmd
262
+ .command("remove <name>")
263
+ .description("remove a saved query")
264
+ .action(async (name: string) => {
265
+ await withClient((client) => client.call("query.remove", { name }));
266
+ });
267
+
268
+ queryCmd
269
+ .command("run <name>")
270
+ .description("run a saved query by name")
271
+ .option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
272
+ .action(async (name: string, opts) => {
273
+ await withClient((client) => client.call("query.run", { name, limit: opts.limit }));
274
+ });
275
+
276
+ discoverCmd
277
+ .command("board_quickfilter")
278
+ .description("resolve a Jira board's quick filter id to its JQL fragment -- board view's quickFilter=, backlog view's customFilter=, are the same id")
279
+ .requiredOption("-b, --backend <name>", "backend name")
280
+ .requiredOption("--board <id>", "board id", (v) => Number.parseInt(v, 10))
281
+ .requiredOption("--quick-filter <id>", "quick filter id", (v) => Number.parseInt(v, 10))
282
+ .action(async (opts) => {
283
+ await withClient((client) => client.call("discover.board_quickfilter", { backend: opts.backend, boardId: opts.board, quickFilterId: opts.quickFilter }));
284
+ });
285
+
233
286
  const daemon = program.command("daemon").description("manage the tickets daemon process");
234
287
 
235
288
  daemon
@@ -15,6 +15,7 @@ import { buildRepositories, type BuildRepositories, type Config, createBackendRe
15
15
  import type { IssueRepository } from "../ports/repository.js";
16
16
  import { FOCUS_MIGRATIONS, FocusStore } from "./focus.js";
17
17
  import { Ledger, LEDGER_MIGRATIONS } from "./ledger.js";
18
+ import { SAVED_QUERY_MIGRATIONS, SavedQueryStore } from "./saved-queries.js";
18
19
  import { TICKETS_DAEMON_NAMES } from "./ops.js";
19
20
  import { buildApp, type TicketsAppDeps } from "./server.js";
20
21
  import { createSyncTask } from "./poller.js";
@@ -50,6 +51,7 @@ export interface BootstrappedDaemon {
50
51
  db: Database;
51
52
  ledger: Ledger;
52
53
  focusStore: FocusStore;
54
+ queries: SavedQueryStore;
53
55
  service: TicketService;
54
56
  options: StartDaemonOptions;
55
57
  }
@@ -61,9 +63,10 @@ const DEFAULT_BACKEND_REFRESH_INTERVAL_MS = 30_000;
61
63
  export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
62
64
  const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
63
65
  const token = ensureAuthToken(paths.token, "Tickets");
64
- const db = openSqliteWithPragmas(paths.database, { migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS] });
66
+ const db = openSqliteWithPragmas(paths.database, { migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS, ...SAVED_QUERY_MIGRATIONS] });
65
67
  const ledger = new Ledger(db);
66
68
  const focusStore = new FocusStore(db);
69
+ const queries = new SavedQueryStore(db);
67
70
  const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
68
71
  const config = opts.config ?? loadConfig();
69
72
  const buildRepos = opts.buildRepositories ?? buildRepositories;
@@ -76,7 +79,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
76
79
  // the registry field itself -- createTicketsVehicleRegistry never reads
77
80
  // deps.vehicleRegistry, so this ordering is safe (see server.ts's own
78
81
  // comment on why the registry is built outside it, not imported into it).
79
- const vehicleRegistry = createTicketsVehicleRegistry({ service, ledger, focusStore, token, version, logger, onShutdownRequested } as TicketsAppDeps);
82
+ const vehicleRegistry = createTicketsVehicleRegistry({ service, ledger, focusStore, queries, token, version, logger, onShutdownRequested } as TicketsAppDeps);
80
83
 
81
84
  const options: StartDaemonOptions = {
82
85
  daemonLabel: "Tickets",
@@ -100,6 +103,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
100
103
  service,
101
104
  ledger,
102
105
  focusStore,
106
+ queries,
103
107
  token,
104
108
  version,
105
109
  logger,
@@ -111,5 +115,5 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
111
115
  },
112
116
  };
113
117
 
114
- return { db, ledger, focusStore, service, options };
118
+ return { db, ledger, focusStore, queries, service, options };
115
119
  }
package/src/daemon/ops.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../domain/issue.js";
8
8
  import type { Template } from "../domain/template.js";
9
9
  import type { TicketFocusState } from "./focus.js";
10
+ import type { SavedQuery } from "./saved-queries.js";
10
11
 
11
12
  export type TicketOperation =
12
13
  | "backends.list"
@@ -28,6 +29,12 @@ export type TicketOperation =
28
29
  | "discover.fields"
29
30
  | "discover.statuses"
30
31
  | "discover.template"
32
+ | "discover.board_quickfilter"
33
+ | "discover.board_filter"
34
+ | "query.save"
35
+ | "query.list"
36
+ | "query.remove"
37
+ | "query.run"
31
38
  | "daemon.shutdown";
32
39
 
33
40
  export interface TicketOpInputs extends Record<TicketOperation, unknown> {
@@ -50,6 +57,12 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
50
57
  "discover.fields": { backend: string };
51
58
  "discover.statuses": { backend: string };
52
59
  "discover.template": { backend: string; project: string; issueType: string; sampleSize?: number };
60
+ "discover.board_quickfilter": { backend: string; boardId: number; quickFilterId: number };
61
+ "discover.board_filter": { backend: string; boardId: number };
62
+ "query.save": { name: string; backend: string; query: string; description?: string };
63
+ "query.list": Record<string, never>;
64
+ "query.remove": { name: string };
65
+ "query.run": { name: string; limit?: number };
53
66
  "daemon.shutdown": Record<string, never>;
54
67
  }
55
68
 
@@ -73,6 +86,12 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
73
86
  "discover.fields": { mappings: Record<string, string> };
74
87
  "discover.statuses": { mappings: Record<string, string> };
75
88
  "discover.template": { template: Template | null };
89
+ "discover.board_quickfilter": { jql: string };
90
+ "discover.board_filter": { jql: string };
91
+ "query.save": { query: SavedQuery };
92
+ "query.list": { queries: SavedQuery[] };
93
+ "query.remove": { removed: boolean };
94
+ "query.run": { issues: Issue[] };
76
95
  "daemon.shutdown": { stopping: true };
77
96
  }
78
97
 
@@ -96,6 +115,12 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
96
115
  "discover.fields",
97
116
  "discover.statuses",
98
117
  "discover.template",
118
+ "discover.board_quickfilter",
119
+ "discover.board_filter",
120
+ "query.save",
121
+ "query.list",
122
+ "query.remove",
123
+ "query.run",
99
124
  "daemon.shutdown",
100
125
  ];
101
126
 
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Saved queries — a name plus a raw query string (Jira JQL today; the backend
3
+ * decides what "raw query" means via ports/repository.ts's RawQueryable) that
4
+ * can be run again by name instead of re-typing the query every time. This is
5
+ * how a board/backlog view (e.g. a Jira board's quickFilter, or its backlog's
6
+ * customFilter) becomes something the CLI/TUI/tool surface can browse
7
+ * directly: resolve the view's real query once (see JiraRepository's
8
+ * getBoardQuickFilterJql/getBoardViewIssues), then save the result under a
9
+ * name once and just run it by name from then on.
10
+ */
11
+ import type { Database } from "bun:sqlite";
12
+ import type { Migration } from "@danypops/vehicle-server/storage";
13
+
14
+ export const SAVED_QUERY_MIGRATIONS: Migration[] = [
15
+ {
16
+ version: 3,
17
+ up: (db) => {
18
+ db.exec(`
19
+ CREATE TABLE saved_query (
20
+ name TEXT PRIMARY KEY,
21
+ backend TEXT NOT NULL,
22
+ query TEXT NOT NULL,
23
+ description TEXT,
24
+ created_at TEXT NOT NULL,
25
+ updated_at TEXT NOT NULL
26
+ );
27
+ `);
28
+ },
29
+ },
30
+ ];
31
+
32
+ export class SavedQueryNotFoundError extends Error {
33
+ constructor(name: string) {
34
+ super(`no saved query named "${name}"`);
35
+ this.name = "SavedQueryNotFoundError";
36
+ }
37
+ }
38
+
39
+ export interface SavedQuery {
40
+ name: string;
41
+ backend: string;
42
+ query: string;
43
+ description?: string;
44
+ createdAt: string;
45
+ updatedAt: string;
46
+ }
47
+
48
+ interface SavedQueryRow {
49
+ name: string;
50
+ backend: string;
51
+ query: string;
52
+ description: string | null;
53
+ created_at: string;
54
+ updated_at: string;
55
+ }
56
+
57
+ function rowToSavedQuery(row: SavedQueryRow): SavedQuery {
58
+ return {
59
+ name: row.name,
60
+ backend: row.backend,
61
+ query: row.query,
62
+ description: row.description ?? undefined,
63
+ createdAt: row.created_at,
64
+ updatedAt: row.updated_at,
65
+ };
66
+ }
67
+
68
+ export class SavedQueryStore {
69
+ constructor(private readonly db: Database) {}
70
+
71
+ /** Creates or overwrites a saved query by name -- saving under an existing name updates it in place rather than erroring, matching the "save this view under a name" mental model (re-saving after a query changed shouldn't require a delete first). */
72
+ save(name: string, backend: string, query: string, description?: string): SavedQuery {
73
+ const now = new Date().toISOString();
74
+ const existing = this.get(name);
75
+ const createdAt = existing?.createdAt ?? now;
76
+ this.db
77
+ .query(
78
+ `INSERT INTO saved_query (name, backend, query, description, created_at, updated_at)
79
+ VALUES ($name, $backend, $query, $description, $createdAt, $updatedAt)
80
+ ON CONFLICT(name) DO UPDATE SET
81
+ backend = excluded.backend, query = excluded.query, description = excluded.description, updated_at = excluded.updated_at`,
82
+ )
83
+ .run({ $name: name, $backend: backend, $query: query, $description: description ?? null, $createdAt: createdAt, $updatedAt: now });
84
+ return { name, backend, query, description, createdAt, updatedAt: now };
85
+ }
86
+
87
+ get(name: string): SavedQuery | undefined {
88
+ const row = this.db.query("SELECT * FROM saved_query WHERE name = $name").get({ $name: name }) as SavedQueryRow | null;
89
+ return row ? rowToSavedQuery(row) : undefined;
90
+ }
91
+
92
+ list(): SavedQuery[] {
93
+ const rows = this.db.query("SELECT * FROM saved_query ORDER BY name ASC").all() as SavedQueryRow[];
94
+ return rows.map(rowToSavedQuery);
95
+ }
96
+
97
+ /** Idempotent -- removing a name that doesn't exist is a no-op, not an error, matching this codebase's own undepend/uncontain convention for "already absent". */
98
+ remove(name: string): boolean {
99
+ const result = this.db.query("DELETE FROM saved_query WHERE name = $name").run({ $name: name });
100
+ return result.changes > 0;
101
+ }
102
+ }
@@ -16,11 +16,13 @@ import { parseRef } from "../domain/issue.js";
16
16
  import { FocusError, type FocusStore } from "./focus.js";
17
17
  import type { Ledger } from "./ledger.js";
18
18
  import { TICKET_OPERATIONS, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "./ops.js";
19
+ import { SavedQueryNotFoundError, type SavedQueryStore } from "./saved-queries.js";
19
20
 
20
21
  export interface TicketsAppDeps {
21
22
  service: TicketService;
22
23
  ledger: Ledger;
23
24
  focusStore: FocusStore;
25
+ queries: SavedQueryStore;
24
26
  token: string;
25
27
  version: string;
26
28
  logger?: Logger;
@@ -89,6 +91,18 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
89
91
  "discover.template": async (deps, input) => ({
90
92
  template: (await deps.service.discoverTemplate(input.backend, input.project, input.issueType, input.sampleSize)) ?? null,
91
93
  }),
94
+ "discover.board_quickfilter": async (deps, input) => ({
95
+ jql: await deps.service.discoverBoardQuickFilterJql(input.backend, input.boardId, input.quickFilterId),
96
+ }),
97
+ "discover.board_filter": async (deps, input) => ({ jql: await deps.service.discoverBoardFilterJql(input.backend, input.boardId) }),
98
+ "query.save": async (deps, input) => ({ query: deps.queries.save(input.name, input.backend, input.query, input.description) }),
99
+ "query.list": async (deps) => ({ queries: deps.queries.list() }),
100
+ "query.remove": async (deps, input) => ({ removed: deps.queries.remove(input.name) }),
101
+ "query.run": async (deps, input) => {
102
+ const saved = deps.queries.get(input.name);
103
+ if (!saved) throw new SavedQueryNotFoundError(input.name);
104
+ return { issues: await deps.service.runQuery(saved.backend, saved.query, input.limit) };
105
+ },
92
106
  "daemon.shutdown": async (deps) => {
93
107
  // Deferred so this handler's own response has already been handed back
94
108
  // to Bun.serve before the process starts tearing down.
@@ -102,7 +116,7 @@ function isTicketOperation(value: unknown): value is TicketOperation {
102
116
  }
103
117
 
104
118
  function statusFor(error: unknown): number {
105
- if (error instanceof IssueNotFoundError) return 404;
119
+ if (error instanceof IssueNotFoundError || error instanceof SavedQueryNotFoundError) return 404;
106
120
  if (error instanceof UnknownBackendError || error instanceof NotSupportedError || error instanceof FocusError) return 400;
107
121
  if (error instanceof AuthRequiredError) return 422;
108
122
  return 500;
@@ -59,3 +59,44 @@ export interface TemplateDiscoverable {
59
59
  export function hasTemplateDiscovery(repo: IssueRepository): repo is IssueRepository & TemplateDiscoverable {
60
60
  return typeof (repo as Partial<TemplateDiscoverable>).discoverTemplate === "function";
61
61
  }
62
+
63
+ /**
64
+ * Optional capability — runs a raw query string in the backend's own query language
65
+ * (Jira's JQL). Backs the "Saved query" feature: a saved query is just a name plus a
66
+ * raw string in whatever language the backend understands, executed verbatim. Only
67
+ * Jira supports this today (GitHub/GitLab have no equivalent single query language
68
+ * spanning issues, boards, and backlogs the way Jira's JQL does).
69
+ */
70
+ export interface RawQueryable {
71
+ runQuery(query: string, limit?: number): Promise<Issue[]>;
72
+ }
73
+
74
+ export function hasRawQuery(repo: IssueRepository): repo is IssueRepository & RawQueryable {
75
+ return typeof (repo as Partial<RawQueryable>).runQuery === "function";
76
+ }
77
+
78
+ /**
79
+ * Optional capability — resolves a Jira board's quick filter id to its real JQL
80
+ * fragment, the one-time step that turns a board/backlog view URL into a saved
81
+ * query (see RawQueryable above). Jira only; boards/quick filters have no GitHub or
82
+ * GitLab equivalent.
83
+ */
84
+ export interface BoardQuickFilterDiscoverable {
85
+ discoverBoardQuickFilterJql(boardId: number, quickFilterId: number): Promise<string>;
86
+ }
87
+
88
+ export function hasBoardQuickFilterDiscovery(repo: IssueRepository): repo is IssueRepository & BoardQuickFilterDiscoverable {
89
+ return typeof (repo as Partial<BoardQuickFilterDiscoverable>).discoverBoardQuickFilterJql === "function";
90
+ }
91
+
92
+ /**
93
+ * Optional capability — resolves a Jira board's own real base scope (its saved
94
+ * filter's JQL) rather than assuming a board tracks one named project. Jira only.
95
+ */
96
+ export interface BoardFilterDiscoverable {
97
+ discoverBoardFilterJql(boardId: number): Promise<string>;
98
+ }
99
+
100
+ export function hasBoardFilterDiscovery(repo: IssueRepository): repo is IssueRepository & BoardFilterDiscoverable {
101
+ return typeof (repo as Partial<BoardFilterDiscoverable>).discoverBoardFilterJql === "function";
102
+ }
@@ -105,6 +105,36 @@ const OPERATIONS: readonly OperationSpec[] = [
105
105
  properties: { backend: stringProp, project: stringProp, issueType: stringProp, sampleSize: numberProp },
106
106
  required: ["backend", "project", "issueType"],
107
107
  },
108
+ {
109
+ action: "discover.board_quickfilter",
110
+ description: "Resolves a Jira board's quick filter id to its JQL fragment -- the one-time step to turn a board/backlog view into a saved query.",
111
+ effect: "read",
112
+ properties: { backend: stringProp, boardId: numberProp, quickFilterId: numberProp },
113
+ required: ["backend", "boardId", "quickFilterId"],
114
+ },
115
+ {
116
+ action: "discover.board_filter",
117
+ description: "Resolves a Jira board's own real base scope -- its saved filter's JQL -- rather than assuming it tracks one named project.",
118
+ effect: "read",
119
+ properties: { backend: stringProp, boardId: numberProp },
120
+ required: ["backend", "boardId"],
121
+ },
122
+ {
123
+ action: "query.save",
124
+ description: "Saves a raw backend query (Jira JQL) under a name, so it can be run again later without retyping it -- e.g. a board's sprint or backlog view.",
125
+ effect: "local-write",
126
+ properties: { name: stringProp, backend: stringProp, query: stringProp, description: stringProp },
127
+ required: ["name", "backend", "query"],
128
+ },
129
+ { action: "query.list", description: "Lists every saved query.", effect: "read", properties: {}, required: [] },
130
+ { action: "query.remove", description: "Removes a saved query by name.", effect: "local-write", properties: { name: stringProp }, required: ["name"] },
131
+ {
132
+ action: "query.run",
133
+ description: "Runs a saved query by name against its backend and returns the matching issues.",
134
+ effect: "read",
135
+ properties: { name: stringProp, limit: numberProp },
136
+ required: ["name"],
137
+ },
108
138
  ];
109
139
 
110
140
  /**