@danypops/tickets 0.4.6 → 0.5.0

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.6",
3
+ "version": "0.5.0",
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,40 @@ 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
+ private agileClientInstance?: AgileClient;
299
+
300
+ private agileClient(): AgileClient {
301
+ this.agileClientInstance ??= new AgileClient(this.clientConfig);
302
+ return this.agileClientInstance;
303
+ }
304
+
267
305
  private async searchJql(jql: string, limit: number): Promise<Issue[]> {
268
306
  // searchForIssuesUsingJqlPost hits the deprecated /rest/api/2/search, which
269
307
  // Atlassian has sunset on Jira Cloud (410 Gone). The enhanced variant posts
@@ -8,8 +8,10 @@ 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
+ hasBoardQuickFilterDiscovery,
11
12
  hasComments,
12
13
  hasFieldDiscovery,
14
+ hasRawQuery,
13
15
  hasStatusDiscovery,
14
16
  hasTemplateDiscovery,
15
17
  type IssueRepository,
@@ -117,4 +119,18 @@ export class TicketService {
117
119
  if (!hasTemplateDiscovery(repo)) throw new NotSupportedError(backend, "template discovery");
118
120
  return repo.discoverTemplate(project, issueType, sampleSize);
119
121
  }
122
+
123
+ /** Runs a raw query string (Jira JQL) verbatim against one backend -- the execution half of the "saved query" feature. */
124
+ async runQuery(backend: string, query: string, limit?: number): Promise<Issue[]> {
125
+ const repo = this.repo(backend);
126
+ if (!hasRawQuery(repo)) throw new NotSupportedError(backend, "raw queries");
127
+ return repo.runQuery(query, limit);
128
+ }
129
+
130
+ /** 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. */
131
+ async discoverBoardQuickFilterJql(backend: string, boardId: number, quickFilterId: number): Promise<string> {
132
+ const repo = this.repo(backend);
133
+ if (!hasBoardQuickFilterDiscovery(repo)) throw new NotSupportedError(backend, "board quick filter discovery");
134
+ return repo.discoverBoardQuickFilterJql(boardId, quickFilterId);
135
+ }
120
136
  }
package/src/cli/index.ts CHANGED
@@ -230,6 +230,50 @@ discoverCmd
230
230
  );
231
231
  });
232
232
 
233
+ const queryCmd = program.command("query").description("save and run named raw backend queries (Jira JQL) -- e.g. a board's sprint or backlog view");
234
+
235
+ queryCmd
236
+ .command("save <name>")
237
+ .description("save a raw query under a name; saving under an existing name updates it in place")
238
+ .requiredOption("-b, --backend <name>", "backend name")
239
+ .requiredOption("--jql <jql>", "the raw query string (Jira JQL)")
240
+ .option("--description <text>", "human-readable note about what this query is")
241
+ .action(async (name: string, opts) => {
242
+ await withClient((client) => client.call("query.save", { name, backend: opts.backend, query: opts.jql, description: opts.description }));
243
+ });
244
+
245
+ queryCmd
246
+ .command("list")
247
+ .description("list every saved query")
248
+ .action(async () => {
249
+ await withClient((client) => client.call("query.list", {}));
250
+ });
251
+
252
+ queryCmd
253
+ .command("remove <name>")
254
+ .description("remove a saved query")
255
+ .action(async (name: string) => {
256
+ await withClient((client) => client.call("query.remove", { name }));
257
+ });
258
+
259
+ queryCmd
260
+ .command("run <name>")
261
+ .description("run a saved query by name")
262
+ .option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
263
+ .action(async (name: string, opts) => {
264
+ await withClient((client) => client.call("query.run", { name, limit: opts.limit }));
265
+ });
266
+
267
+ discoverCmd
268
+ .command("board_quickfilter")
269
+ .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")
270
+ .requiredOption("-b, --backend <name>", "backend name")
271
+ .requiredOption("--board <id>", "board id", (v) => Number.parseInt(v, 10))
272
+ .requiredOption("--quick-filter <id>", "quick filter id", (v) => Number.parseInt(v, 10))
273
+ .action(async (opts) => {
274
+ await withClient((client) => client.call("discover.board_quickfilter", { backend: opts.backend, boardId: opts.board, quickFilterId: opts.quickFilter }));
275
+ });
276
+
233
277
  const daemon = program.command("daemon").description("manage the tickets daemon process");
