@danypops/tickets 0.6.0 → 0.8.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/github.ts +3 -3
- package/src/adapters/gitlab.ts +7 -15
- package/src/adapters/http.ts +1 -3
- package/src/adapters/jira.ts +48 -16
- package/src/application/service.ts +41 -3
- package/src/auth/browser.ts +9 -3
- package/src/auth/device-flow.ts +3 -9
- package/src/auth/gh-cli.ts +23 -20
- package/src/auth/jira-oauth.ts +4 -7
- package/src/cli/index.ts +66 -22
- package/src/client/tickets-client.ts +4 -8
- package/src/config/config.ts +30 -3
- package/src/daemon/bootstrap.ts +26 -8
- package/src/daemon/ledger.ts +4 -3
- package/src/daemon/poller.ts +4 -8
- package/src/daemon/server.ts +5 -4
- package/src/domain/issue.ts +1 -8
- package/src/index.ts +26 -27
- package/src/ports/repository.ts +17 -0
- package/src/vehicle/tickets-vehicle.ts +162 -19
package/package.json
CHANGED
package/src/adapters/github.ts
CHANGED
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
* indeterminate duration). Both plugins are explicitly disabled below, and
|
|
19
19
|
* every call carries a hard timeout matching the old hand-rolled HttpClient's.
|
|
20
20
|
*/
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
import { RequestError } from "@octokit/request-error";
|
|
23
|
-
import
|
|
24
|
-
import { parsePriority } from "../domain/issue.js";
|
|
23
|
+
import { Octokit } from "octokit";
|
|
24
|
+
import type { Comment, CreateInput, Issue, ListFilter, parsePriority, Status, UpdateInput } from "../domain/issue.js";
|
|
25
25
|
import { ApiError, AuthRequiredError, IssueNotFoundError } from "./errors.js";
|
|
26
26
|
|
|
27
27
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
package/src/adapters/gitlab.ts
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
* Self-hosted base URLs are still validated to reject SSRF-prone targets before
|
|
11
11
|
* any request is made.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
import { GitbeakerRequestError, type RequesterType, type ResourceOptions } from "@gitbeaker/requester-utils";
|
|
13
|
+
|
|
15
14
|
import { isIP } from "node:net";
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
15
|
+
import { GitbeakerRequestError, type RequesterType, type ResourceOptions } from "@gitbeaker/requester-utils";
|
|
16
|
+
import { Gitlab } from "@gitbeaker/rest";
|
|
17
|
+
import type { Comment, CreateInput, Issue, ListFilter, parsePriority, Status, UpdateInput } from "../domain/issue.js";
|
|
18
18
|
import { ApiError, AuthRequiredError, InvalidUrlError, IssueNotFoundError } from "./errors.js";
|
|
19
19
|
|
|
20
20
|
export interface GitLabOptions {
|
|
@@ -80,11 +80,7 @@ export class GitLabRepository {
|
|
|
80
80
|
// is set (confirmed by reading its source), so a stalled call fails predictably
|
|
81
81
|
// instead of hanging -- same class of gap the octokit adapter needed a manual fix for.
|
|
82
82
|
queryTimeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
83
|
-
...(opts.token
|
|
84
|
-
? opts.tokenType === "oauth"
|
|
85
|
-
? { oauthToken: opts.token }
|
|
86
|
-
: { token: opts.token }
|
|
87
|
-
: {}),
|
|
83
|
+
...(opts.token ? (opts.tokenType === "oauth" ? { oauthToken: opts.token } : { token: opts.token }) : {}),
|
|
88
84
|
...(opts.requesterFn ? { requesterFn: opts.requesterFn } : {}),
|
|
89
85
|
});
|
|
90
86
|
}
|
|
@@ -144,9 +140,7 @@ export class GitLabRepository {
|
|
|
144
140
|
// project is accepted for IssueRepository interface parity but ignored --
|
|
145
141
|
// GitLab's scope (projectId) is fixed at construction, not overridable per call.
|
|
146
142
|
async search(query: string, limit = 50, _project?: string): Promise<Issue[]> {
|
|
147
|
-
const raw = await this.call<GlIssue[]>(() =>
|
|
148
|
-
this.client.Issues.all({ projectId: this.projectId, search: query, perPage: limit }),
|
|
149
|
-
);
|
|
143
|
+
const raw = await this.call<GlIssue[]>(() => this.client.Issues.all({ projectId: this.projectId, search: query, perPage: limit }));
|
|
150
144
|
return raw.map(toDomain);
|
|
151
145
|
}
|
|
152
146
|
|
|
@@ -275,9 +269,7 @@ export function validateUrl(rawUrl: string): void {
|
|
|
275
269
|
throw new InvalidUrlError(`gitlab: scheme must be http(s) (got ${parsed.protocol})`);
|
|
276
270
|
}
|
|
277
271
|
if (parsed.protocol === "http:" && parsed.hostname !== "localhost") {
|
|
278
|
-
throw new InvalidUrlError(
|
|
279
|
-
`gitlab: http:// only allowed for localhost (got ${parsed.hostname}); use https:// for remote instances`,
|
|
280
|
-
);
|
|
272
|
+
throw new InvalidUrlError(`gitlab: http:// only allowed for localhost (got ${parsed.hostname}); use https:// for remote instances`);
|
|
281
273
|
}
|
|
282
274
|
if (isIP(parsed.hostname) && isPrivateIp(parsed.hostname)) {
|
|
283
275
|
throw new InvalidUrlError("gitlab: private IP addresses are not allowed (blocks SSRF)");
|
package/src/adapters/http.ts
CHANGED
|
@@ -80,7 +80,5 @@ export class HttpClient {
|
|
|
80
80
|
|
|
81
81
|
/** Strips anything that looks like a bearer/basic credential from error bodies before logging. */
|
|
82
82
|
function redact(text: string): string {
|
|
83
|
-
return text
|
|
84
|
-
.replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"')
|
|
85
|
-
.slice(0, 2000);
|
|
83
|
+
return text.replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"').slice(0, 2000);
|
|
86
84
|
}
|
package/src/adapters/jira.ts
CHANGED
|
@@ -12,15 +12,15 @@
|
|
|
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
|
-
|
|
16
|
-
import type { Config as JiraClientConfig, HttpException } from "jira.js";
|
|
15
|
+
|
|
17
16
|
import type { AxiosAdapter } from "axios";
|
|
18
|
-
import type {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
17
|
+
import type { HttpException, Config as JiraClientConfig } from "jira.js";
|
|
18
|
+
import { AgileClient, Version2Client } from "jira.js";
|
|
19
|
+
import type { Comment, CreateInput, Issue, IssueLink, ListFilter, parsePriority, Status, UpdateInput } from "../domain/issue.js";
|
|
21
20
|
import type { Template } from "../domain/template.js";
|
|
22
21
|
import { buildTemplateBody, extractTemplateSections } from "../domain/template.js";
|
|
23
22
|
import * as manifest from "../manifest/manifest.js";
|
|
23
|
+
import { ApiError, IssueNotFoundError } from "./errors.js";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Basic-auth mode (email + API token) hits the tenant's own *.atlassian.net
|
|
@@ -39,6 +39,10 @@ export interface JiraBasicAuthOptions {
|
|
|
39
39
|
email: string;
|
|
40
40
|
token: string;
|
|
41
41
|
project?: string;
|
|
42
|
+
/** Additional project keys the poller's background sync also pools into the ledger, beyond the single default `project` above -- see buildSyncQuery(). */
|
|
43
|
+
syncProjects?: string[];
|
|
44
|
+
/** When true, the poller's background sync also pools everything assigned to the authenticated user (JQL `assignee = currentUser()`), regardless of project -- covers projects not listed in `project`/`syncProjects`. */
|
|
45
|
+
syncMine?: boolean;
|
|
42
46
|
timeoutMs?: number;
|
|
43
47
|
/** Injected in tests instead of a real network call — see axios's AxiosRequestConfig.adapter. */
|
|
44
48
|
axiosAdapter?: AxiosAdapter;
|
|
@@ -55,6 +59,8 @@ export interface JiraOAuthOptions {
|
|
|
55
59
|
accessToken: string;
|
|
56
60
|
cloudId: string;
|
|
57
61
|
project?: string;
|
|
62
|
+
syncProjects?: string[];
|
|
63
|
+
syncMine?: boolean;
|
|
58
64
|
timeoutMs?: number;
|
|
59
65
|
axiosAdapter?: AxiosAdapter;
|
|
60
66
|
configDir?: string;
|
|
@@ -125,6 +131,8 @@ export class JiraRepository {
|
|
|
125
131
|
/** 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. */
|
|
126
132
|
private readonly clientConfig: JiraClientConfig;
|
|
127
133
|
private readonly project?: string;
|
|
134
|
+
private readonly syncProjects: string[];
|
|
135
|
+
private readonly syncMine: boolean;
|
|
128
136
|
private readonly configDir?: string;
|
|
129
137
|
/** display name (lowercased) -> { fieldId, schema type/items }, populated lazily from client.issueFields.getFields(). */
|
|
130
138
|
private customFieldCache?: Map<string, { id: string; type: string; items?: string }>;
|
|
@@ -136,6 +144,8 @@ export class JiraRepository {
|
|
|
136
144
|
constructor(name: string, opts: JiraOptions) {
|
|
137
145
|
this.name = name;
|
|
138
146
|
this.project = opts.project;
|
|
147
|
+
this.syncProjects = opts.syncProjects ?? [];
|
|
148
|
+
this.syncMine = opts.syncMine ?? false;
|
|
139
149
|
this.configDir = opts.configDir;
|
|
140
150
|
|
|
141
151
|
if (this.configDir) {
|
|
@@ -188,7 +198,11 @@ export class JiraRepository {
|
|
|
188
198
|
]);
|
|
189
199
|
const issue = this.toDomain(raw);
|
|
190
200
|
if (remoteLinks.length > 0) {
|
|
191
|
-
issue.externalLinks = remoteLinks.map((link) => ({
|
|
201
|
+
issue.externalLinks = remoteLinks.map((link) => ({
|
|
202
|
+
url: link.object?.url ?? "",
|
|
203
|
+
title: link.object?.title,
|
|
204
|
+
type: link.application?.name,
|
|
205
|
+
}));
|
|
192
206
|
}
|
|
193
207
|
return issue;
|
|
194
208
|
}
|
|
@@ -256,18 +270,12 @@ export class JiraRepository {
|
|
|
256
270
|
}
|
|
257
271
|
|
|
258
272
|
async listComments(key: string): Promise<Comment[]> {
|
|
259
|
-
const result = await this.call<{ comments?: JiraComment[] }>(
|
|
260
|
-
() => this.client.issueComments.getComments({ issueIdOrKey: key }),
|
|
261
|
-
key,
|
|
262
|
-
);
|
|
273
|
+
const result = await this.call<{ comments?: JiraComment[] }>(() => this.client.issueComments.getComments({ issueIdOrKey: key }), key);
|
|
263
274
|
return (result?.comments ?? []).map(commentToDomain);
|
|
264
275
|
}
|
|
265
276
|
|
|
266
277
|
async addComment(key: string, body: string): Promise<Comment> {
|
|
267
|
-
const raw = await this.call<JiraComment>(
|
|
268
|
-
() => this.client.issueComments.addComment({ issueIdOrKey: key, comment: body }),
|
|
269
|
-
key,
|
|
270
|
-
);
|
|
278
|
+
const raw = await this.call<JiraComment>(() => this.client.issueComments.addComment({ issueIdOrKey: key, comment: body }), key);
|
|
271
279
|
return commentToDomain(raw);
|
|
272
280
|
}
|
|
273
281
|
|
|
@@ -276,6 +284,25 @@ export class JiraRepository {
|
|
|
276
284
|
return this.searchJql(query, limit);
|
|
277
285
|
}
|
|
278
286
|
|
|
287
|
+
/**
|
|
288
|
+
* SyncScopeExpandable -- widens what the poller's own background sync pools
|
|
289
|
+
* into the local ledger beyond the single default `project` list() falls
|
|
290
|
+
* back to: every configured project (default plus syncProjects) ORed with
|
|
291
|
+
* "assignee = currentUser()" when syncMine is set, so issues assigned to
|
|
292
|
+
* you in a project nobody thought to list still get pooled. Returns
|
|
293
|
+
* undefined -- letting the poller fall back to plain list() -- when
|
|
294
|
+
* neither syncProjects nor syncMine adds anything beyond the default
|
|
295
|
+
* project's own existing behavior.
|
|
296
|
+
*/
|
|
297
|
+
buildSyncQuery(): string | undefined {
|
|
298
|
+
const projects = [...new Set([this.project, ...this.syncProjects].filter((p): p is string => Boolean(p)))];
|
|
299
|
+
if (projects.length <= 1 && !this.syncMine) return undefined;
|
|
300
|
+
const clauses: string[] = [];
|
|
301
|
+
if (projects.length > 0) clauses.push(`project in (${projects.map(jqlQuote).join(", ")})`);
|
|
302
|
+
if (this.syncMine) clauses.push("assignee = currentUser()");
|
|
303
|
+
return `${clauses.join(" OR ")} ORDER BY created DESC`;
|
|
304
|
+
}
|
|
305
|
+
|
|
279
306
|
/**
|
|
280
307
|
* BoardQuickFilterDiscoverable -- resolves a board's quick filter (the same object
|
|
281
308
|
* a board view's `quickFilter=` URL param and a backlog view's `customFilter=`
|
|
@@ -526,7 +553,9 @@ function formatCustomFieldValue(raw: unknown): string | undefined {
|
|
|
526
553
|
if (typeof raw === "string") return raw;
|
|
527
554
|
if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
|
|
528
555
|
if (Array.isArray(raw)) {
|
|
529
|
-
const names = raw
|
|
556
|
+
const names = raw
|
|
557
|
+
.map((item) => (item && typeof item === "object" && "name" in item ? String((item as { name: unknown }).name) : undefined))
|
|
558
|
+
.filter((n): n is string => !!n);
|
|
530
559
|
return names.length > 0 ? names.join(", ") : undefined;
|
|
531
560
|
}
|
|
532
561
|
if (raw && typeof raw === "object") {
|
|
@@ -538,7 +567,10 @@ function formatCustomFieldValue(raw: unknown): string | undefined {
|
|
|
538
567
|
|
|
539
568
|
function coerceCustomFieldValue(field: { type: string; items?: string }, rawValue: string): unknown {
|
|
540
569
|
if (field.type === "array") {
|
|
541
|
-
const parts = rawValue
|
|
570
|
+
const parts = rawValue
|
|
571
|
+
.split(",")
|
|
572
|
+
.map((v) => v.trim())
|
|
573
|
+
.filter(Boolean);
|
|
542
574
|
return field.items === "option" ? parts.map((v) => ({ value: v })) : parts;
|
|
543
575
|
}
|
|
544
576
|
if (field.type === "option") return { value: rawValue };
|
|
@@ -14,10 +14,21 @@ import {
|
|
|
14
14
|
hasFieldDiscovery,
|
|
15
15
|
hasRawQuery,
|
|
16
16
|
hasStatusDiscovery,
|
|
17
|
+
hasSyncScopeExpansion,
|
|
17
18
|
hasTemplateDiscovery,
|
|
18
19
|
type IssueRepository,
|
|
19
20
|
} from "../ports/repository.js";
|
|
20
21
|
|
|
22
|
+
export interface BackendCapabilities {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly supportsRawQuery: boolean;
|
|
25
|
+
readonly supportsFieldDiscovery: boolean;
|
|
26
|
+
readonly supportsStatusDiscovery: boolean;
|
|
27
|
+
readonly supportsTemplateDiscovery: boolean;
|
|
28
|
+
readonly supportsBoardQuickFilterDiscovery: boolean;
|
|
29
|
+
readonly supportsBoardFilterDiscovery: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
export class UnknownBackendError extends Error {
|
|
22
33
|
constructor(backend: string, known: string[]) {
|
|
23
34
|
super(`unknown backend "${backend}" (known: ${known.join(", ") || "none configured"})`);
|
|
@@ -39,9 +50,17 @@ export class TicketService {
|
|
|
39
50
|
return Object.keys(this.repos);
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
/** Every configured backend's name plus which optional capabilities its own repository actually implements -- lets a driving adapter (the CLI, pi-tickets) branch on real capability instead of a hardcoded backend name. */
|
|
43
|
-
backendCapabilities():
|
|
44
|
-
return Object.values(this.repos).map((repo) => ({
|
|
53
|
+
/** Every configured backend's name plus which optional capabilities its own repository actually implements -- lets a driving adapter (the CLI, pi-tickets, the Vehicle tool-availability sync) branch on real capability instead of a hardcoded backend name. */
|
|
54
|
+
backendCapabilities(): BackendCapabilities[] {
|
|
55
|
+
return Object.values(this.repos).map((repo) => ({
|
|
56
|
+
name: repo.name,
|
|
57
|
+
supportsRawQuery: hasRawQuery(repo),
|
|
58
|
+
supportsFieldDiscovery: hasFieldDiscovery(repo),
|
|
59
|
+
supportsStatusDiscovery: hasStatusDiscovery(repo),
|
|
60
|
+
supportsTemplateDiscovery: hasTemplateDiscovery(repo),
|
|
61
|
+
supportsBoardQuickFilterDiscovery: hasBoardQuickFilterDiscovery(repo),
|
|
62
|
+
supportsBoardFilterDiscovery: hasBoardFilterDiscovery(repo),
|
|
63
|
+
}));
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
/**
|
|
@@ -71,6 +90,25 @@ export class TicketService {
|
|
|
71
90
|
return this.repo(backend).list(filter);
|
|
72
91
|
}
|
|
73
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Fetches issues for the poller's own background sync pass (see
|
|
95
|
+
* daemon/poller.ts). Prefers a backend's own expanded sync scope
|
|
96
|
+
* (SyncScopeExpandable -- Jira: multiple configured projects plus
|
|
97
|
+
* everything assigned to the authenticated user, unioned into one query)
|
|
98
|
+
* over its plain default-project list() when one is configured; falls
|
|
99
|
+
* back to list() otherwise, so a backend with no sync scope configured
|
|
100
|
+
* (or with no such capability at all, e.g. GitHub/GitLab) behaves exactly
|
|
101
|
+
* as before.
|
|
102
|
+
*/
|
|
103
|
+
async syncFetch(backend: string, limit: number): Promise<Issue[]> {
|
|
104
|
+
const repo = this.repo(backend);
|
|
105
|
+
if (hasSyncScopeExpansion(repo) && hasRawQuery(repo)) {
|
|
106
|
+
const query = repo.buildSyncQuery();
|
|
107
|
+
if (query) return repo.runQuery(query, limit);
|
|
108
|
+
}
|
|
109
|
+
return repo.list({ limit });
|
|
110
|
+
}
|
|
111
|
+
|
|
74
112
|
async get(ref: string): Promise<Issue> {
|
|
75
113
|
const { backend, key } = parseRef(ref);
|
|
76
114
|
return this.repo(backend).get(key);
|
package/src/auth/browser.ts
CHANGED
|
@@ -29,7 +29,13 @@ export function openUrl(url: string, opts: { platform?: NodeJS.Platform; spawner
|
|
|
29
29
|
const platform = opts.platform ?? process.platform;
|
|
30
30
|
const spawner = opts.spawner ?? defaultSpawner;
|
|
31
31
|
|
|
32
|
-
if (platform === "darwin")
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
if (platform === "darwin") {
|
|
33
|
+
spawner("open", [url]);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (platform === "win32") {
|
|
37
|
+
spawner("cmd", ["/c", "start", '""', url]);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
spawner("xdg-open", [url]);
|
|
35
41
|
}
|
package/src/auth/device-flow.ts
CHANGED
|
@@ -54,11 +54,7 @@ export interface DeviceFlowToken {
|
|
|
54
54
|
refreshToken?: string;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
async function postForm(
|
|
58
|
-
fetchImpl: FetchLike,
|
|
59
|
-
url: string,
|
|
60
|
-
params: Record<string, string>,
|
|
61
|
-
): Promise<Record<string, unknown>> {
|
|
57
|
+
async function postForm(fetchImpl: FetchLike, url: string, params: Record<string, string>): Promise<Record<string, unknown>> {
|
|
62
58
|
const res = await fetchImpl(url, {
|
|
63
59
|
method: "POST",
|
|
64
60
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
@@ -80,8 +76,7 @@ export async function requestDeviceAuthorization(config: DeviceFlowConfig): Prom
|
|
|
80
76
|
deviceCode: body.device_code,
|
|
81
77
|
userCode: body.user_code,
|
|
82
78
|
verificationUri: String(body.verification_uri ?? ""),
|
|
83
|
-
verificationUriComplete:
|
|
84
|
-
typeof body.verification_uri_complete === "string" ? body.verification_uri_complete : undefined,
|
|
79
|
+
verificationUriComplete: typeof body.verification_uri_complete === "string" ? body.verification_uri_complete : undefined,
|
|
85
80
|
expiresInSeconds: Number(body.expires_in ?? 900),
|
|
86
81
|
intervalSeconds: Number(body.interval ?? 5),
|
|
87
82
|
};
|
|
@@ -130,8 +125,7 @@ export async function pollForToken(
|
|
|
130
125
|
accessToken: body.access_token,
|
|
131
126
|
tokenType: String(body.token_type ?? "Bearer"),
|
|
132
127
|
scope: typeof body.scope === "string" ? body.scope : undefined,
|
|
133
|
-
expiresAt:
|
|
134
|
-
typeof body.expires_in === "number" ? new Date(Date.now() + body.expires_in * 1000).toISOString() : undefined,
|
|
128
|
+
expiresAt: typeof body.expires_in === "number" ? new Date(Date.now() + body.expires_in * 1000).toISOString() : undefined,
|
|
135
129
|
refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : undefined,
|
|
136
130
|
};
|
|
137
131
|
}
|
package/src/auth/gh-cli.ts
CHANGED
|
@@ -14,9 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export type GhCliTokenResult = { ok: true; token: string } | { ok: false; reason: string };
|
|
16
16
|
|
|
17
|
-
export
|
|
18
|
-
(command: string[]): { stdout: ReadableStream<Uint8Array> | number; exited: Promise<number> };
|
|
19
|
-
}
|
|
17
|
+
export type SpawnLike = (command: string[]) => { stdout: ReadableStream<Uint8Array> | number; exited: Promise<number> };
|
|
20
18
|
|
|
21
19
|
const defaultSpawn: SpawnLike = (command) => Bun.spawn(command, { stdout: "pipe" });
|
|
22
20
|
|
|
@@ -26,21 +24,26 @@ const defaultSpawn: SpawnLike = (command) => Bun.spawn(command, { stdout: "pipe"
|
|
|
26
24
|
* prompts, never falls back to a device flow itself.
|
|
27
25
|
*/
|
|
28
26
|
export async function readGhCliToken(user?: string, spawn: SpawnLike = defaultSpawn): Promise<GhCliTokenResult> {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
27
|
+
const command = user ? ["gh", "auth", "token", "--user", user] : ["gh", "auth", "token"];
|
|
28
|
+
let proc: ReturnType<SpawnLike>;
|
|
29
|
+
try {
|
|
30
|
+
proc = spawn(command);
|
|
31
|
+
} catch {
|
|
32
|
+
return { ok: false, reason: "gh CLI not found -- install it (cli.github.com) or use a different login method" };
|
|
33
|
+
}
|
|
34
|
+
const [stdout, code] = await Promise.all([
|
|
35
|
+
proc.stdout instanceof ReadableStream ? new Response(proc.stdout).text() : Promise.resolve(""),
|
|
36
|
+
proc.exited,
|
|
37
|
+
]);
|
|
38
|
+
if (code !== 0) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: user
|
|
42
|
+
? `gh CLI has no authenticated account named "${user}" -- run \`gh auth login\` first`
|
|
43
|
+
: "gh CLI is not authenticated -- run `gh auth login` first",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const token = stdout.trim();
|
|
47
|
+
if (!token) return { ok: false, reason: "gh auth token returned no token" };
|
|
48
|
+
return { ok: true, token };
|
|
46
49
|
}
|
package/src/auth/jira-oauth.ts
CHANGED
|
@@ -92,7 +92,9 @@ export function startCallbackServer(expectedState: string, port = 0): CallbackSe
|
|
|
92
92
|
rejectCode?.(new JiraOAuthError("callback missing authorization code"));
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
|
-
res
|
|
95
|
+
res
|
|
96
|
+
.writeHead(200, { "Content-Type": "text/html" })
|
|
97
|
+
.end("<html><body>Authorized. You can close this tab and return to the terminal.</body></html>");
|
|
96
98
|
resolveCode?.({ code, redirectUri: redirectUriFor() });
|
|
97
99
|
});
|
|
98
100
|
|
|
@@ -118,12 +120,7 @@ export function startCallbackServer(expectedState: string, port = 0): CallbackSe
|
|
|
118
120
|
};
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
export function buildAuthorizeUrl(opts: {
|
|
122
|
-
clientId: string;
|
|
123
|
-
redirectUri: string;
|
|
124
|
-
scope: string;
|
|
125
|
-
state: string;
|
|
126
|
-
}): string {
|
|
123
|
+
export function buildAuthorizeUrl(opts: { clientId: string; redirectUri: string; scope: string; state: string }): string {
|
|
127
124
|
const url = new URL(ATLASSIAN_AUTHORIZE_URL);
|
|
128
125
|
url.searchParams.set("audience", "api.atlassian.com");
|
|
129
126
|
url.searchParams.set("client_id", opts.clientId);
|
package/src/cli/index.ts
CHANGED
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
* ledger or a backend adapter directly.
|
|
7
7
|
*/
|
|
8
8
|
import { Command } from "commander";
|
|
9
|
-
import type { CreateInput, ListFilter, Priority, Status, UpdateInput } from "../domain/issue.js";
|
|
10
|
-
import { parseStatus } from "../domain/issue.js";
|
|
11
|
-
import { createTicketsClient, type TicketsRpcClient } from "../client/tickets-client.js";
|
|
12
9
|
import { openUrl } from "../auth/browser.js";
|
|
13
|
-
import { loginWithGitHubDeviceFlow } from "../auth/github-oauth.js";
|
|
14
10
|
import { readGhCliToken } from "../auth/gh-cli.js";
|
|
15
|
-
import {
|
|
11
|
+
import { loginWithGitHubDeviceFlow } from "../auth/github-oauth.js";
|
|
12
|
+
import { loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
|
|
16
13
|
import { loginWithJiraAuthorizationCode } from "../auth/jira-oauth.js";
|
|
17
|
-
import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
|
|
18
14
|
import { promptMaskedSecret } from "../auth/masked-prompt.js";
|
|
15
|
+
import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
|
|
16
|
+
import { createTicketsClient, type TicketsRpcClient } from "../client/tickets-client.js";
|
|
17
|
+
import type { CreateInput, ListFilter, Priority, Status, UpdateInput } from "../domain/issue.js";
|
|
18
|
+
import { parseStatus } from "../domain/issue.js";
|
|
19
19
|
import { installTicketsService, systemctlTickets, systemdUnitPath } from "./systemd-service.js";
|
|
20
20
|
|
|
21
21
|
function printJson(value: unknown): void {
|
|
@@ -40,7 +40,7 @@ program
|
|
|
40
40
|
.command("list")
|
|
41
41
|
.description("list issues on a backend")
|
|
42
42
|
.requiredOption("-b, --backend <name>", "backend name")
|
|
43
|
-
.option("--project <key>", "project key/id override (e.g. reach
|
|
43
|
+
.option("--project <key>", "project key/id override (e.g. reach ENG or OPS on a Jira backend defaulting to another project)")
|
|
44
44
|
.option("--status <status>", "filter by status")
|
|
45
45
|
.option("--assignee <user>", "filter by assignee")
|
|
46
46
|
.option("--label <label...>", "filter by label(s)")
|
|
@@ -109,7 +109,7 @@ program
|
|
|
109
109
|
.command("search <query>")
|
|
110
110
|
.description("search issues on a backend")
|
|
111
111
|
.requiredOption("-b, --backend <name>", "backend name")
|
|
112
|
-
.option("--project <key>", "project key/id override (e.g. reach
|
|
112
|
+
.option("--project <key>", "project key/id override (e.g. reach ENG or OPS on a Jira backend defaulting to another project)")
|
|
113
113
|
.option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
|
|
114
114
|
.action(async (query: string, opts) => {
|
|
115
115
|
await withClient((client) => client.call("issue.search", { backend: opts.backend, query, limit: opts.limit, project: opts.project }));
|
|
@@ -200,7 +200,9 @@ focus
|
|
|
200
200
|
await withClient((client) => client.call("focus.clear", {}));
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
-
const discoverCmd = program
|
|
203
|
+
const discoverCmd = program
|
|
204
|
+
.command("discover")
|
|
205
|
+
.description("discover and persist backend-specific mappings (custom fields, statuses) or a description template");
|
|
204
206
|
|
|
205
207
|
discoverCmd
|
|
206
208
|
.command("fields")
|
|
@@ -227,7 +229,12 @@ discoverCmd
|
|
|
227
229
|
.option("--sample-size <n>", "how many recent issues to sample", (v) => Number.parseInt(v, 10))
|
|
228
230
|
.action(async (opts) => {
|
|
229
231
|
await withClient((client) =>
|
|
230
|
-
client.call("discover.template", {
|
|
232
|
+
client.call("discover.template", {
|
|
233
|
+
backend: opts.backend,
|
|
234
|
+
project: opts.project,
|
|
235
|
+
issueType: opts.issueType,
|
|
236
|
+
sampleSize: opts.sampleSize,
|
|
237
|
+
}),
|
|
231
238
|
);
|
|
232
239
|
});
|
|
233
240
|
|
|
@@ -240,7 +247,9 @@ discoverCmd
|
|
|
240
247
|
await withClient((client) => client.call("discover.board_filter", { backend: opts.backend, boardId: opts.board }));
|
|
241
248
|
});
|
|
242
249
|
|
|
243
|
-
const queryCmd = program
|
|
250
|
+
const queryCmd = program
|
|
251
|
+
.command("query")
|
|
252
|
+
.description("save and run named raw backend queries (Jira JQL) -- e.g. a board's sprint or backlog view");
|
|
244
253
|
|
|
245
254
|
queryCmd
|
|
246
255
|
.command("save <name>")
|
|
@@ -249,7 +258,9 @@ queryCmd
|
|
|
249
258
|
.requiredOption("--jql <jql>", "the raw query string (Jira JQL)")
|
|
250
259
|
.option("--description <text>", "human-readable note about what this query is")
|
|
251
260
|
.action(async (name: string, opts) => {
|
|
252
|
-
await withClient((client) =>
|
|
261
|
+
await withClient((client) =>
|
|
262
|
+
client.call("query.save", { name, backend: opts.backend, query: opts.jql, description: opts.description }),
|
|
263
|
+
);
|
|
253
264
|
});
|
|
254
265
|
|
|
255
266
|
queryCmd
|
|
@@ -276,12 +287,16 @@ queryCmd
|
|
|
276
287
|
|
|
277
288
|
discoverCmd
|
|
278
289
|
.command("board_quickfilter")
|
|
279
|
-
.description(
|
|
290
|
+
.description(
|
|
291
|
+
"resolve a Jira board's quick filter id to its JQL fragment -- board view's quickFilter=, backlog view's customFilter=, are the same id",
|
|
292
|
+
)
|
|
280
293
|
.requiredOption("-b, --backend <name>", "backend name")
|
|
281
294
|
.requiredOption("--board <id>", "board id", (v) => Number.parseInt(v, 10))
|
|
282
295
|
.requiredOption("--quick-filter <id>", "quick filter id", (v) => Number.parseInt(v, 10))
|
|
283
296
|
.action(async (opts) => {
|
|
284
|
-
await withClient((client) =>
|
|
297
|
+
await withClient((client) =>
|
|
298
|
+
client.call("discover.board_quickfilter", { backend: opts.backend, boardId: opts.board, quickFilterId: opts.quickFilter }),
|
|
299
|
+
);
|
|
285
300
|
});
|
|
286
301
|
|
|
287
302
|
const daemon = program.command("daemon").description("manage the tickets daemon process");
|
|
@@ -348,7 +363,9 @@ daemon
|
|
|
348
363
|
|
|
349
364
|
const service = program
|
|
350
365
|
.command("service")
|
|
351
|
-
.description(
|
|
366
|
+
.description(
|
|
367
|
+
"deploy the tickets daemon as a persistent systemd --user service (Linux; survives logout/reboot, unlike `daemon start`'s on-demand spawn)",
|
|
368
|
+
);
|
|
352
369
|
|
|
353
370
|
service
|
|
354
371
|
.command("install")
|
|
@@ -395,7 +412,10 @@ auth
|
|
|
395
412
|
.option("--client-secret <secret>", "OAuth client secret (Jira only — GitHub/GitLab device flow needs none)")
|
|
396
413
|
.option("--url <baseUrl>", "self-managed GitLab URL (defaults to gitlab.com)")
|
|
397
414
|
.option("--scope <scope>", "space-delimited OAuth scope override")
|
|
398
|
-
.option(
|
|
415
|
+
.option(
|
|
416
|
+
"--gh-cli [account]",
|
|
417
|
+
"github only: reuse an already-authenticated gh CLI session instead of the device flow (omit value for gh's active account)",
|
|
418
|
+
)
|
|
399
419
|
.action(async (opts) => {
|
|
400
420
|
const type = opts.type ?? opts.backend;
|
|
401
421
|
try {
|
|
@@ -403,12 +423,20 @@ auth
|
|
|
403
423
|
const result = await readGhCliToken(opts.ghCli === true ? undefined : opts.ghCli);
|
|
404
424
|
if (!result.ok) throw new Error(result.reason);
|
|
405
425
|
saveToken(opts.backend, { accessToken: result.token });
|
|
406
|
-
printJson({
|
|
426
|
+
printJson({
|
|
427
|
+
backend: opts.backend,
|
|
428
|
+
status: "authorized",
|
|
429
|
+
via: "gh-cli",
|
|
430
|
+
note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token",
|
|
431
|
+
});
|
|
407
432
|
return;
|
|
408
433
|
}
|
|
409
434
|
if (type === "github") {
|
|
410
435
|
const clientId = opts.clientId ?? process.env.GITHUB_OAUTH_CLIENT_ID;
|
|
411
|
-
if (!clientId)
|
|
436
|
+
if (!clientId)
|
|
437
|
+
throw new Error(
|
|
438
|
+
"--client-id or GITHUB_OAUTH_CLIENT_ID is required (or pass --gh-cli [account] to reuse an already-authenticated gh CLI session instead)",
|
|
439
|
+
);
|
|
412
440
|
const token = await loginWithGitHubDeviceFlow({
|
|
413
441
|
clientId,
|
|
414
442
|
scope: opts.scope,
|
|
@@ -421,7 +449,12 @@ auth
|
|
|
421
449
|
}
|
|
422
450
|
},
|
|
423
451
|
});
|
|
424
|
-
saveToken(opts.backend, {
|
|
452
|
+
saveToken(opts.backend, {
|
|
453
|
+
accessToken: token.accessToken,
|
|
454
|
+
refreshToken: token.refreshToken,
|
|
455
|
+
expiresAt: token.expiresAt,
|
|
456
|
+
scope: token.scope,
|
|
457
|
+
});
|
|
425
458
|
} else if (type === "gitlab") {
|
|
426
459
|
const clientId = opts.clientId ?? process.env.GITLAB_OAUTH_CLIENT_ID;
|
|
427
460
|
if (!clientId) throw new Error("--client-id or GITLAB_OAUTH_CLIENT_ID is required");
|
|
@@ -439,12 +472,19 @@ auth
|
|
|
439
472
|
}
|
|
440
473
|
},
|
|
441
474
|
});
|
|
442
|
-
saveToken(opts.backend, {
|
|
475
|
+
saveToken(opts.backend, {
|
|
476
|
+
accessToken: token.accessToken,
|
|
477
|
+
refreshToken: token.refreshToken,
|
|
478
|
+
expiresAt: token.expiresAt,
|
|
479
|
+
scope: token.scope,
|
|
480
|
+
});
|
|
443
481
|
} else if (type === "jira") {
|
|
444
482
|
const clientId = opts.clientId ?? process.env.JIRA_OAUTH_CLIENT_ID;
|
|
445
483
|
const clientSecret = opts.clientSecret ?? process.env.JIRA_OAUTH_CLIENT_SECRET;
|
|
446
484
|
if (!clientId || !clientSecret) {
|
|
447
|
-
throw new Error(
|
|
485
|
+
throw new Error(
|
|
486
|
+
"--client-id/--client-secret or JIRA_OAUTH_CLIENT_ID/JIRA_OAUTH_CLIENT_SECRET are required (Atlassian 3LO has no public-client flow)",
|
|
487
|
+
);
|
|
448
488
|
}
|
|
449
489
|
const token = await loginWithJiraAuthorizationCode({
|
|
450
490
|
clientId,
|
|
@@ -494,7 +534,11 @@ auth
|
|
|
494
534
|
return;
|
|
495
535
|
}
|
|
496
536
|
saveToken(backend, { accessToken: value });
|
|
497
|
-
printJson({
|
|
537
|
+
printJson({
|
|
538
|
+
backend,
|
|
539
|
+
status: "stored",
|
|
540
|
+
note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token",
|
|
541
|
+
});
|
|
498
542
|
});
|
|
499
543
|
|
|
500
544
|
auth
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
|
|
10
11
|
import type { DaemonHandle } from "@danypops/vehicle-server/paths";
|
|
11
12
|
import { ensureAuthToken, readDaemonHandle, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
|
|
12
|
-
import {
|
|
13
|
+
import { TICKETS_DAEMON_NAMES, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "../daemon/ops.js";
|
|
13
14
|
import { packageRoot } from "../util/package-root.js";
|
|
14
|
-
import { TICKETS_DAEMON_NAMES, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "../daemon/ops.js";
|
|
15
15
|
|
|
16
16
|
export function ticketsPaths(env?: Record<string, string | undefined>) {
|
|
17
17
|
return resolveDaemonPaths(TICKETS_DAEMON_NAMES, env ? { env } : {});
|
|
@@ -58,9 +58,7 @@ export interface EnsureDaemonOptions {
|
|
|
58
58
|
|
|
59
59
|
const DEFAULT_SPAWN_TIMEOUT_MS = 4_000;
|
|
60
60
|
|
|
61
|
-
export async function ensureDaemonRunning(
|
|
62
|
-
opts: EnsureDaemonOptions = {},
|
|
63
|
-
): Promise<{ baseUrl: string; token: string }> {
|
|
61
|
+
export async function ensureDaemonRunning(opts: EnsureDaemonOptions = {}): Promise<{ baseUrl: string; token: string }> {
|
|
64
62
|
const paths = ticketsPaths();
|
|
65
63
|
const token = ensureAuthToken(paths.token, "Tickets");
|
|
66
64
|
|
|
@@ -70,9 +68,7 @@ export async function ensureDaemonRunning(
|
|
|
70
68
|
}
|
|
71
69
|
|
|
72
70
|
if (opts.autoStart === false) {
|
|
73
|
-
throw new Error(
|
|
74
|
-
"tickets daemon is not running. Start it with `npm run daemon` (or `bun run src/daemon/main.ts`).",
|
|
75
|
-
);
|
|
71
|
+
throw new Error("tickets daemon is not running. Start it with `npm run daemon` (or `bun run src/daemon/main.ts`).");
|
|
76
72
|
}
|
|
77
73
|
|
|
78
74
|
spawnDaemon();
|
package/src/config/config.ts
CHANGED
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import {
|
|
9
|
+
import { type TryEnigmaCredential, tryEnigmaCredential } from "@danypops/enigma-client";
|
|
10
10
|
import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
|
|
11
11
|
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
12
|
+
import { parse as parseYaml } from "yaml";
|
|
12
13
|
import { GitHubRepository } from "../adapters/github.js";
|
|
13
14
|
import { GitLabRepository } from "../adapters/gitlab.js";
|
|
14
15
|
import { JiraRepository } from "../adapters/jira.js";
|
|
15
|
-
import type { IssueRepository } from "../ports/repository.js";
|
|
16
16
|
import type { TicketService } from "../application/service.js";
|
|
17
17
|
import { isTokenFresh, loadToken } from "../auth/token-store.js";
|
|
18
|
-
import
|
|
18
|
+
import type { IssueRepository } from "../ports/repository.js";
|
|
19
19
|
|
|
20
20
|
export interface BackendConfig {
|
|
21
21
|
/** Adapter type: "github" | "gitlab" | "jira". Falls back to the config key when omitted. */
|
|
@@ -27,6 +27,10 @@ export interface BackendConfig {
|
|
|
27
27
|
owner?: string;
|
|
28
28
|
project?: string;
|
|
29
29
|
repo?: string;
|
|
30
|
+
/** Jira only: additional project keys the background poller also pools into the ledger, beyond the single default `project` above. */
|
|
31
|
+
syncProjects?: string[];
|
|
32
|
+
/** Jira only: when true, the background poller also pools everything assigned to the authenticated user, regardless of project. */
|
|
33
|
+
syncMine?: boolean;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export interface Config {
|
|
@@ -58,6 +62,22 @@ function resolveToken(cfg: BackendConfig, env: NodeJS.ProcessEnv, envFallback: s
|
|
|
58
62
|
return env[envFallback];
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
/** Config wins over the env var; the env var is a comma-separated list (JIRA_SYNC_PROJECTS=ENG,OPS). */
|
|
66
|
+
function resolveSyncProjects(cfg: BackendConfig, env: NodeJS.ProcessEnv): string[] | undefined {
|
|
67
|
+
if (cfg.syncProjects) return cfg.syncProjects;
|
|
68
|
+
const raw = env.JIRA_SYNC_PROJECTS;
|
|
69
|
+
if (!raw) return undefined;
|
|
70
|
+
return raw
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((p) => p.trim())
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveSyncMine(cfg: BackendConfig, env: NodeJS.ProcessEnv): boolean {
|
|
77
|
+
if (cfg.syncMine !== undefined) return cfg.syncMine;
|
|
78
|
+
return /^(1|true|yes)$/i.test(env.JIRA_SYNC_MINE ?? "");
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
/**
|
|
62
82
|
* Resolution order, highest priority first: (1) a running Enigma vault, if
|
|
63
83
|
* one happens to be configured for this backend — entirely optional, never a
|
|
@@ -142,6 +162,8 @@ export function createBackendRefreshTask(
|
|
|
142
162
|
buildRepos: BuildRepositories,
|
|
143
163
|
intervalMs: number,
|
|
144
164
|
logger?: Logger,
|
|
165
|
+
/** Re-syncs Vehicle tool availability (createTicketsVehicleRegistry's syncDiscoverAvailability) against the freshly swapped-in backend set -- called after every successful refresh, so a Jira credential added or removed at runtime flips discover.* tool visibility without a daemon restart. Optional: a caller with no vehicleRegistry yet (tests, injected repos) just skips it. */
|
|
166
|
+
onRefreshed?: (service: TicketService) => void,
|
|
145
167
|
): MaintenanceTask {
|
|
146
168
|
return {
|
|
147
169
|
name: "backend-refresh",
|
|
@@ -158,6 +180,7 @@ export function createBackendRefreshTask(
|
|
|
158
180
|
return;
|
|
159
181
|
}
|
|
160
182
|
service.setRepos(fresh);
|
|
183
|
+
onRefreshed?.(service);
|
|
161
184
|
const after = new Set(Object.keys(fresh));
|
|
162
185
|
const added = [...after].filter((backend) => !before.has(backend));
|
|
163
186
|
const removed = [...before].filter((backend) => !after.has(backend));
|
|
@@ -205,6 +228,8 @@ async function createRepository(
|
|
|
205
228
|
accessToken: auth.token,
|
|
206
229
|
cloudId: auth.extra.cloudId,
|
|
207
230
|
project: cfg.project ?? env.JIRA_PROJECT,
|
|
231
|
+
syncProjects: resolveSyncProjects(cfg, env),
|
|
232
|
+
syncMine: resolveSyncMine(cfg, env),
|
|
208
233
|
configDir: configDir(),
|
|
209
234
|
});
|
|
210
235
|
}
|
|
@@ -216,6 +241,8 @@ async function createRepository(
|
|
|
216
241
|
email,
|
|
217
242
|
token: auth.token,
|
|
218
243
|
project: cfg.project ?? env.JIRA_PROJECT,
|
|
244
|
+
syncProjects: resolveSyncProjects(cfg, env),
|
|
245
|
+
syncMine: resolveSyncMine(cfg, env),
|
|
219
246
|
configDir: configDir(),
|
|
220
247
|
});
|
|
221
248
|
}
|
package/src/daemon/bootstrap.ts
CHANGED
|
@@ -6,20 +6,20 @@
|
|
|
6
6
|
* root instead of hitting real GitHub/GitLab/Jira or the real home directory.
|
|
7
7
|
*/
|
|
8
8
|
import type { Database } from "bun:sqlite";
|
|
9
|
+
import type { StartDaemonOptions } from "@danypops/vehicle-server/daemon";
|
|
9
10
|
import { createLogger, type Logger } from "@danypops/vehicle-server/logging";
|
|
10
11
|
import { ensureAuthToken, type PathEnvironment, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
|
|
11
12
|
import { checkpoint, openSqliteWithPragmas } from "@danypops/vehicle-server/storage";
|
|
12
|
-
import type { StartDaemonOptions } from "@danypops/vehicle-server/daemon";
|
|
13
13
|
import { TicketService } from "../application/service.js";
|
|
14
|
-
import {
|
|
14
|
+
import { type BuildRepositories, buildRepositories, type Config, createBackendRefreshTask, loadConfig } from "../config/config.js";
|
|
15
15
|
import type { IssueRepository } from "../ports/repository.js";
|
|
16
|
+
import { createTicketsVehicleRegistry, syncDiscoverAvailability } from "../vehicle/tickets-vehicle.js";
|
|
16
17
|
import { FOCUS_MIGRATIONS, FocusStore } from "./focus.js";
|
|
17
|
-
import {
|
|
18
|
-
import { SAVED_QUERY_MIGRATIONS, SavedQueryStore } from "./saved-queries.js";
|
|
18
|
+
import { LEDGER_MIGRATIONS, Ledger } from "./ledger.js";
|
|
19
19
|
import { TICKETS_DAEMON_NAMES } from "./ops.js";
|
|
20
|
-
import { buildApp, type TicketsAppDeps } from "./server.js";
|
|
21
20
|
import { createSyncTask } from "./poller.js";
|
|
22
|
-
import {
|
|
21
|
+
import { SAVED_QUERY_MIGRATIONS, SavedQueryStore } from "./saved-queries.js";
|
|
22
|
+
import { buildApp, type TicketsAppDeps } from "./server.js";
|
|
23
23
|
|
|
24
24
|
export interface BootstrapOptions {
|
|
25
25
|
pathEnv?: PathEnvironment;
|
|
@@ -79,7 +79,16 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
79
79
|
// the registry field itself -- createTicketsVehicleRegistry never reads
|
|
80
80
|
// deps.vehicleRegistry, so this ordering is safe (see server.ts's own
|
|
81
81
|
// comment on why the registry is built outside it, not imported into it).
|
|
82
|
-
const vehicleRegistry = createTicketsVehicleRegistry({
|
|
82
|
+
const vehicleRegistry = createTicketsVehicleRegistry({
|
|
83
|
+
service,
|
|
84
|
+
ledger,
|
|
85
|
+
focusStore,
|
|
86
|
+
queries,
|
|
87
|
+
token,
|
|
88
|
+
version,
|
|
89
|
+
logger,
|
|
90
|
+
onShutdownRequested,
|
|
91
|
+
} as TicketsAppDeps);
|
|
83
92
|
|
|
84
93
|
const options: StartDaemonOptions = {
|
|
85
94
|
daemonLabel: "Tickets",
|
|
@@ -95,7 +104,16 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
95
104
|
// Only when repos came from real config/env/Enigma resolution -- an
|
|
96
105
|
// injected test fixture (opts.repos) has no config to re-resolve from.
|
|
97
106
|
...(opts.repos === undefined
|
|
98
|
-
? [
|
|
107
|
+
? [
|
|
108
|
+
createBackendRefreshTask(
|
|
109
|
+
service,
|
|
110
|
+
config,
|
|
111
|
+
buildRepos,
|
|
112
|
+
opts.backendRefreshIntervalMs ?? DEFAULT_BACKEND_REFRESH_INTERVAL_MS,
|
|
113
|
+
logger,
|
|
114
|
+
(refreshedService) => syncDiscoverAvailability(vehicleRegistry, refreshedService),
|
|
115
|
+
),
|
|
116
|
+
]
|
|
99
117
|
: []),
|
|
100
118
|
],
|
|
101
119
|
buildApp: () =>
|
package/src/daemon/ledger.ts
CHANGED
|
@@ -122,9 +122,10 @@ export class Ledger {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
stats(): { backend: string; count: number }[] {
|
|
125
|
-
const rows = this.db
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
const rows = this.db.query("SELECT backend, COUNT(*) as count FROM issues GROUP BY backend ORDER BY backend").all() as {
|
|
126
|
+
backend: string;
|
|
127
|
+
count: number;
|
|
128
|
+
}[];
|
|
128
129
|
return rows;
|
|
129
130
|
}
|
|
130
131
|
}
|
package/src/daemon/poller.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* network, bad creds) is logged and skipped; it never crashes the daemon
|
|
6
6
|
* and never blocks other backends' syncs.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
|
|
10
|
+
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
10
11
|
import type { TicketService } from "../application/service.js";
|
|
11
12
|
import type { Ledger } from "./ledger.js";
|
|
12
13
|
|
|
@@ -22,7 +23,7 @@ export async function syncOnce(
|
|
|
22
23
|
const results: { backend: string; synced: number; error?: string }[] = [];
|
|
23
24
|
for (const backend of backends) {
|
|
24
25
|
try {
|
|
25
|
-
const issues = await service.
|
|
26
|
+
const issues = await service.syncFetch(backend, DEFAULT_SYNC_LIMIT);
|
|
26
27
|
const synced = ledger.upsertMany(backend, issues);
|
|
27
28
|
results.push({ backend, synced });
|
|
28
29
|
logger?.debug("ledger sync ok", { backend, synced });
|
|
@@ -42,12 +43,7 @@ export async function syncOnce(
|
|
|
42
43
|
* service is synced on this task's very next run, no daemon restart or
|
|
43
44
|
* task rebuild needed.
|
|
44
45
|
*/
|
|
45
|
-
export function createSyncTask(
|
|
46
|
-
service: TicketService,
|
|
47
|
-
ledger: Ledger,
|
|
48
|
-
intervalMs: number,
|
|
49
|
-
logger?: Logger,
|
|
50
|
-
): MaintenanceTask {
|
|
46
|
+
export function createSyncTask(service: TicketService, ledger: Ledger, intervalMs: number, logger?: Logger): MaintenanceTask {
|
|
51
47
|
return {
|
|
52
48
|
name: "ledger-sync",
|
|
53
49
|
intervalMs,
|
package/src/daemon/server.ts
CHANGED
|
@@ -6,16 +6,17 @@
|
|
|
6
6
|
* stand up. Every operation here has a CLI command (cli/index.ts) and a
|
|
7
7
|
* pi-tickets tool action — no operation exists only for one caller.
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
|
-
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
11
|
-
import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
9
|
+
|
|
12
10
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
11
|
+
import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
12
|
+
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
13
|
+
import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http";
|
|
13
14
|
import { AuthRequiredError, IssueNotFoundError } from "../adapters/errors.js";
|
|
14
15
|
import { NotSupportedError, type TicketService, UnknownBackendError } from "../application/service.js";
|
|
15
16
|
import { parseRef } from "../domain/issue.js";
|
|
16
17
|
import { FocusError, type FocusStore } from "./focus.js";
|
|
17
18
|
import type { Ledger } from "./ledger.js";
|
|
18
|
-
import { TICKET_OPERATIONS, type
|
|
19
|
+
import { TICKET_OPERATIONS, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "./ops.js";
|
|
19
20
|
import { SavedQueryNotFoundError, type SavedQueryStore } from "./saved-queries.js";
|
|
20
21
|
|
|
21
22
|
export interface TicketsAppDeps {
|
package/src/domain/issue.ts
CHANGED
|
@@ -15,14 +15,7 @@ export function parsePriority(value: unknown): Priority {
|
|
|
15
15
|
return (PRIORITIES as readonly string[]).includes(lower) ? (lower as Priority) : "none";
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export const STATUSES = [
|
|
19
|
-
"backlog",
|
|
20
|
-
"todo",
|
|
21
|
-
"in_progress",
|
|
22
|
-
"in_review",
|
|
23
|
-
"done",
|
|
24
|
-
"canceled",
|
|
25
|
-
] as const;
|
|
18
|
+
export const STATUSES = ["backlog", "todo", "in_progress", "in_review", "done", "canceled"] as const;
|
|
26
19
|
export type Status = (typeof STATUSES)[number];
|
|
27
20
|
|
|
28
21
|
export function parseStatus(value: unknown, fallback: Status = "todo"): Status {
|
package/src/index.ts
CHANGED
|
@@ -1,35 +1,34 @@
|
|
|
1
|
-
export * from "./domain/issue.js";
|
|
2
|
-
export * from "./ports/repository.js";
|
|
3
1
|
export * from "./adapters/errors.js";
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
7
|
-
export { TicketService, UnknownBackendError
|
|
8
|
-
export {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export type { TicketOperation, TicketOpInputs, TicketOpOutputs } from "./daemon/ops.js";
|
|
17
|
-
export type { FocusStatus, TicketFocusState } from "./daemon/focus.js";
|
|
2
|
+
export { type GitHubOptions, GitHubRepository } from "./adapters/github.js";
|
|
3
|
+
export { type GitLabOptions, GitLabRepository } from "./adapters/gitlab.js";
|
|
4
|
+
export { type JiraOptions, JiraRepository } from "./adapters/jira.js";
|
|
5
|
+
export { NotSupportedError, TicketService, UnknownBackendError } from "./application/service.js";
|
|
6
|
+
export { openUrl } from "./auth/browser.js";
|
|
7
|
+
// Delegated OAuth (device flow for GitHub/GitLab, authorization code for Jira)
|
|
8
|
+
// — see RESEARCH.md for why each backend gets a different flow.
|
|
9
|
+
export * from "./auth/device-flow.js";
|
|
10
|
+
export * from "./auth/github-oauth.js";
|
|
11
|
+
export * from "./auth/gitlab-oauth.js";
|
|
12
|
+
export * from "./auth/jira-oauth.js";
|
|
13
|
+
export * from "./auth/token-store.js";
|
|
18
14
|
export {
|
|
19
15
|
createTicketsClient,
|
|
16
|
+
type EnsureDaemonOptions,
|
|
20
17
|
ensureDaemonRunning,
|
|
21
18
|
resolveVehicleClientTarget,
|
|
22
|
-
ticketsPaths,
|
|
23
|
-
type EnsureDaemonOptions,
|
|
24
19
|
type TicketsRpcClient,
|
|
20
|
+
ticketsPaths,
|
|
25
21
|
type VehicleClientTarget,
|
|
26
22
|
} from "./client/tickets-client.js";
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
export
|
|
23
|
+
export {
|
|
24
|
+
type BackendConfig,
|
|
25
|
+
buildRepositories,
|
|
26
|
+
type Config,
|
|
27
|
+
configDir,
|
|
28
|
+
defaultConfigPath,
|
|
29
|
+
loadConfig,
|
|
30
|
+
} from "./config/config.js";
|
|
31
|
+
export type { FocusStatus, TicketFocusState } from "./daemon/focus.js";
|
|
32
|
+
export type { TicketOperation, TicketOpInputs, TicketOpOutputs } from "./daemon/ops.js";
|
|
33
|
+
export * from "./domain/issue.js";
|
|
34
|
+
export * from "./ports/repository.js";
|
package/src/ports/repository.ts
CHANGED
|
@@ -100,3 +100,20 @@ export interface BoardFilterDiscoverable {
|
|
|
100
100
|
export function hasBoardFilterDiscovery(repo: IssueRepository): repo is IssueRepository & BoardFilterDiscoverable {
|
|
101
101
|
return typeof (repo as Partial<BoardFilterDiscoverable>).discoverBoardFilterJql === "function";
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Optional capability — lets a backend widen what the poller's own background
|
|
106
|
+
* sync pools into the local ledger beyond list()'s single default-project
|
|
107
|
+
* filter (Jira: additional named projects, plus everything assigned to the
|
|
108
|
+
* authenticated user, unioned into one JQL string). Returns undefined when
|
|
109
|
+
* nothing beyond the default scope is configured, so the poller falls back
|
|
110
|
+
* to plain list() unchanged. Jira only; GitHub/GitLab have no equivalent
|
|
111
|
+
* multi-project-plus-assignee query language to expand into.
|
|
112
|
+
*/
|
|
113
|
+
export interface SyncScopeExpandable {
|
|
114
|
+
buildSyncQuery(): string | undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function hasSyncScopeExpansion(repo: IssueRepository): repo is IssueRepository & SyncScopeExpandable {
|
|
118
|
+
return typeof (repo as Partial<SyncScopeExpandable>).buildSyncQuery === "function";
|
|
119
|
+
}
|
|
@@ -14,10 +14,18 @@
|
|
|
14
14
|
* daemon.shutdown is deliberately excluded: it's an admin/lifecycle
|
|
15
15
|
* operation, not something an agent should be able to call as a tool.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
bindVehicleOperation,
|
|
19
|
+
defineLooseObjectSchema,
|
|
20
|
+
defineVehicleOperation,
|
|
21
|
+
type LooseObjectProperty,
|
|
22
|
+
passthroughVehicleSchema,
|
|
23
|
+
type VehicleEffect,
|
|
24
|
+
} from "@danypops/vehicle-core";
|
|
18
25
|
import { VehicleRegistry } from "@danypops/vehicle-server";
|
|
19
|
-
import {
|
|
26
|
+
import type { BackendCapabilities, TicketService } from "../application/service.js";
|
|
20
27
|
import type { TicketOperation } from "../daemon/ops.js";
|
|
28
|
+
import { TICKET_OP_HANDLERS, type TicketsAppDeps } from "../daemon/server.js";
|
|
21
29
|
|
|
22
30
|
const OWNER = "tickets";
|
|
23
31
|
|
|
@@ -52,19 +60,38 @@ function definedEntriesOnly(input: Record<string, unknown>): Record<string, unkn
|
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
const OPERATIONS: readonly OperationSpec[] = [
|
|
55
|
-
{
|
|
63
|
+
{
|
|
64
|
+
action: "backends.list",
|
|
65
|
+
description: "Lists every configured backend name (github, gitlab, jira, ...).",
|
|
66
|
+
effect: "read",
|
|
67
|
+
properties: {},
|
|
68
|
+
required: [],
|
|
69
|
+
},
|
|
56
70
|
{
|
|
57
71
|
action: "issue.list",
|
|
58
72
|
description: "Lists issues from one backend, optionally filtered.",
|
|
59
73
|
effect: "read",
|
|
60
|
-
properties: {
|
|
74
|
+
properties: {
|
|
75
|
+
backend: stringProp,
|
|
76
|
+
project: stringProp,
|
|
77
|
+
status: stringProp,
|
|
78
|
+
assignee: stringProp,
|
|
79
|
+
labels: stringArrayProp,
|
|
80
|
+
limit: numberProp,
|
|
81
|
+
},
|
|
61
82
|
required: ["backend"],
|
|
62
83
|
mapInput: ({ backend, project, status, assignee, labels, limit }) => ({
|
|
63
84
|
backend,
|
|
64
85
|
filter: definedEntriesOnly({ project, status, assignee, labels, limit }),
|
|
65
86
|
}),
|
|
66
87
|
},
|
|
67
|
-
{
|
|
88
|
+
{
|
|
89
|
+
action: "issue.get",
|
|
90
|
+
description: 'Gets one issue by its ref (e.g. "github:#42").',
|
|
91
|
+
effect: "read",
|
|
92
|
+
properties: { ref: stringProp },
|
|
93
|
+
required: ["ref"],
|
|
94
|
+
},
|
|
68
95
|
{
|
|
69
96
|
action: "issue.create",
|
|
70
97
|
description: "Creates a new issue on a live backend -- a real, externally visible write, not a local draft.",
|
|
@@ -79,9 +106,27 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
79
106
|
properties: { ref: stringProp, input: { type: "object" } },
|
|
80
107
|
required: ["ref", "input"],
|
|
81
108
|
},
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
|
|
109
|
+
{
|
|
110
|
+
action: "issue.search",
|
|
111
|
+
description: "Searches one backend's issues by text query.",
|
|
112
|
+
effect: "read",
|
|
113
|
+
properties: { backend: stringProp, query: stringProp, limit: numberProp, project: stringProp },
|
|
114
|
+
required: ["backend", "query"],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
action: "issue.children",
|
|
118
|
+
description: "Lists an issue's child issues.",
|
|
119
|
+
effect: "read",
|
|
120
|
+
properties: { ref: stringProp },
|
|
121
|
+
required: ["ref"],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
action: "issue.comments",
|
|
125
|
+
description: "Lists an issue's comments.",
|
|
126
|
+
effect: "read",
|
|
127
|
+
properties: { ref: stringProp },
|
|
128
|
+
required: ["ref"],
|
|
129
|
+
},
|
|
85
130
|
{
|
|
86
131
|
action: "issue.comment_add",
|
|
87
132
|
description: "Adds a comment to an issue on its live backend -- a real, externally visible write.",
|
|
@@ -89,15 +134,51 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
89
134
|
properties: { ref: stringProp, body: stringProp },
|
|
90
135
|
required: ["ref", "body"],
|
|
91
136
|
},
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
137
|
+
{
|
|
138
|
+
action: "ledger.search",
|
|
139
|
+
description: "Searches the local pooled-issue ledger (no live backend call).",
|
|
140
|
+
effect: "read",
|
|
141
|
+
properties: { query: stringProp, limit: numberProp },
|
|
142
|
+
required: ["query"],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
action: "ledger.stats",
|
|
146
|
+
description: "Per-backend counts of issues pooled into the local ledger.",
|
|
147
|
+
effect: "read",
|
|
148
|
+
properties: {},
|
|
149
|
+
required: [],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
action: "focus.set",
|
|
153
|
+
description: "Sets the currently focused issue, by ref.",
|
|
154
|
+
effect: "local-write",
|
|
155
|
+
properties: { ref: stringProp },
|
|
156
|
+
required: ["ref"],
|
|
157
|
+
},
|
|
95
158
|
{ action: "focus.get", description: "Gets the currently focused issue, if any.", effect: "read", properties: {}, required: [] },
|
|
96
|
-
{
|
|
159
|
+
{
|
|
160
|
+
action: "focus.pause",
|
|
161
|
+
description: "Pauses focus with an optional reason, without clearing it.",
|
|
162
|
+
effect: "local-write",
|
|
163
|
+
properties: { reason: stringProp },
|
|
164
|
+
required: [],
|
|
165
|
+
},
|
|
97
166
|
{ action: "focus.unpause", description: "Resumes a paused focus.", effect: "local-write", properties: {}, required: [] },
|
|
98
167
|
{ action: "focus.clear", description: "Clears the currently focused issue.", effect: "local-write", properties: {}, required: [] },
|
|
99
|
-
{
|
|
100
|
-
|
|
168
|
+
{
|
|
169
|
+
action: "discover.fields",
|
|
170
|
+
description: "Discovers a backend's custom field display names and IDs (Jira).",
|
|
171
|
+
effect: "read",
|
|
172
|
+
properties: { backend: stringProp },
|
|
173
|
+
required: ["backend"],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
action: "discover.statuses",
|
|
177
|
+
description: "Discovers a backend's real status names.",
|
|
178
|
+
effect: "read",
|
|
179
|
+
properties: { backend: stringProp },
|
|
180
|
+
required: ["backend"],
|
|
181
|
+
},
|
|
101
182
|
{
|
|
102
183
|
action: "discover.template",
|
|
103
184
|
description: "Samples recent issues for a project/issueType and extracts a reusable description template (Jira).",
|
|
@@ -107,27 +188,36 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
107
188
|
},
|
|
108
189
|
{
|
|
109
190
|
action: "discover.board_quickfilter",
|
|
110
|
-
description:
|
|
191
|
+
description:
|
|
192
|
+
"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
193
|
effect: "read",
|
|
112
194
|
properties: { backend: stringProp, boardId: numberProp, quickFilterId: numberProp },
|
|
113
195
|
required: ["backend", "boardId", "quickFilterId"],
|
|
114
196
|
},
|
|
115
197
|
{
|
|
116
198
|
action: "discover.board_filter",
|
|
117
|
-
description:
|
|
199
|
+
description:
|
|
200
|
+
"Resolves a Jira board's own real base scope -- its saved filter's JQL -- rather than assuming it tracks one named project.",
|
|
118
201
|
effect: "read",
|
|
119
202
|
properties: { backend: stringProp, boardId: numberProp },
|
|
120
203
|
required: ["backend", "boardId"],
|
|
121
204
|
},
|
|
122
205
|
{
|
|
123
206
|
action: "query.save",
|
|
124
|
-
description:
|
|
207
|
+
description:
|
|
208
|
+
"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
209
|
effect: "local-write",
|
|
126
210
|
properties: { name: stringProp, backend: stringProp, query: stringProp, description: stringProp },
|
|
127
211
|
required: ["name", "backend", "query"],
|
|
128
212
|
},
|
|
129
213
|
{ action: "query.list", description: "Lists every saved query.", effect: "read", properties: {}, required: [] },
|
|
130
|
-
{
|
|
214
|
+
{
|
|
215
|
+
action: "query.remove",
|
|
216
|
+
description: "Removes a saved query by name.",
|
|
217
|
+
effect: "local-write",
|
|
218
|
+
properties: { name: stringProp },
|
|
219
|
+
required: ["name"],
|
|
220
|
+
},
|
|
131
221
|
{
|
|
132
222
|
action: "query.run",
|
|
133
223
|
description: "Runs a saved query by name against its backend and returns the matching issues.",
|
|
@@ -137,6 +227,54 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
137
227
|
},
|
|
138
228
|
];
|
|
139
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The five discover.* operations only ever succeed against a backend whose
|
|
232
|
+
* repository implements the matching optional capability (Jira today,
|
|
233
|
+
* structurally -- never a hardcoded backend name). An operation none of the
|
|
234
|
+
* currently configured backends could possibly satisfy is marked
|
|
235
|
+
* unavailable so it never appears in the LLM's callable tool list in the
|
|
236
|
+
* first place, instead of being offered and then failing with
|
|
237
|
+
* NotSupportedError on the first real call.
|
|
238
|
+
*/
|
|
239
|
+
const DISCOVER_AVAILABILITY: readonly { action: TicketOperation; capability: keyof BackendCapabilities; reason: string }[] = [
|
|
240
|
+
{ action: "discover.fields", capability: "supportsFieldDiscovery", reason: "no configured backend supports field discovery (Jira only)" },
|
|
241
|
+
{
|
|
242
|
+
action: "discover.statuses",
|
|
243
|
+
capability: "supportsStatusDiscovery",
|
|
244
|
+
reason: "no configured backend supports status discovery (Jira only)",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
action: "discover.template",
|
|
248
|
+
capability: "supportsTemplateDiscovery",
|
|
249
|
+
reason: "no configured backend supports template discovery (Jira only)",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
action: "discover.board_quickfilter",
|
|
253
|
+
capability: "supportsBoardQuickFilterDiscovery",
|
|
254
|
+
reason: "no configured backend supports board quick-filter discovery (Jira only)",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
action: "discover.board_filter",
|
|
258
|
+
capability: "supportsBoardFilterDiscovery",
|
|
259
|
+
reason: "no configured backend supports board filter discovery (Jira only)",
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Re-syncs the five discover.* operations' availability against the
|
|
265
|
+
* service's current backend set -- called once right after the registry is
|
|
266
|
+
* built, and again after every live backend refresh (config.ts's
|
|
267
|
+
* createBackendRefreshTask), so a Jira credential added or removed at
|
|
268
|
+
* runtime flips these tools' visibility without a daemon restart.
|
|
269
|
+
*/
|
|
270
|
+
export function syncDiscoverAvailability(registry: VehicleRegistry, service: TicketService): void {
|
|
271
|
+
const capabilities = service.backendCapabilities();
|
|
272
|
+
for (const { action, capability, reason } of DISCOVER_AVAILABILITY) {
|
|
273
|
+
const available = capabilities.some((backend) => backend[capability]);
|
|
274
|
+
registry.setAvailability(action, 1, available, available ? undefined : reason);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
140
278
|
/**
|
|
141
279
|
* Builds a VehicleRegistry exposing every real ticket operation, backed by
|
|
142
280
|
* the exact same deps shape the daemon's own hand-rolled dispatch already
|
|
@@ -145,7 +283,11 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
145
283
|
* this registry to the same base object afterward).
|
|
146
284
|
*/
|
|
147
285
|
export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicleRegistry">): VehicleRegistry {
|
|
148
|
-
const registry = new VehicleRegistry({
|
|
286
|
+
const registry = new VehicleRegistry({
|
|
287
|
+
name: "tickets",
|
|
288
|
+
version: "1.0.0",
|
|
289
|
+
description: "Unified issue tracking across GitHub, GitLab, and Jira.",
|
|
290
|
+
});
|
|
149
291
|
|
|
150
292
|
for (const spec of OPERATIONS) {
|
|
151
293
|
const operation = defineVehicleOperation({
|
|
@@ -167,5 +309,6 @@ export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicle
|
|
|
167
309
|
);
|
|
168
310
|
}
|
|
169
311
|
|
|
312
|
+
syncDiscoverAvailability(registry, deps.service);
|
|
170
313
|
return registry;
|
|
171
314
|
}
|