@danypops/tickets 0.4.7 → 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 +1 -1
- package/src/adapters/jira.ts +44 -6
- package/src/application/service.ts +16 -0
- package/src/cli/index.ts +44 -0
- package/src/daemon/bootstrap.ts +7 -3
- package/src/daemon/ops.ts +21 -0
- package/src/daemon/saved-queries.ts +102 -0
- package/src/daemon/server.ts +14 -1
- package/src/ports/repository.ts +29 -0
- package/src/vehicle/tickets-vehicle.ts +23 -0
package/package.json
CHANGED
package/src/adapters/jira.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
package/src/daemon/bootstrap.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -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;
|
package/src/ports/repository.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -105,6 +105,29 @@ 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: "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
|
+
},
|
|
108
131
|
];
|
|
109
132
|
|
|
110
133
|
/**
|