234
278
 
235
279
  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,11 @@ export type TicketOperation =
28
29
  | "discover.fields"
29
30
  | "discover.statuses"
30
31
  | "discover.template"
32
+ | "discover.board_quickfilter"
33
+ | "query.save"
34
+ | "query.list"
35
+ | "query.remove"
36
+ | "query.run"
31
37
  | "daemon.shutdown";
32
38
 
33
39
  export interface TicketOpInputs extends Record<TicketOperation, unknown> {
@@ -50,6 +56,11 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
50
56
  "discover.fields": { backend: string };
51
57
  "discover.statuses": { backend: string };
52
58
  "discover.template": { backend: string; project: string; issueType: string; sampleSize?: number };
59
+ "discover.board_quickfilter": { backend: string; boardId: number; quickFilterId: number };
60
+ "query.save": { name: string; backend: string; query: string; description?: string };
61
+ "query.list": Record<string, never>;
62
+ "query.remove": { name: string };
63
+ "query.run": { name: string; limit?: number };
53
64
  "daemon.shutdown": Record<string, never>;
54
65
  }
55
66
 
@@ -73,6 +84,11 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
73
84
  "discover.fields": { mappings: Record<string, string> };
74
85
  "discover.statuses": { mappings: Record<string, string> };
75
86
  "discover.template": { template: Template | null };
87
+ "discover.board_quickfilter": { jql: string };
88
+ "query.save": { query: SavedQuery };
89
+ "query.list": { queries: SavedQuery[] };
90
+ "query.remove": { removed: boolean };
91
+ "query.run": { issues: Issue[] };
76
92
  "daemon.shutdown": { stopping: true };
77
93
  }
78
94
 
@@ -96,6 +112,11 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
96
112
  "discover.fields",
97
113
  "discover.statuses",
98
114
  "discover.template",
115
+ "discover.board_quickfilter",
116
+ "query.save",
117
+ "query.list",
118
+ "query.remove",
119
+ "query.run",
99
120
  "daemon.shutdown",
100
121
  ];
101
122
 
