@danypops/tickets 0.2.0 → 0.3.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/README.md +48 -15
- package/package.json +9 -6
- package/src/adapters/github.ts +135 -56
- package/src/adapters/gitlab.ts +98 -42
- package/src/adapters/jira.ts +128 -46
- package/src/application/service.ts +12 -1
- package/src/auth/gh-cli.ts +46 -0
- package/src/cli/index.ts +10 -1
- package/src/config/config.ts +80 -19
- package/src/daemon/bootstrap.ts +21 -5
- package/src/daemon/main.ts +1 -1
- package/src/daemon/ops.ts +1 -1
- package/src/daemon/poller.ts +8 -2
- package/src/domain/issue.ts +4 -0
- package/src/index.ts +2 -0
package/src/adapters/jira.ts
CHANGED
|
@@ -1,36 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Jira adapter — driven implementation of IssueRepository/CommentCapable against
|
|
3
|
-
* Jira Cloud
|
|
2
|
+
* Jira adapter — driven implementation of IssueRepository/CommentCapable against
|
|
3
|
+
* the Jira Cloud REST API v2, via jira.js's Version2Client (github.com/MrRefactoring/jira.js)
|
|
4
|
+
* rather than a hand-rolled HTTP client: a mature, actively-maintained,
|
|
5
|
+
* TypeScript-native client generated directly from Atlassian's own OpenAPI spec.
|
|
4
6
|
* Status changes go through Jira's workflow transitions, not a direct field PUT —
|
|
5
7
|
* Jira statuses are workflow-owned, so we resolve the transition whose name matches
|
|
6
8
|
* the mapped target status and post to it.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: assignee is deliberately NOT handled in create()/update() here — out of
|
|
11
|
+
* scope for this migration per explicit user direction, even though the mature
|
|
12
|
+
* client's `UserDetails.accountId` typing would make the fix straightforward
|
|
13
|
+
* (see RESEARCH.md for the full analysis of the bug this leaves unfixed).
|
|
7
14
|
*/
|
|
15
|
+
import { Version2Client } from "jira.js";
|
|
16
|
+
import type { HttpException } from "jira.js";
|
|
17
|
+
import type { AxiosAdapter } from "axios";
|
|
8
18
|
import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
|
|
9
19
|
import { parsePriority } from "../domain/issue.js";
|
|
10
|
-
import {
|
|
20
|
+
import { ApiError, IssueNotFoundError } from "./errors.js";
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* Basic-auth mode (email + API token) hits the tenant's own *.atlassian.net
|
|
14
24
|
* domain directly. OAuth 2.0 (3LO) mode (accessToken + cloudId, see
|
|
15
25
|
* auth/jira-oauth.ts) instead goes through api.atlassian.com/ex/jira/{cloudId}
|
|
16
26
|
* with a Bearer token — Atlassian does not accept 3LO tokens against the
|
|
17
|
-
* tenant domain directly.
|
|
27
|
+
* tenant domain directly; jira.js resolves that gateway routing itself when
|
|
28
|
+
* given `authentication.oauth2`. Exactly one of the two auth modes must be given.
|
|
18
29
|
*/
|
|
19
30
|
export type JiraOptions = JiraBasicAuthOptions | JiraOAuthOptions;
|
|
20
31
|
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
33
|
+
|
|
21
34
|
export interface JiraBasicAuthOptions {
|
|
22
35
|
baseUrl: string;
|
|
23
36
|
email: string;
|
|
24
37
|
token: string;
|
|
25
38
|
project?: string;
|
|
26
|
-
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
/** Injected in tests instead of a real network call — see axios's AxiosRequestConfig.adapter. */
|
|
41
|
+
axiosAdapter?: AxiosAdapter;
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
export interface JiraOAuthOptions {
|
|
30
45
|
accessToken: string;
|
|
31
46
|
cloudId: string;
|
|
32
47
|
project?: string;
|
|
33
|
-
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
axiosAdapter?: AxiosAdapter;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
function isOAuthOptions(opts: JiraOptions): opts is JiraOAuthOptions {
|
|
@@ -58,22 +74,26 @@ interface JiraIssue {
|
|
|
58
74
|
self: string;
|
|
59
75
|
fields: JiraIssueFields;
|
|
60
76
|
}
|
|
61
|
-
interface JiraTransition {
|
|
62
|
-
id: string;
|
|
63
|
-
name: string;
|
|
64
|
-
}
|
|
65
77
|
interface JiraComment {
|
|
66
|
-
id
|
|
67
|
-
|
|
68
|
-
created
|
|
69
|
-
updated
|
|
70
|
-
author?: { displayName
|
|
78
|
+
id?: string;
|
|
79
|
+
comment?: string;
|
|
80
|
+
created?: string;
|
|
81
|
+
updated?: string;
|
|
82
|
+
author?: { displayName?: string };
|
|
83
|
+
}
|
|
84
|
+
interface JiraFieldDetails {
|
|
85
|
+
id?: string;
|
|
86
|
+
name?: string;
|
|
87
|
+
custom?: boolean;
|
|
88
|
+
schema?: { type: string; items?: string };
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
export class JiraRepository {
|
|
74
92
|
readonly name: string;
|
|
75
|
-
private readonly
|
|
93
|
+
private readonly client: Version2Client;
|
|
76
94
|
private readonly project?: string;
|
|
95
|
+
/** display name (lowercased) -> { fieldId, schema type/items }, populated lazily from client.issueFields.getFields(). */
|
|
96
|
+
private customFieldCache?: Map<string, { id: string; type: string; items?: string }>;
|
|
77
97
|
|
|
78
98
|
constructor(name: string, opts: JiraOptions) {
|
|
79
99
|
this.name = name;
|
|
@@ -81,23 +101,23 @@ export class JiraRepository {
|
|
|
81
101
|
|
|
82
102
|
if (isOAuthOptions(opts)) {
|
|
83
103
|
if (!opts.accessToken || !opts.cloudId) throw new Error("jira: accessToken and cloudId are required for OAuth mode");
|
|
84
|
-
this.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
this.client = new Version2Client({
|
|
105
|
+
authentication: { oauth2: { accessToken: opts.accessToken, cloudId: opts.cloudId } },
|
|
106
|
+
// axios's own native, tested timeout handling -- a stalled call fails predictably
|
|
107
|
+
// instead of hanging (see RESEARCH.md for the octokit throttling-plugin hang this
|
|
108
|
+
// migration found and fixed; jira.js/axios has no equivalent auto-retry-and-wait
|
|
109
|
+
// behavior by default, but had no explicit timeout either until now).
|
|
110
|
+
baseRequestConfig: { timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(opts.axiosAdapter ? { adapter: opts.axiosAdapter } : {}) },
|
|
89
111
|
});
|
|
90
112
|
return;
|
|
91
113
|
}
|
|
92
114
|
|
|
93
115
|
if (!opts.baseUrl) throw new Error("jira: baseUrl is required");
|
|
94
116
|
if (!opts.email || !opts.token) throw new Error("jira: email and token are required");
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
fetchImpl: opts.fetchImpl,
|
|
100
|
-
headers: { Authorization: `Basic ${basic}` },
|
|
117
|
+
this.client = new Version2Client({
|
|
118
|
+
host: opts.baseUrl,
|
|
119
|
+
authentication: { basic: { email: opts.email, apiToken: opts.token } },
|
|
120
|
+
baseRequestConfig: { timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(opts.axiosAdapter ? { adapter: opts.axiosAdapter } : {}) },
|
|
101
121
|
});
|
|
102
122
|
}
|
|
103
123
|
|
|
@@ -113,11 +133,25 @@ export class JiraRepository {
|
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
async get(key: string): Promise<Issue> {
|
|
116
|
-
const raw = await this.
|
|
117
|
-
if (!raw) throw new Error(`jira: empty response for ${key}`);
|
|
136
|
+
const raw = await this.call<JiraIssue>(() => this.client.issues.getIssue({ issueIdOrKey: key }), key);
|
|
118
137
|
return toDomain(raw);
|
|
119
138
|
}
|
|
120
139
|
|
|
140
|
+
/** Runs a jira.js call and maps its HttpException onto this project's shared error taxonomy. */
|
|
141
|
+
private async call<T>(fn: () => Promise<unknown>, key?: string): Promise<T> {
|
|
142
|
+
try {
|
|
143
|
+
return (await fn()) as T;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const status = (err as Partial<HttpException>)?.status;
|
|
146
|
+
if (typeof status === "number") {
|
|
147
|
+
if (status === 404) throw new IssueNotFoundError("jira", key ?? "?");
|
|
148
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
149
|
+
throw new ApiError("jira", "?", key ?? "?", status, redact(message));
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
121
155
|
async create(input: CreateInput): Promise<Issue> {
|
|
122
156
|
const project = input.project ?? this.project;
|
|
123
157
|
if (!project) throw new Error("jira: project is required (pass project or set a default)");
|
|
@@ -132,8 +166,8 @@ export class JiraRepository {
|
|
|
132
166
|
if (input.priority && input.priority !== "none") {
|
|
133
167
|
fields.priority = { name: mapPriorityToJira(input.priority) };
|
|
134
168
|
}
|
|
135
|
-
|
|
136
|
-
|
|
169
|
+
if (input.customFields) await this.applyCustomFields(fields, input.customFields);
|
|
170
|
+
const result = await this.call<{ key: string }>(() => this.client.issues.createIssue({ fields } as never));
|
|
137
171
|
return this.get(result.key);
|
|
138
172
|
}
|
|
139
173
|
|
|
@@ -143,8 +177,10 @@ export class JiraRepository {
|
|
|
143
177
|
if (input.description !== undefined) fields.description = input.description;
|
|
144
178
|
if (input.priority !== undefined) fields.priority = { name: mapPriorityToJira(input.priority) };
|
|
145
179
|
if (input.labels !== undefined) fields.labels = input.labels;
|
|
180
|
+
if (input.customFields) await this.applyCustomFields(fields, input.customFields);
|
|
181
|
+
// assignee intentionally not handled here — see file header.
|
|
146
182
|
if (Object.keys(fields).length > 0) {
|
|
147
|
-
await this.
|
|
183
|
+
await this.call(() => this.client.issues.editIssue({ issueIdOrKey: key, fields }), key);
|
|
148
184
|
}
|
|
149
185
|
if (input.status !== undefined) {
|
|
150
186
|
await this.transitionTo(key, input.status, input.resolution);
|
|
@@ -163,28 +199,33 @@ export class JiraRepository {
|
|
|
163
199
|
}
|
|
164
200
|
|
|
165
201
|
async listComments(key: string): Promise<Comment[]> {
|
|
166
|
-
const result = await this.
|
|
202
|
+
const result = await this.call<{ comments?: JiraComment[] }>(
|
|
203
|
+
() => this.client.issueComments.getComments({ issueIdOrKey: key }),
|
|
204
|
+
key,
|
|
205
|
+
);
|
|
167
206
|
return (result?.comments ?? []).map(commentToDomain);
|
|
168
207
|
}
|
|
169
208
|
|
|
170
209
|
async addComment(key: string, body: string): Promise<Comment> {
|
|
171
|
-
const raw = await this.
|
|
172
|
-
|
|
210
|
+
const raw = await this.call<JiraComment>(
|
|
211
|
+
() => this.client.issueComments.addComment({ issueIdOrKey: key, comment: body }),
|
|
212
|
+
key,
|
|
213
|
+
);
|
|
173
214
|
return commentToDomain(raw);
|
|
174
215
|
}
|
|
175
216
|
|
|
176
217
|
private async searchJql(jql: string, limit: number): Promise<Issue[]> {
|
|
177
|
-
const result = await this.
|
|
178
|
-
jql,
|
|
179
|
-
|
|
180
|
-
});
|
|
218
|
+
const result = await this.call<{ issues?: JiraIssue[] }>(() =>
|
|
219
|
+
this.client.issueSearch.searchForIssuesUsingJqlPost({ jql, maxResults: limit }),
|
|
220
|
+
);
|
|
181
221
|
return (result?.issues ?? []).map(toDomain);
|
|
182
222
|
}
|
|
183
223
|
|
|
184
224
|
private async transitionTo(key: string, status: Status, resolution?: string): Promise<void> {
|
|
185
225
|
const target = mapStatusToJira(status);
|
|
186
|
-
const result = await this.
|
|
187
|
-
|
|
226
|
+
const result = await this.call<{ transitions?: { id: string; name: string }[] }>(
|
|
227
|
+
() => this.client.issues.getTransitions({ issueIdOrKey: key }),
|
|
228
|
+
key,
|
|
188
229
|
);
|
|
189
230
|
const transitions = result?.transitions ?? [];
|
|
190
231
|
const match = transitions.find((t) => t.name.toLowerCase() === target.toLowerCase());
|
|
@@ -192,10 +233,51 @@ export class JiraRepository {
|
|
|
192
233
|
const available = transitions.map((t) => t.name).join(", ");
|
|
193
234
|
throw new Error(`jira: no transition matching "${target}" (available: ${available})`);
|
|
194
235
|
}
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
236
|
+
const fields = resolution ? { resolution: { name: resolution } } : undefined;
|
|
237
|
+
await this.call(() => this.client.issues.doTransition({ issueIdOrKey: key, transition: { id: match.id }, fields }), key);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Resolves each custom field's display name to its `customfield_XXXXX` ID via
|
|
242
|
+
* client.issueFields.getFields() (GET /rest/api/2/field) -- not a hand-maintained
|
|
243
|
+
* map -- and coerces the given string value to the shape that field's own Jira
|
|
244
|
+
* schema type expects (a plain "option"/select field wants `{value}`, an array
|
|
245
|
+
* field splits on commas, everything else passes through as a raw string).
|
|
246
|
+
*/
|
|
247
|
+
private async applyCustomFields(fields: Record<string, unknown>, customFields: Record<string, string>): Promise<void> {
|
|
248
|
+
for (const [displayName, rawValue] of Object.entries(customFields)) {
|
|
249
|
+
const field = await this.resolveCustomField(displayName);
|
|
250
|
+
fields[field.id] = coerceCustomFieldValue(field, rawValue);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async resolveCustomField(displayName: string): Promise<{ id: string; type: string; items?: string }> {
|
|
255
|
+
if (!this.customFieldCache) {
|
|
256
|
+
const all = await this.call<JiraFieldDetails[]>(() => this.client.issueFields.getFields());
|
|
257
|
+
this.customFieldCache = new Map(
|
|
258
|
+
all
|
|
259
|
+
.filter((f) => f.custom && f.id && f.name)
|
|
260
|
+
.map((f) => [f.name!.toLowerCase(), { id: f.id!, type: f.schema?.type ?? "string", items: f.schema?.items }]),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const field = this.customFieldCache.get(displayName.toLowerCase());
|
|
264
|
+
if (!field) throw new Error(`jira: unknown custom field "${displayName}"`);
|
|
265
|
+
return field;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function coerceCustomFieldValue(field: { type: string; items?: string }, rawValue: string): unknown {
|
|
270
|
+
if (field.type === "array") {
|
|
271
|
+
const parts = rawValue.split(",").map((v) => v.trim()).filter(Boolean);
|
|
272
|
+
return field.items === "option" ? parts.map((v) => ({ value: v })) : parts;
|
|
198
273
|
}
|
|
274
|
+
if (field.type === "option") return { value: rawValue };
|
|
275
|
+
if (field.type === "number") return Number(rawValue);
|
|
276
|
+
return rawValue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function redact(text: string): string {
|
|
280
|
+
return text.replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"').slice(0, 2000);
|
|
199
281
|
}
|
|
200
282
|
|
|
201
283
|
function jqlQuote(value: string): string {
|
|
@@ -302,8 +384,8 @@ function toDomain(j: JiraIssue): Issue {
|
|
|
302
384
|
|
|
303
385
|
function commentToDomain(c: JiraComment): Comment {
|
|
304
386
|
return {
|
|
305
|
-
id: c.id,
|
|
306
|
-
body: c.
|
|
387
|
+
id: c.id ?? "",
|
|
388
|
+
body: c.comment ?? "",
|
|
307
389
|
author: c.author?.displayName,
|
|
308
390
|
createdAt: c.created,
|
|
309
391
|
updatedAt: c.updated,
|
|
@@ -23,12 +23,23 @@ export class NotSupportedError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export class TicketService {
|
|
26
|
-
constructor(private
|
|
26
|
+
constructor(private repos: Record<string, IssueRepository>) {}
|
|
27
27
|
|
|
28
28
|
backends(): string[] {
|
|
29
29
|
return Object.keys(this.repos);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Swaps the live backend set atomically. A backend newly configured in
|
|
34
|
+
* Enigma (or removed) becomes usable on the next call without
|
|
35
|
+
* reconstructing the service or restarting the daemon -- see
|
|
36
|
+
* config.ts's createBackendRefreshTask, the maintenance task that calls
|
|
37
|
+
* this on a schedule.
|
|
38
|
+
*/
|
|
39
|
+
setRepos(repos: Record<string, IssueRepository>): void {
|
|
40
|
+
this.repos = repos;
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
private repo(backend: string): IssueRepository {
|
|
33
44
|
const repo = this.repos[backend];
|
|
34
45
|
if (!repo) throw new UnknownBackendError(backend, this.backends());
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional shortcut: reuse an already-authenticated `gh` CLI session instead
|
|
3
|
+
* of running tickets' own OAuth device flow. Never re-implement a vendor
|
|
4
|
+
* CLI's own auth, just delegate to it and consume the result.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately shells out to `gh auth token` rather than reading gh's own
|
|
7
|
+
* credential storage directly: gh's default "secure storage" keeps the
|
|
8
|
+
* token in the OS keyring (Secret Service/libsecret on Linux, Keychain on
|
|
9
|
+
* macOS, Credential Manager on Windows) under an internal, undocumented
|
|
10
|
+
* schema -- not a published contract. `gh auth token` is gh's own
|
|
11
|
+
* documented, stable interface for exactly this scripting use case,
|
|
12
|
+
* abstracting over wherever the credential actually lives. The token never
|
|
13
|
+
* touches this process's own stdout/log output, only the returned string.
|
|
14
|
+
*/
|
|
15
|
+
export type GhCliTokenResult = { ok: true; token: string } | { ok: false; reason: string };
|
|
16
|
+
|
|
17
|
+
export interface SpawnLike {
|
|
18
|
+
(command: string[]): { stdout: ReadableStream<Uint8Array> | number; exited: Promise<number> };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const defaultSpawn: SpawnLike = (command) => Bun.spawn(command, { stdout: "pipe" });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads `gh auth token`'s output for the given account (gh's own `--user`
|
|
25
|
+
* flag; omit to use gh's currently active account). Never mints, never
|
|
26
|
+
* prompts, never falls back to a device flow itself.
|
|
27
|
+
*/
|
|
28
|
+
export async function readGhCliToken(user?: string, spawn: SpawnLike = defaultSpawn): Promise<GhCliTokenResult> {
|
|
29
|
+
const command = user ? ["gh", "auth", "token", "--user", user] : ["gh", "auth", "token"];
|
|
30
|
+
let proc: ReturnType<SpawnLike>;
|
|
31
|
+
try {
|
|
32
|
+
proc = spawn(command);
|
|
33
|
+
} catch {
|
|
34
|
+
return { ok: false, reason: "gh CLI not found -- install it (cli.github.com) or use a different login method" };
|
|
35
|
+
}
|
|
36
|
+
const [stdout, code] = await Promise.all([
|
|
37
|
+
proc.stdout instanceof ReadableStream ? new Response(proc.stdout).text() : Promise.resolve(""),
|
|
38
|
+
proc.exited,
|
|
39
|
+
]);
|
|
40
|
+
if (code !== 0) {
|
|
41
|
+
return { ok: false, reason: user ? `gh CLI has no authenticated account named "${user}" -- run \`gh auth login\` first` : "gh CLI is not authenticated -- run `gh auth login` first" };
|
|
42
|
+
}
|
|
43
|
+
const token = stdout.trim();
|
|
44
|
+
if (!token) return { ok: false, reason: "gh auth token returned no token" };
|
|
45
|
+
return { ok: true, token };
|
|
46
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { parseStatus } from "../domain/issue.js";
|
|
|
11
11
|
import { createTicketsClient, type TicketsRpcClient } from "../client/tickets-client.js";
|
|
12
12
|
import { openUrl } from "../auth/browser.js";
|
|
13
13
|
import { loginWithGitHubDeviceFlow } from "../auth/github-oauth.js";
|
|
14
|
+
import { readGhCliToken } from "../auth/gh-cli.js";
|
|
14
15
|
import { gitlabDeviceEndpoints, loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
|
|
15
16
|
import { loginWithJiraAuthorizationCode } from "../auth/jira-oauth.js";
|
|
16
17
|
import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
|
|
@@ -305,12 +306,20 @@ auth
|
|
|
305
306
|
.option("--client-secret <secret>", "OAuth client secret (Jira only — GitHub/GitLab device flow needs none)")
|
|
306
307
|
.option("--url <baseUrl>", "self-managed GitLab URL (defaults to gitlab.com)")
|
|
307
308
|
.option("--scope <scope>", "space-delimited OAuth scope override")
|
|
309
|
+
.option("--gh-cli [account]", "github only: reuse an already-authenticated gh CLI session instead of the device flow (omit value for gh's active account)")
|
|
308
310
|
.action(async (opts) => {
|
|
309
311
|
const type = opts.type ?? opts.backend;
|
|
310
312
|
try {
|
|
313
|
+
if (type === "github" && opts.ghCli !== undefined) {
|
|
314
|
+
const result = await readGhCliToken(opts.ghCli === true ? undefined : opts.ghCli);
|
|
315
|
+
if (!result.ok) throw new Error(result.reason);
|
|
316
|
+
saveToken(opts.backend, { accessToken: result.token });
|
|
317
|
+
printJson({ backend: opts.backend, status: "authorized", via: "gh-cli", note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
311
320
|
if (type === "github") {
|
|
312
321
|
const clientId = opts.clientId ?? process.env.GITHUB_OAUTH_CLIENT_ID;
|
|
313
|
-
if (!clientId) throw new Error("--client-id or GITHUB_OAUTH_CLIENT_ID is required");
|
|
322
|
+
if (!clientId) throw new Error("--client-id or GITHUB_OAUTH_CLIENT_ID is required (or pass --gh-cli [account] to reuse an already-authenticated gh CLI session instead)");
|
|
314
323
|
const token = await loginWithGitHubDeviceFlow({
|
|
315
324
|
clientId,
|
|
316
325
|
scope: opts.scope,
|
package/src/config/config.ts
CHANGED
|
@@ -7,11 +7,15 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { parse as parseYaml } from "yaml";
|
|
10
|
+
import type { MaintenanceTask } from "@danypops/daemon-kit/daemon";
|
|
11
|
+
import type { Logger } from "@danypops/daemon-kit/logging";
|
|
10
12
|
import { GitHubRepository } from "../adapters/github.js";
|
|
11
13
|
import { GitLabRepository } from "../adapters/gitlab.js";
|
|
12
14
|
import { JiraRepository } from "../adapters/jira.js";
|
|
13
15
|
import type { IssueRepository } from "../ports/repository.js";
|
|
16
|
+
import type { TicketService } from "../application/service.js";
|
|
14
17
|
import { isTokenFresh, loadToken } from "../auth/token-store.js";
|
|
18
|
+
import { type TryEnigmaCredential, tryEnigmaCredential } from "@danypops/enigma-client";
|
|
15
19
|
|
|
16
20
|
export interface BackendConfig {
|
|
17
21
|
/** Adapter type: "github" | "gitlab" | "jira". Falls back to the config key when omitted. */
|
|
@@ -55,19 +59,30 @@ function resolveToken(cfg: BackendConfig, env: NodeJS.ProcessEnv, envFallback: s
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
62
|
+
* Resolution order, highest priority first: (1) a running Enigma vault, if
|
|
63
|
+
* one happens to be configured for this backend — entirely optional, never a
|
|
64
|
+
* hard dependency, and bounded so Tickets never waits long for it (see
|
|
65
|
+
* @danypops/enigma-client); (2) a locally stored, still-fresh delegated OAuth
|
|
66
|
+
* token (see auth/token-store.ts, populated by `tickets auth login`); (3) a
|
|
67
|
+
* static config/env PAT. (1) is additive to the pre-Enigma precedence this
|
|
68
|
+
* project already followed for GitHub, GitLab, and Jira — see RESEARCH.md
|
|
69
|
+
* for the auth flows each backend actually supports (device flow for
|
|
70
|
+
* GitHub/GitLab, authorization code for Jira, which has no device flow or
|
|
71
|
+
* PKCE).
|
|
64
72
|
*/
|
|
65
|
-
function preferredAuth(
|
|
73
|
+
export async function preferredAuth(
|
|
66
74
|
name: string,
|
|
67
75
|
cfg: BackendConfig,
|
|
68
76
|
env: NodeJS.ProcessEnv,
|
|
69
77
|
envFallback: string,
|
|
70
|
-
|
|
78
|
+
tryEnigma: TryEnigmaCredential = tryEnigmaCredential,
|
|
79
|
+
): Promise<{ token: string | undefined; oauth: boolean; extra?: Record<string, string> }> {
|
|
80
|
+
// ENIGMA_CLIENT_TOKEN is this daemon's own registered-client token (`enigma client add`) --
|
|
81
|
+
// Enigma's shared admin-token file is deliberately unreadable outside its own service
|
|
82
|
+
// account, so tickets must present its own scoped token to get anything back at all.
|
|
83
|
+
const fromEnigma = await tryEnigma(name, { env, token: env.ENIGMA_CLIENT_TOKEN });
|
|
84
|
+
if (fromEnigma) return { token: fromEnigma.accessToken, oauth: true, extra: fromEnigma.extra };
|
|
85
|
+
|
|
71
86
|
const stored = loadToken(name, { env });
|
|
72
87
|
if (stored && isTokenFresh(stored)) {
|
|
73
88
|
return { token: stored.accessToken, oauth: true, extra: stored.extra };
|
|
@@ -80,45 +95,91 @@ function preferredAuth(
|
|
|
80
95
|
* inferrable purely from environment variables when not present in the config file.
|
|
81
96
|
* Config-file entries take precedence over bare env-var inference for the same name.
|
|
82
97
|
*/
|
|
83
|
-
export function buildRepositories(
|
|
98
|
+
export async function buildRepositories(
|
|
84
99
|
config: Config,
|
|
85
100
|
env: NodeJS.ProcessEnv = process.env,
|
|
86
|
-
|
|
101
|
+
tryEnigma: TryEnigmaCredential = tryEnigmaCredential,
|
|
102
|
+
): Promise<Record<string, IssueRepository>> {
|
|
87
103
|
const repos: Record<string, IssueRepository> = {};
|
|
88
104
|
|
|
89
105
|
for (const [name, cfg] of Object.entries(config.backends)) {
|
|
90
106
|
const type = cfg.type ?? name;
|
|
91
|
-
const repo = createRepository(name, type, cfg, env);
|
|
107
|
+
const repo = await createRepository(name, type, cfg, env, tryEnigma);
|
|
92
108
|
if (repo) repos[name] = repo;
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
if (!repos.github && (env.GITHUB_OWNER || env.GITHUB_TOKEN)) {
|
|
96
|
-
const repo = createRepository("github", "github", {}, env);
|
|
112
|
+
const repo = await createRepository("github", "github", {}, env, tryEnigma);
|
|
97
113
|
if (repo) repos.github = repo;
|
|
98
114
|
}
|
|
99
115
|
if (!repos.gitlab && (env.GITLAB_PROJECT || env.GITLAB_TOKEN)) {
|
|
100
|
-
const repo = createRepository("gitlab", "gitlab", {}, env);
|
|
116
|
+
const repo = await createRepository("gitlab", "gitlab", {}, env, tryEnigma);
|
|
101
117
|
if (repo) repos.gitlab = repo;
|
|
102
118
|
}
|
|
103
119
|
if (!repos.jira && env.JIRA_URL) {
|
|
104
|
-
const repo = createRepository("jira", "jira", {}, env);
|
|
120
|
+
const repo = await createRepository("jira", "jira", {}, env, tryEnigma);
|
|
105
121
|
if (repo) repos.jira = repo;
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
return repos;
|
|
109
125
|
}
|
|
110
126
|
|
|
111
|
-
|
|
127
|
+
export type BuildRepositories = typeof buildRepositories;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Re-runs buildRepositories on a schedule and swaps the result into a live
|
|
131
|
+
* TicketService via setRepos -- the counterpart to token-provider.ts's
|
|
132
|
+
* per-request freshness in Pipes, one level up: this refreshes which
|
|
133
|
+
* backends exist at all, not just an existing backend's token. A backend
|
|
134
|
+
* enigma login just made available becomes callable without a daemon
|
|
135
|
+
* restart; a removed one stops being offered. A failed refresh (Enigma
|
|
136
|
+
* unreachable, transient) keeps the previous backend set rather than
|
|
137
|
+
* wiping it out.
|
|
138
|
+
*/
|
|
139
|
+
export function createBackendRefreshTask(
|
|
140
|
+
service: TicketService,
|
|
141
|
+
config: Config,
|
|
142
|
+
buildRepos: BuildRepositories,
|
|
143
|
+
intervalMs: number,
|
|
144
|
+
logger?: Logger,
|
|
145
|
+
): MaintenanceTask {
|
|
146
|
+
return {
|
|
147
|
+
name: "backend-refresh",
|
|
148
|
+
intervalMs,
|
|
149
|
+
run: async () => {
|
|
150
|
+
const before = new Set(service.backends());
|
|
151
|
+
let fresh: Record<string, IssueRepository>;
|
|
152
|
+
try {
|
|
153
|
+
fresh = await buildRepos(config);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
logger?.warn("backend refresh failed, keeping previous backend set", {
|
|
156
|
+
error: error instanceof Error ? error.message : String(error),
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
service.setRepos(fresh);
|
|
161
|
+
const after = new Set(Object.keys(fresh));
|
|
162
|
+
const added = [...after].filter((backend) => !before.has(backend));
|
|
163
|
+
const removed = [...before].filter((backend) => !after.has(backend));
|
|
164
|
+
if (added.length > 0 || removed.length > 0) {
|
|
165
|
+
logger?.info("backend set changed", { added, removed });
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function createRepository(
|
|
112
172
|
name: string,
|
|
113
173
|
type: string,
|
|
114
174
|
cfg: BackendConfig,
|
|
115
175
|
env: NodeJS.ProcessEnv,
|
|
116
|
-
|
|
176
|
+
tryEnigma: TryEnigmaCredential,
|
|
177
|
+
): Promise<IssueRepository | undefined> {
|
|
117
178
|
switch (type) {
|
|
118
179
|
case "github": {
|
|
119
180
|
const owner = cfg.owner ?? env.GITHUB_OWNER;
|
|
120
181
|
if (!owner) return undefined;
|
|
121
|
-
const auth = preferredAuth(name, cfg, env, "GITHUB_TOKEN");
|
|
182
|
+
const auth = await preferredAuth(name, cfg, env, "GITHUB_TOKEN", tryEnigma);
|
|
122
183
|
return new GitHubRepository(name, {
|
|
123
184
|
owner,
|
|
124
185
|
repo: cfg.repo ?? env.GITHUB_REPO,
|
|
@@ -129,7 +190,7 @@ function createRepository(
|
|
|
129
190
|
case "gitlab": {
|
|
130
191
|
const project = cfg.project ?? env.GITLAB_PROJECT;
|
|
131
192
|
if (!project) return undefined;
|
|
132
|
-
const auth = preferredAuth(name, cfg, env, "GITLAB_TOKEN");
|
|
193
|
+
const auth = await preferredAuth(name, cfg, env, "GITLAB_TOKEN", tryEnigma);
|
|
133
194
|
return new GitLabRepository(name, {
|
|
134
195
|
projectId: project,
|
|
135
196
|
token: auth.token,
|
|
@@ -138,7 +199,7 @@ function createRepository(
|
|
|
138
199
|
});
|
|
139
200
|
}
|
|
140
201
|
case "jira": {
|
|
141
|
-
const auth = preferredAuth(name, cfg, env, "JIRA_API_TOKEN");
|
|
202
|
+
const auth = await preferredAuth(name, cfg, env, "JIRA_API_TOKEN", tryEnigma);
|
|
142
203
|
if (auth.oauth && auth.token && auth.extra?.cloudId) {
|
|
143
204
|
return new JiraRepository(name, {
|
|
144
205
|
accessToken: auth.token,
|
package/src/daemon/bootstrap.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { ensureAuthToken, type PathEnvironment, resolveDaemonPaths } from "@dany
|
|
|
11
11
|
import { checkpoint, openSqliteWithPragmas } from "@danypops/daemon-kit/storage";
|
|
12
12
|
import type { StartDaemonOptions } from "@danypops/daemon-kit/daemon";
|
|
13
13
|
import { TicketService } from "../application/service.js";
|
|
14
|
-
import { buildRepositories, type Config, loadConfig } from "../config/config.js";
|
|
14
|
+
import { buildRepositories, type BuildRepositories, type Config, createBackendRefreshTask, loadConfig } from "../config/config.js";
|
|
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";
|
|
@@ -22,12 +22,20 @@ import { createSyncTask } from "./poller.js";
|
|
|
22
22
|
export interface BootstrapOptions {
|
|
23
23
|
pathEnv?: PathEnvironment;
|
|
24
24
|
config?: Config;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Injected directly in tests instead of building from config/env. Also
|
|
27
|
+
* disables the live backend-refresh task -- an injected repo set is a
|
|
28
|
+
* fixed test fixture, not something to re-resolve from Enigma/config.
|
|
29
|
+
*/
|
|
26
30
|
repos?: Record<string, IssueRepository>;
|
|
31
|
+
/** Injected in tests to control which backends a refresh cycle resolves to, without a real Enigma/GitHub/GitLab/Jira. */
|
|
32
|
+
buildRepositories?: BuildRepositories;
|
|
27
33
|
version?: string;
|
|
28
34
|
logger?: Logger;
|
|
29
35
|
syncIntervalMs?: number;
|
|
30
36
|
checkpointIntervalMs?: number;
|
|
37
|
+
/** How often the live backend set re-resolves from config/env/Enigma. Ignored when repos is injected. */
|
|
38
|
+
backendRefreshIntervalMs?: number;
|
|
31
39
|
/**
|
|
32
40
|
* Overrides the daemon.shutdown op's effect. Defaults to sending this
|
|
33
41
|
* process SIGTERM, which daemon-kit's runDaemonProcess already handles
|
|
@@ -47,15 +55,18 @@ export interface BootstrappedDaemon {
|
|
|
47
55
|
|
|
48
56
|
const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
|
|
49
57
|
const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
|
|
58
|
+
const DEFAULT_BACKEND_REFRESH_INTERVAL_MS = 30_000;
|
|
50
59
|
|
|
51
|
-
export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
|
|
60
|
+
export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
|
|
52
61
|
const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
|
|
53
62
|
const token = ensureAuthToken(paths.token, "Tickets");
|
|
54
63
|
const db = openSqliteWithPragmas(paths.database, { migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS] });
|
|
55
64
|
const ledger = new Ledger(db);
|
|
56
65
|
const focusStore = new FocusStore(db);
|
|
57
66
|
const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
|
|
58
|
-
const
|
|
67
|
+
const config = opts.config ?? loadConfig();
|
|
68
|
+
const buildRepos = opts.buildRepositories ?? buildRepositories;
|
|
69
|
+
const repos = opts.repos ?? (await buildRepos(config));
|
|
59
70
|
const service = new TicketService(repos);
|
|
60
71
|
const version = opts.version ?? "0.0.0-dev";
|
|
61
72
|
|
|
@@ -64,12 +75,17 @@ export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
|
|
|
64
75
|
handlePath: paths.handle,
|
|
65
76
|
logger,
|
|
66
77
|
maintenanceTasks: [
|
|
67
|
-
createSyncTask(service, ledger,
|
|
78
|
+
createSyncTask(service, ledger, opts.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, logger),
|
|
68
79
|
{
|
|
69
80
|
name: "checkpoint",
|
|
70
81
|
intervalMs: opts.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS,
|
|
71
82
|
run: () => checkpoint(db),
|
|
72
83
|
},
|
|
84
|
+
// Only when repos came from real config/env/Enigma resolution -- an
|
|
85
|
+
// injected test fixture (opts.repos) has no config to re-resolve from.
|
|
86
|
+
...(opts.repos === undefined
|
|
87
|
+
? [createBackendRefreshTask(service, config, buildRepos, opts.backendRefreshIntervalMs ?? DEFAULT_BACKEND_REFRESH_INTERVAL_MS, logger)]
|
|
88
|
+
: []),
|
|
73
89
|
],
|
|
74
90
|
buildApp: () =>
|
|
75
91
|
buildApp({
|
package/src/daemon/main.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { readPackageVersion } from "@danypops/daemon-kit/version";
|
|
|
10
10
|
import { bootstrap } from "./bootstrap.js";
|
|
11
11
|
|
|
12
12
|
const version = readPackageVersion(new URL("../../package.json", import.meta.url), "Tickets");
|
|
13
|
-
const { options } = bootstrap({ version });
|
|
13
|
+
const { options } = await bootstrap({ version });
|
|
14
14
|
|
|
15
15
|
runDaemonProcess({
|
|
16
16
|
...options,
|
package/src/daemon/ops.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The RPC protocol shared between the tickets daemon (server.ts, running under
|
|
3
|
-
* Bun) and every client (cli/index.ts,
|
|
3
|
+
* Bun) and every client (cli/index.ts, packages/pi-tickets, running under
|
|
4
4
|
* whatever consumes this package). Pure types, zero runtime imports, safe to
|
|
5
5
|
* import from either side without pulling in bun:sqlite or Bun.serve.
|
|
6
6
|
*/
|