@@ -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,17 @@ 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
+ "query.save": async (deps, input) => ({ query: deps.queries.save(input.name, input.backend, input.query, input.description) }),
98
+ "query.list": async (deps) => ({ queries: deps.queries.list() }),
99
+ "query.remove": async (deps, input) => ({ removed: deps.queries.remove(input.name) }),
100
+ "query.run": async (deps, input) => {
101
+ const saved = deps.queries.get(input.name);
102
+ if (!saved) throw new SavedQueryNotFoundError(input.name);
103
+ return { issues: await deps.service.runQuery(saved.backend, saved.query, input.limit) };
104
+ },
92
105
  "daemon.shutdown": async (deps) => {
93
106
  // Deferred so this handler's own response has already been handed back
94
107
  // to Bun.serve before the process starts tearing down.
@@ -102,7 +115,7 @@ function isTicketOperation(value: unknown): value is TicketOperation {
102
115
  }
103
116
 
104
117
  function statusFor(error: unknown): number {
105
- if (error instanceof IssueNotFoundError) return 404;
118
+ if (error instanceof IssueNotFoundError || error instanceof SavedQueryNotFoundError) return 404;
106
119
  if (error instanceof UnknownBackendError || error instanceof NotSupportedError || error instanceof FocusError) return 400;
107
120
  if (error instanceof AuthRequiredError) return 422;
108
121
  return 500;
@@ -59,3 +59,32 @@ 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
+ }
@@ -32,11 +32,38 @@ interface OperationSpec {
32
32
  readonly effect: VehicleEffect;
33
33
  readonly properties: Record<string, LooseObjectProperty>;
34
34
  readonly required: readonly string[];
35
+ /**
36
+ * Reshapes the flat tool-facing input into whatever TICKET_OP_HANDLERS
37
+ * expects, for the one operation where those differ: issue.list flattens
38
+ * project/status/assignee/labels/limit as top-level tool properties
39
+ * (matching issue.search's own flat convention, and pi-stef/atlassian's
40
+ * jira_search_issues/jira_get_project_issues -- every filter param is its
41
+ * own top-level property there too, never an opaque nested bag), while
42
+ * the RPC/CLI-level handler contract keeps its existing nested
43
+ * `filter: ListFilter` shape. Identity by default.
44
+ */
45
+ readonly mapInput?: (input: Record<string, unknown>) => Record<string, unknown>;
46
+ }
47
+
48
+ const stringArrayProp: LooseObjectProperty = { type: "array" };
49
+
50
+ function definedEntriesOnly(input: Record<string, unknown>): Record<string, unknown> {
51
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
35
52
  }
36
53
 
37
54
  const OPERATIONS: readonly OperationSpec[] = [
38
55
  { action: "backends.list", description: "Lists every configured backend name (github, gitlab, jira, ...).", effect: "read", properties: {}, required: [] },
39
- { action: "issue.list", description: "Lists issues from one backend, optionally filtered.", effect: "read", properties: { backend: stringProp, filter: { type: "object" } }, required: ["backend"] },
56
+ {
57
+ action: "issue.list",
58
+ description: "Lists issues from one backend, optionally filtered.",
59
+ effect: "read",
60
+ properties: { backend: stringProp, project: stringProp, status: stringProp, assignee: stringProp, labels: stringArrayProp, limit: numberProp },
61
+ required: ["backend"],
62
+ mapInput: ({ backend, project, status, assignee, labels, limit }) => ({
63
+ backend,
64
+ filter: definedEntriesOnly({ project, status, assignee, labels, limit }),
65
+ }),
66
+ },
40
67
  { action: "issue.get", description: "Gets one issue by its ref (e.g. \"github:#42\").", effect: "read", properties: { ref: stringProp }, required: ["ref"] },
41
68
  {
42
69
  action: "issue.create",
@@ -78,6 +105,29 @@ const OPERATIONS: readonly OperationSpec[] = [
78
105
  properties: { backend: stringProp, project: stringProp, issueType: stringProp, sampleSize: numberProp },
79
106
  required: ["backend", "project", "issueType"],
80
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: "query.save",
117
+ 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.",
118
+ effect: "local-write",
119
+ properties: { name: stringProp, backend: stringProp, query: stringProp, description: stringProp },
120
+ required: ["name", "backend", "query"],
121
+ },
122
+ { action: "query.list", description: "Lists every saved query.", effect: "read", properties: {}, required: [] },
123
+ { action: "query.remove", description: "Removes a saved query by name.", effect: "local-write", properties: { name: stringProp }, required: ["name"] },
124
+ {
125
+ action: "query.run",
126
+ description: "Runs a saved query by name against its backend and returns the matching issues.",
127
+ effect: "read",
128
+ properties: { name: stringProp, limit: numberProp },
129
+ required: ["name"],
130
+ },
81
131
  ];
82
132
 
83
133
  /**
@@ -103,7 +153,11 @@ export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicle
103
153
  limits: LIMITS,
104
154
  });
105
155
  const handler = TICKET_OP_HANDLERS[spec.action];
106
- registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => handler(deps, context.input as never)));
156
+ const mapInput = spec.mapInput ?? ((input: Record<string, unknown>) => input);
157
+ registry.register(
158
+ OWNER,
159
+ bindVehicleOperation(operation, () => async (context) => handler(deps, mapInput(context.input as Record<string, unknown>) as never)),
160
+ );
107
161
  }
108
162
 
109
163
  return registry;