@danypops/tickets 0.2.1 → 0.4.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 +57 -3
- package/package.json +2 -1
- package/src/adapters/jira.ts +224 -47
- package/src/application/service.ts +38 -2
- package/src/auth/gh-cli.ts +46 -0
- package/src/cli/index.ts +51 -4
- package/src/config/config.ts +55 -3
- package/src/daemon/bootstrap.ts +20 -4
- package/src/daemon/ops.ts +14 -1
- package/src/daemon/poller.ts +8 -2
- package/src/daemon/server.ts +5 -0
- package/src/domain/issue.ts +27 -0
- package/src/domain/template.ts +78 -0
- package/src/manifest/manifest.ts +62 -0
- package/src/ports/repository.ts +33 -0
- package/src/auth/enigma-source.ts +0 -65
package/README.md
CHANGED
|
@@ -46,6 +46,9 @@ bun run src/cli/index.ts daemon restart
|
|
|
46
46
|
|
|
47
47
|
bun run src/cli/index.ts backends
|
|
48
48
|
bun run src/cli/index.ts list -b github --status todo
|
|
49
|
+
# get includes fixVersions, issueLinks, externalLinks (Jira "Web Links", e.g.
|
|
50
|
+
# the PR that fixed a bug), and customFields (by display name -- run
|
|
51
|
+
# `discover fields` first, see below) whenever the backend has them.
|
|
49
52
|
bun run src/cli/index.ts get jira:PROJ-42
|
|
50
53
|
bun run src/cli/index.ts create -b github "Fix the thing" --label bug
|
|
51
54
|
bun run src/cli/index.ts comment add jira:PROJ-42 "Looks good, shipping"
|
|
@@ -60,6 +63,17 @@ bun run src/cli/index.ts focus get
|
|
|
60
63
|
bun run src/cli/index.ts focus pause "waiting on review"
|
|
61
64
|
bun run src/cli/index.ts focus unpause
|
|
62
65
|
bun run src/cli/index.ts focus clear
|
|
66
|
+
|
|
67
|
+
# Discover and persist backend-specific mappings (Jira only today): custom
|
|
68
|
+
# field display names -> IDs, and status names -> domain status. Read once
|
|
69
|
+
# from the backend, cached to ~/.config/tickets/{fields,statuses}/<backend>.yaml
|
|
70
|
+
# so a later lookup needs no network call and survives a daemon restart.
|
|
71
|
+
bun run src/cli/index.ts discover fields -b jira
|
|
72
|
+
bun run src/cli/index.ts discover statuses -b jira
|
|
73
|
+
|
|
74
|
+
# Sample recent issues for a project/issue-type and extract a reusable
|
|
75
|
+
# description template from the section headers common to all of them.
|
|
76
|
+
bun run src/cli/index.ts discover template -b jira --project PROJ --issue-type Bug
|
|
63
77
|
```
|
|
64
78
|
|
|
65
79
|
### Running the daemon persistently (systemd --user)
|
|
@@ -157,19 +171,59 @@ tickets auth status
|
|
|
157
171
|
tickets auth logout github
|
|
158
172
|
```
|
|
159
173
|
|
|
174
|
+
### GitHub: reuse an already-authenticated `gh` CLI session
|
|
175
|
+
|
|
176
|
+
`tickets auth login --backend github --gh-cli [account]` skips the device
|
|
177
|
+
flow (and the `GITHUB_OAUTH_CLIENT_ID` App registration it needs) entirely
|
|
178
|
+
by reading `gh auth token` instead — never re-implement a vendor CLI's own
|
|
179
|
+
auth, just consume its result via its own documented, stable interface.
|
|
180
|
+
Works whether `gh` stores its token in the OS keyring or a legacy
|
|
181
|
+
plaintext file. Omit `account` for `gh`'s current active account, or name
|
|
182
|
+
one of `gh`'s own multiple authenticated accounts (`gh auth status` lists
|
|
183
|
+
them) — pair with a distinct `--backend` name to register each as its own
|
|
184
|
+
tickets backend:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
tickets auth login --backend github-personal --gh-cli DanyPops
|
|
188
|
+
tickets auth login --backend github-work --gh-cli work-account
|
|
189
|
+
```
|
|
190
|
+
|
|
160
191
|
A stored, still-fresh delegated token always takes precedence over a static
|
|
161
192
|
config/env token for that backend. Tokens are written to
|
|
162
193
|
`$XDG_STATE_HOME/tickets/oauth/<backend>.json`, mode `0600`, and are never
|
|
163
194
|
printed by any command. **Restart the daemon** after logging in so it picks
|
|
164
195
|
up the new credential — `buildRepositories()` runs once at daemon startup.
|
|
165
196
|
|
|
197
|
+
### Optional: credentials via Enigma
|
|
198
|
+
|
|
199
|
+
If an [Enigma](https://github.com/DanyPops/enigma) vault is running,
|
|
200
|
+
tickets checks it first on every request, ahead of a stored delegated token
|
|
201
|
+
and any static config/env token — a credential Enigma rotates is picked up
|
|
202
|
+
on the very next call, no daemon restart needed. Purely additive: tickets
|
|
203
|
+
works identically with no Enigma running at all.
|
|
204
|
+
|
|
205
|
+
Register tickets as a scoped Enigma client (once), then pass the printed
|
|
206
|
+
token to the daemon via `ENIGMA_CLIENT_TOKEN`:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
enigma client add tickets --backends github,gitlab,jira
|
|
210
|
+
# -> prints a token once; export it wherever the tickets daemon is started
|
|
211
|
+
export ENIGMA_CLIENT_TOKEN=<printed token>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Without `ENIGMA_CLIENT_TOKEN`, tickets falls back to Enigma's shared
|
|
215
|
+
admin-token file if one exists at `$XDG_STATE_HOME/enigma/token` — fine for
|
|
216
|
+
a single-user machine where every local daemon is equally trusted, but a
|
|
217
|
+
scoped client token is the least-privilege default.
|
|
218
|
+
|
|
166
219
|
## The `pi-tickets` extension
|
|
167
220
|
|
|
168
|
-
Published as `@danypops/pi-tickets`.
|
|
221
|
+
Published as `@danypops/pi-tickets`. `../pi-tickets/` (this repo's workspace member) registers a single `tickets` tool for
|
|
169
222
|
[pi](https://github.com/badlogic/pi) with one action per CLI command (`list`,
|
|
170
223
|
`get`, `create`, `update`, `search`, `children`, `comments`, `comment_add`,
|
|
171
224
|
`backends`, `ledger_search`, `ledger_stats`, `focus_set`, `focus_get`,
|
|
172
|
-
`focus_pause`, `focus_unpause`, `focus_clear`
|
|
225
|
+
`focus_pause`, `focus_unpause`, `focus_clear`, `discover_fields`,
|
|
226
|
+
`discover_statuses`, `discover_template`). It talks to the same daemon
|
|
173
227
|
through the same authenticated RPC client the CLI uses — never a direct
|
|
174
228
|
backend call or a direct SQLite open. OAuth login and daemon lifecycle
|
|
175
229
|
control are deliberately **not** exposed here (neither as a tool action nor
|
|
@@ -196,7 +250,7 @@ To use it, add it to pi's `settings.json`:
|
|
|
196
250
|
```
|
|
197
251
|
|
|
198
252
|
Or, for local development against this monorepo, point at the workspace
|
|
199
|
-
member directory instead: `{ "packages": ["/path/to/tickets/
|
|
253
|
+
member directory instead: `{ "packages": ["/path/to/tickets/packages/pi-tickets"] }`.
|
|
200
254
|
|
|
201
255
|
## Development
|
|
202
256
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@danypops/daemon-kit": "^0.3.0",
|
|
28
|
+
"@danypops/enigma-client": "^0.3.0",
|
|
28
29
|
"@gitbeaker/rest": "^43.8.0",
|
|
29
30
|
"commander": "^12.1.0",
|
|
30
31
|
"jira.js": "^5.4.0",
|
package/src/adapters/jira.ts
CHANGED
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
import { Version2Client } from "jira.js";
|
|
16
16
|
import type { HttpException } from "jira.js";
|
|
17
17
|
import type { AxiosAdapter } from "axios";
|
|
18
|
-
import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
|
|
18
|
+
import type { Comment, CreateInput, Issue, IssueLink, ListFilter, Status, UpdateInput } from "../domain/issue.js";
|
|
19
19
|
import { parsePriority } from "../domain/issue.js";
|
|
20
20
|
import { ApiError, IssueNotFoundError } from "./errors.js";
|
|
21
|
+
import type { Template } from "../domain/template.js";
|
|
22
|
+
import { buildTemplateBody, extractTemplateSections } from "../domain/template.js";
|
|
23
|
+
import * as manifest from "../manifest/manifest.js";
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* Basic-auth mode (email + API token) hits the tenant's own *.atlassian.net
|
|
@@ -39,6 +42,13 @@ export interface JiraBasicAuthOptions {
|
|
|
39
42
|
timeoutMs?: number;
|
|
40
43
|
/** Injected in tests instead of a real network call — see axios's AxiosRequestConfig.adapter. */
|
|
41
44
|
axiosAdapter?: AxiosAdapter;
|
|
45
|
+
/**
|
|
46
|
+
* Directory holding this daemon's persisted field/status discovery manifests
|
|
47
|
+
* (see ../manifest/manifest.ts), typically configDir() from config.ts. When
|
|
48
|
+
* omitted, discovery still works but nothing is persisted across restarts —
|
|
49
|
+
* every test and any caller that doesn't care about persistence can leave it out.
|
|
50
|
+
*/
|
|
51
|
+
configDir?: string;
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
export interface JiraOAuthOptions {
|
|
@@ -47,12 +57,22 @@ export interface JiraOAuthOptions {
|
|
|
47
57
|
project?: string;
|
|
48
58
|
timeoutMs?: number;
|
|
49
59
|
axiosAdapter?: AxiosAdapter;
|
|
60
|
+
configDir?: string;
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
function isOAuthOptions(opts: JiraOptions): opts is JiraOAuthOptions {
|
|
53
64
|
return "accessToken" in opts;
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
interface JiraIssueLinkedIssue {
|
|
68
|
+
key: string;
|
|
69
|
+
fields: { summary: string; status: { name: string } };
|
|
70
|
+
}
|
|
71
|
+
interface JiraIssueLink {
|
|
72
|
+
type: { name: string; inward: string; outward: string };
|
|
73
|
+
inwardIssue?: JiraIssueLinkedIssue;
|
|
74
|
+
outwardIssue?: JiraIssueLinkedIssue;
|
|
75
|
+
}
|
|
56
76
|
interface JiraIssueFields {
|
|
57
77
|
summary: string;
|
|
58
78
|
description?: string | null;
|
|
@@ -67,6 +87,10 @@ interface JiraIssueFields {
|
|
|
67
87
|
parent?: { key: string; fields?: { summary: string; status?: { name: string } } };
|
|
68
88
|
created?: string;
|
|
69
89
|
updated?: string;
|
|
90
|
+
fixVersions?: { name: string }[];
|
|
91
|
+
issuelinks?: JiraIssueLink[];
|
|
92
|
+
/** customfield_XXXXX passthrough -- Jira's getIssue returns every field by default, this just doesn't narrow their type. */
|
|
93
|
+
[key: string]: unknown;
|
|
70
94
|
}
|
|
71
95
|
interface JiraIssue {
|
|
72
96
|
id: string;
|
|
@@ -74,6 +98,10 @@ interface JiraIssue {
|
|
|
74
98
|
self: string;
|
|
75
99
|
fields: JiraIssueFields;
|
|
76
100
|
}
|
|
101
|
+
interface JiraRemoteLink {
|
|
102
|
+
object?: { url?: string; title?: string };
|
|
103
|
+
application?: { name?: string };
|
|
104
|
+
}
|
|
77
105
|
interface JiraComment {
|
|
78
106
|
id?: string;
|
|
79
107
|
comment?: string;
|
|
@@ -92,12 +120,26 @@ export class JiraRepository {
|
|
|
92
120
|
readonly name: string;
|
|
93
121
|
private readonly client: Version2Client;
|
|
94
122
|
private readonly project?: string;
|
|
123
|
+
private readonly configDir?: string;
|
|
95
124
|
/** display name (lowercased) -> { fieldId, schema type/items }, populated lazily from client.issueFields.getFields(). */
|
|
96
125
|
private customFieldCache?: Map<string, { id: string; type: string; items?: string }>;
|
|
126
|
+
/** field id -> display name, the inbound counterpart of customFieldCache. Seeded from the persisted manifest at construction (no network), refreshed by discoverFields(). */
|
|
127
|
+
private fieldNameById = new Map<string, string>();
|
|
128
|
+
/** Jira status name -> domain Status, loaded from the persisted manifest at construction; refreshed by discoverStatuses(). Empty until discovery has run at least once (falls back to category-based mapping until then). */
|
|
129
|
+
private statusManifest: manifest.Manifest;
|
|
97
130
|
|
|
98
131
|
constructor(name: string, opts: JiraOptions) {
|
|
99
132
|
this.name = name;
|
|
100
133
|
this.project = opts.project;
|
|
134
|
+
this.configDir = opts.configDir;
|
|
135
|
+
|
|
136
|
+
if (this.configDir) {
|
|
137
|
+
const fieldManifest = manifest.load("fields", this.name, this.configDir);
|
|
138
|
+
for (const [displayName, id] of Object.entries(fieldManifest.mappings)) this.fieldNameById.set(id, displayName);
|
|
139
|
+
this.statusManifest = manifest.load("statuses", this.name, this.configDir);
|
|
140
|
+
} else {
|
|
141
|
+
this.statusManifest = { backend: this.name, mappings: {} };
|
|
142
|
+
}
|
|
101
143
|
|
|
102
144
|
if (isOAuthOptions(opts)) {
|
|
103
145
|
if (!opts.accessToken || !opts.cloudId) throw new Error("jira: accessToken and cloudId are required for OAuth mode");
|
|
@@ -133,8 +175,15 @@ export class JiraRepository {
|
|
|
133
175
|
}
|
|
134
176
|
|
|
135
177
|
async get(key: string): Promise<Issue> {
|
|
136
|
-
const raw = await
|
|
137
|
-
|
|
178
|
+
const [raw, remoteLinks] = await Promise.all([
|
|
179
|
+
this.call<JiraIssue>(() => this.client.issues.getIssue({ issueIdOrKey: key }), key),
|
|
180
|
+
this.call<JiraRemoteLink[]>(() => this.client.issueRemoteLinks.getRemoteIssueLinks({ issueIdOrKey: key }), key),
|
|
181
|
+
]);
|
|
182
|
+
const issue = this.toDomain(raw);
|
|
183
|
+
if (remoteLinks.length > 0) {
|
|
184
|
+
issue.externalLinks = remoteLinks.map((link) => ({ url: link.object?.url ?? "", title: link.object?.title, type: link.application?.name }));
|
|
185
|
+
}
|
|
186
|
+
return issue;
|
|
138
187
|
}
|
|
139
188
|
|
|
140
189
|
/** Runs a jira.js call and maps its HttpException onto this project's shared error taxonomy. */
|
|
@@ -218,7 +267,7 @@ export class JiraRepository {
|
|
|
218
267
|
const result = await this.call<{ issues?: JiraIssue[] }>(() =>
|
|
219
268
|
this.client.issueSearch.searchForIssuesUsingJqlPost({ jql, maxResults: limit }),
|
|
220
269
|
);
|
|
221
|
-
return (result?.issues ?? []).map(toDomain);
|
|
270
|
+
return (result?.issues ?? []).map((raw) => this.toDomain(raw));
|
|
222
271
|
}
|
|
223
272
|
|
|
224
273
|
private async transitionTo(key: string, status: Status, resolution?: string): Promise<void> {
|
|
@@ -252,18 +301,173 @@ export class JiraRepository {
|
|
|
252
301
|
}
|
|
253
302
|
|
|
254
303
|
private async resolveCustomField(displayName: string): Promise<{ id: string; type: string; items?: string }> {
|
|
255
|
-
if (!this.customFieldCache)
|
|
256
|
-
|
|
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());
|
|
304
|
+
if (!this.customFieldCache) await this.discoverFields();
|
|
305
|
+
const field = this.customFieldCache?.get(displayName.toLowerCase());
|
|
264
306
|
if (!field) throw new Error(`jira: unknown custom field "${displayName}"`);
|
|
265
307
|
return field;
|
|
266
308
|
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Discovers every custom field's display name -> customfield_XXXXX ID via
|
|
312
|
+
* client.issueFields.getFields() (GET /rest/api/2/field), same live call
|
|
313
|
+
* resolveCustomField already made lazily -- this just also persists the
|
|
314
|
+
* result to a manifest (see ../manifest/manifest.ts) so a later inbound
|
|
315
|
+
* lookup (fieldDisplayName) doesn't need a network round trip at all, and
|
|
316
|
+
* survives a daemon restart. Ported from emcee's FieldService.DiscoverFields
|
|
317
|
+
* (~/Workspace/emcee), same manifest file shape.
|
|
318
|
+
*/
|
|
319
|
+
async discoverFields(): Promise<Record<string, string>> {
|
|
320
|
+
const all = await this.call<JiraFieldDetails[]>(() => this.client.issueFields.getFields());
|
|
321
|
+
const custom = all.filter((f) => f.custom && f.id && f.name);
|
|
322
|
+
this.customFieldCache = new Map(
|
|
323
|
+
custom.map((f) => [f.name!.toLowerCase(), { id: f.id!, type: f.schema?.type ?? "string", items: f.schema?.items }]),
|
|
324
|
+
);
|
|
325
|
+
this.fieldNameById = new Map(custom.map((f) => [f.id!, f.name!]));
|
|
326
|
+
const mappings = Object.fromEntries(custom.map((f) => [f.name!, f.id!]));
|
|
327
|
+
if (this.configDir) manifest.save("fields", this.name, this.configDir, manifest.discover(this.name, mappings));
|
|
328
|
+
return mappings;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Inbound counterpart of resolveCustomField -- a display name for a raw customfield_XXXXX id, or undefined if never discovered. Never makes a network call; run discoverFields() (or construct with configDir set, so a prior discovery's manifest loads automatically) first. */
|
|
332
|
+
fieldDisplayName(fieldId: string): string | undefined {
|
|
333
|
+
return this.fieldNameById.get(fieldId);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Discovers every Jira status name -> domain Status via
|
|
338
|
+
* client.workflowStatuses.getStatuses() (GET /rest/api/2/status), persists
|
|
339
|
+
* it to a manifest, and immediately applies it in-memory so status mapping
|
|
340
|
+
* reflects the discovery without a daemon restart. Ported from emcee's
|
|
341
|
+
* StatusService.DiscoverStatuses.
|
|
342
|
+
*/
|
|
343
|
+
async discoverStatuses(): Promise<Record<string, string>> {
|
|
344
|
+
const all = await this.call<{ name: string; statusCategory?: { key: string } }[]>(() => this.client.workflowStatuses.getStatuses());
|
|
345
|
+
const mappings = Object.fromEntries(all.map((s) => [s.name, categoryToStatus(s.statusCategory?.key)]));
|
|
346
|
+
this.statusManifest = manifest.discover(this.name, mappings);
|
|
347
|
+
if (this.configDir) manifest.save("statuses", this.name, this.configDir, this.statusManifest);
|
|
348
|
+
return mappings;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Samples the most recently created issues for a project/issue-type pair
|
|
353
|
+
* and extracts the description section headers common to all of them --
|
|
354
|
+
* see ../domain/template.ts. Ported from emcee's TemplateService.DiscoverTemplate.
|
|
355
|
+
*/
|
|
356
|
+
async discoverTemplate(project: string, issueType: string, sampleSize = 5): Promise<Template | undefined> {
|
|
357
|
+
const jql = `project = ${jqlQuote(project)} AND issuetype = ${jqlQuote(issueType)} ORDER BY created DESC`;
|
|
358
|
+
const issues = await this.searchJql(jql, sampleSize > 0 ? sampleSize : 5);
|
|
359
|
+
const descriptions = issues.map((issue) => issue.description).filter((d): d is string => !!d);
|
|
360
|
+
const sections = extractTemplateSections(descriptions);
|
|
361
|
+
if (!sections) return undefined;
|
|
362
|
+
return { project, issueType, sections, body: buildTemplateBody(sections) };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private resolveStatus(categoryKey: string | undefined, statusName: string): Status {
|
|
366
|
+
const mapped = manifest.get(this.statusManifest, statusName);
|
|
367
|
+
if (mapped) return mapped as Status;
|
|
368
|
+
return mapStatusFromCategory(categoryKey);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private toDomain(j: JiraIssue): Issue {
|
|
372
|
+
const issue: Issue = {
|
|
373
|
+
ref: `jira:${j.key}`,
|
|
374
|
+
id: j.id,
|
|
375
|
+
key: j.key,
|
|
376
|
+
title: j.fields.summary,
|
|
377
|
+
description: j.fields.description ?? undefined,
|
|
378
|
+
status: this.resolveStatus(j.fields.status.statusCategory?.key, j.fields.status.name),
|
|
379
|
+
rawStatus: j.fields.status.name,
|
|
380
|
+
priority: mapPriorityFromJira(j.fields.priority?.name),
|
|
381
|
+
labels: j.fields.labels?.length ? j.fields.labels : undefined,
|
|
382
|
+
assignee: j.fields.assignee?.displayName,
|
|
383
|
+
reporter: j.fields.reporter?.displayName,
|
|
384
|
+
project: j.fields.project?.key,
|
|
385
|
+
issueType: j.fields.issuetype?.name,
|
|
386
|
+
resolution: j.fields.resolution?.name,
|
|
387
|
+
createdAt: j.fields.created,
|
|
388
|
+
updatedAt: j.fields.updated,
|
|
389
|
+
};
|
|
390
|
+
if (j.fields.parent) {
|
|
391
|
+
issue.parent = {
|
|
392
|
+
key: j.fields.parent.key,
|
|
393
|
+
title: j.fields.parent.fields?.summary ?? "",
|
|
394
|
+
status: j.fields.parent.fields?.status?.name,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
if (j.self) {
|
|
398
|
+
const idx = j.self.indexOf("/rest/");
|
|
399
|
+
if (idx > 0) issue.url = `${j.self.slice(0, idx)}/browse/${j.key}`;
|
|
400
|
+
}
|
|
401
|
+
if (j.fields.fixVersions?.length) issue.fixVersions = j.fields.fixVersions.map((v) => v.name);
|
|
402
|
+
if (j.fields.issuelinks?.length) issue.issueLinks = j.fields.issuelinks.flatMap(jiraIssueLinkToDomain);
|
|
403
|
+
const customFields = this.extractCustomFields(j.fields);
|
|
404
|
+
if (customFields) issue.customFields = customFields;
|
|
405
|
+
return issue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Every customfield_XXXXX Jira's getIssue response already carries (no
|
|
410
|
+
* extra call -- "All fields are returned by default", jira.js's own
|
|
411
|
+
* getIssue doc comment), keyed by display name via fieldNameById. A field
|
|
412
|
+
* with no known display name yet (discovery never ran for this backend) is
|
|
413
|
+
* skipped, not guessed at -- matching emcee's own "unmapped field, skip
|
|
414
|
+
* silently" behavior. Run `tickets discover fields -b <backend>` once, or
|
|
415
|
+
* construct with configDir set so a prior discovery's manifest loads
|
|
416
|
+
* automatically (see the constructor), to make more fields resolvable.
|
|
417
|
+
*/
|
|
418
|
+
private extractCustomFields(fields: JiraIssueFields): Record<string, string> | undefined {
|
|
419
|
+
if (this.fieldNameById.size === 0) return undefined;
|
|
420
|
+
let result: Record<string, string> | undefined;
|
|
421
|
+
for (const [fieldId, displayName] of this.fieldNameById) {
|
|
422
|
+
const raw = fields[fieldId];
|
|
423
|
+
if (raw === undefined || raw === null) continue;
|
|
424
|
+
const value = formatCustomFieldValue(raw);
|
|
425
|
+
if (value === undefined) continue;
|
|
426
|
+
result ??= {};
|
|
427
|
+
result[displayName] = value;
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function jiraIssueLinkToDomain(link: JiraIssueLink): IssueLink[] {
|
|
434
|
+
const links: IssueLink[] = [];
|
|
435
|
+
if (link.outwardIssue) {
|
|
436
|
+
links.push({
|
|
437
|
+
type: link.type.outward,
|
|
438
|
+
direction: "outward",
|
|
439
|
+
targetRef: `jira:${link.outwardIssue.key}`,
|
|
440
|
+
targetKey: link.outwardIssue.key,
|
|
441
|
+
targetTitle: link.outwardIssue.fields.summary,
|
|
442
|
+
targetStatus: link.outwardIssue.fields.status.name,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (link.inwardIssue) {
|
|
446
|
+
links.push({
|
|
447
|
+
type: link.type.inward,
|
|
448
|
+
direction: "inward",
|
|
449
|
+
targetRef: `jira:${link.inwardIssue.key}`,
|
|
450
|
+
targetKey: link.inwardIssue.key,
|
|
451
|
+
targetTitle: link.inwardIssue.fields.summary,
|
|
452
|
+
targetStatus: link.inwardIssue.fields.status.name,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return links;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Formats one raw customfield_XXXXX value for display: a {name}-bearing array (e.g. version fields) joins names; a {value} option object unwraps; everything else is stringified as-is. */
|
|
459
|
+
function formatCustomFieldValue(raw: unknown): string | undefined {
|
|
460
|
+
if (typeof raw === "string") return raw;
|
|
461
|
+
if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
|
|
462
|
+
if (Array.isArray(raw)) {
|
|
463
|
+
const names = raw.map((item) => (item && typeof item === "object" && "name" in item ? String((item as { name: unknown }).name) : undefined)).filter((n): n is string => !!n);
|
|
464
|
+
return names.length > 0 ? names.join(", ") : undefined;
|
|
465
|
+
}
|
|
466
|
+
if (raw && typeof raw === "object") {
|
|
467
|
+
if ("value" in raw) return String((raw as { value: unknown }).value);
|
|
468
|
+
if ("name" in raw) return String((raw as { name: unknown }).name);
|
|
469
|
+
}
|
|
470
|
+
return undefined;
|
|
267
471
|
}
|
|
268
472
|
|
|
269
473
|
function coerceCustomFieldValue(field: { type: string; items?: string }, rawValue: string): unknown {
|
|
@@ -303,7 +507,8 @@ function mapStatusToJira(status: Status): string {
|
|
|
303
507
|
}
|
|
304
508
|
}
|
|
305
509
|
|
|
306
|
-
|
|
510
|
+
/** Baseline category -> Status mapping, used until a per-status-name manifest entry (discoverStatuses) says otherwise -- same fallback emcee's own mapStatusFromJira uses. */
|
|
511
|
+
function mapStatusFromCategory(categoryKey: string | undefined): Status {
|
|
307
512
|
switch (categoryKey) {
|
|
308
513
|
case "new":
|
|
309
514
|
return "todo";
|
|
@@ -316,6 +521,11 @@ function mapStatusFromJira(categoryKey: string | undefined): Status {
|
|
|
316
521
|
}
|
|
317
522
|
}
|
|
318
523
|
|
|
524
|
+
/** categoryKey -> Status, used by discoverStatuses() to seed the persisted manifest from Jira's own status listing. */
|
|
525
|
+
function categoryToStatus(categoryKey: string | undefined): Status {
|
|
526
|
+
return mapStatusFromCategory(categoryKey);
|
|
527
|
+
}
|
|
528
|
+
|
|
319
529
|
function mapPriorityToJira(p: ReturnType<typeof parsePriority>): string {
|
|
320
530
|
switch (p) {
|
|
321
531
|
case "urgent":
|
|
@@ -349,39 +559,6 @@ function mapPriorityFromJira(name: string | undefined): ReturnType<typeof parseP
|
|
|
349
559
|
}
|
|
350
560
|
}
|
|
351
561
|
|
|
352
|
-
function toDomain(j: JiraIssue): Issue {
|
|
353
|
-
const issue: Issue = {
|
|
354
|
-
ref: `jira:${j.key}`,
|
|
355
|
-
id: j.id,
|
|
356
|
-
key: j.key,
|
|
357
|
-
title: j.fields.summary,
|
|
358
|
-
description: j.fields.description ?? undefined,
|
|
359
|
-
status: mapStatusFromJira(j.fields.status.statusCategory?.key),
|
|
360
|
-
rawStatus: j.fields.status.name,
|
|
361
|
-
priority: mapPriorityFromJira(j.fields.priority?.name),
|
|
362
|
-
labels: j.fields.labels?.length ? j.fields.labels : undefined,
|
|
363
|
-
assignee: j.fields.assignee?.displayName,
|
|
364
|
-
reporter: j.fields.reporter?.displayName,
|
|
365
|
-
project: j.fields.project?.key,
|
|
366
|
-
issueType: j.fields.issuetype?.name,
|
|
367
|
-
resolution: j.fields.resolution?.name,
|
|
368
|
-
createdAt: j.fields.created,
|
|
369
|
-
updatedAt: j.fields.updated,
|
|
370
|
-
};
|
|
371
|
-
if (j.fields.parent) {
|
|
372
|
-
issue.parent = {
|
|
373
|
-
key: j.fields.parent.key,
|
|
374
|
-
title: j.fields.parent.fields?.summary ?? "",
|
|
375
|
-
status: j.fields.parent.fields?.status?.name,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
if (j.self) {
|
|
379
|
-
const idx = j.self.indexOf("/rest/");
|
|
380
|
-
if (idx > 0) issue.url = `${j.self.slice(0, idx)}/browse/${j.key}`;
|
|
381
|
-
}
|
|
382
|
-
return issue;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
562
|
function commentToDomain(c: JiraComment): Comment {
|
|
386
563
|
return {
|
|
387
564
|
id: c.id ?? "",
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../domain/issue.js";
|
|
8
8
|
import { parseRef } from "../domain/issue.js";
|
|
9
|
-
import {
|
|
9
|
+
import type { Template } from "../domain/template.js";
|
|
10
|
+
import {
|
|
11
|
+
hasComments,
|
|
12
|
+
hasFieldDiscovery,
|
|
13
|
+
hasStatusDiscovery,
|
|
14
|
+
hasTemplateDiscovery,
|
|
15
|
+
type IssueRepository,
|
|
16
|
+
} from "../ports/repository.js";
|
|
10
17
|
|
|
11
18
|
export class UnknownBackendError extends Error {
|
|
12
19
|
constructor(backend: string, known: string[]) {
|
|
@@ -23,12 +30,23 @@ export class NotSupportedError extends Error {
|
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export class TicketService {
|
|
26
|
-
constructor(private
|
|
33
|
+
constructor(private repos: Record<string, IssueRepository>) {}
|
|
27
34
|
|
|
28
35
|
backends(): string[] {
|
|
29
36
|
return Object.keys(this.repos);
|
|
30
37
|
}
|
|
31
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Swaps the live backend set atomically. A backend newly configured in
|
|
41
|
+
* Enigma (or removed) becomes usable on the next call without
|
|
42
|
+
* reconstructing the service or restarting the daemon -- see
|
|
43
|
+
* config.ts's createBackendRefreshTask, the maintenance task that calls
|
|
44
|
+
* this on a schedule.
|
|
45
|
+
*/
|
|
46
|
+
setRepos(repos: Record<string, IssueRepository>): void {
|
|
47
|
+
this.repos = repos;
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
private repo(backend: string): IssueRepository {
|
|
33
51
|
const repo = this.repos[backend];
|
|
34
52
|
if (!repo) throw new UnknownBackendError(backend, this.backends());
|
|
@@ -81,4 +99,22 @@ export class TicketService {
|
|
|
81
99
|
if (!hasComments(repo)) throw new NotSupportedError(backend, "comments");
|
|
82
100
|
return repo.addComment(key, body);
|
|
83
101
|
}
|
|
102
|
+
|
|
103
|
+
async discoverFields(backend: string): Promise<Record<string, string>> {
|
|
104
|
+
const repo = this.repo(backend);
|
|
105
|
+
if (!hasFieldDiscovery(repo)) throw new NotSupportedError(backend, "field discovery");
|
|
106
|
+
return repo.discoverFields();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async discoverStatuses(backend: string): Promise<Record<string, string>> {
|
|
110
|
+
const repo = this.repo(backend);
|
|
111
|
+
if (!hasStatusDiscovery(repo)) throw new NotSupportedError(backend, "status discovery");
|
|
112
|
+
return repo.discoverStatuses();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async discoverTemplate(backend: string, project: string, issueType: string, sampleSize?: number): Promise<Template | undefined> {
|
|
116
|
+
const repo = this.repo(backend);
|
|
117
|
+
if (!hasTemplateDiscovery(repo)) throw new NotSupportedError(backend, "template discovery");
|
|
118
|
+
return repo.discoverTemplate(project, issueType, sampleSize);
|
|
119
|
+
}
|
|
84
120
|
}
|
|
@@ -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";
|
|
@@ -194,6 +195,37 @@ focus
|
|
|
194
195
|
await withClient((client) => client.call("focus.clear", {}));
|
|
195
196
|
});
|
|
196
197
|
|
|
198
|
+
const discoverCmd = program.command("discover").description("discover and persist backend-specific mappings (custom fields, statuses) or a description template");
|
|
199
|
+
|
|
200
|
+
discoverCmd
|
|
201
|
+
.command("fields")
|
|
202
|
+
.description("discover a backend's custom field display names -> IDs and persist them for reuse (Jira only today)")
|
|
203
|
+
.requiredOption("-b, --backend <name>", "backend name")
|
|
204
|
+
.action(async (opts) => {
|
|
205
|
+
await withClient((client) => client.call("discover.fields", { backend: opts.backend }));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
discoverCmd
|
|
209
|
+
.command("statuses")
|
|
210
|
+
.description("discover a backend's status names -> domain status and persist them (Jira only today)")
|
|
211
|
+
.requiredOption("-b, --backend <name>", "backend name")
|
|
212
|
+
.action(async (opts) => {
|
|
213
|
+
await withClient((client) => client.call("discover.statuses", { backend: opts.backend }));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
discoverCmd
|
|
217
|
+
.command("template")
|
|
218
|
+
.description("sample recent issues for a project/issue-type and extract a reusable description template (Jira only today)")
|
|
219
|
+
.requiredOption("-b, --backend <name>", "backend name")
|
|
220
|
+
.requiredOption("--project <project>", "project key")
|
|
221
|
+
.requiredOption("--issue-type <type>", "issue type, e.g. Bug")
|
|
222
|
+
.option("--sample-size <n>", "how many recent issues to sample", (v) => Number.parseInt(v, 10))
|
|
223
|
+
.action(async (opts) => {
|
|
224
|
+
await withClient((client) =>
|
|
225
|
+
client.call("discover.template", { backend: opts.backend, project: opts.project, issueType: opts.issueType, sampleSize: opts.sampleSize }),
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
197
229
|
const daemon = program.command("daemon").description("manage the tickets daemon process");
|
|
198
230
|
|
|
199
231
|
daemon
|
|
@@ -305,12 +337,20 @@ auth
|
|
|
305
337
|
.option("--client-secret <secret>", "OAuth client secret (Jira only — GitHub/GitLab device flow needs none)")
|
|
306
338
|
.option("--url <baseUrl>", "self-managed GitLab URL (defaults to gitlab.com)")
|
|
307
339
|
.option("--scope <scope>", "space-delimited OAuth scope override")
|
|
340
|
+
.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
341
|
.action(async (opts) => {
|
|
309
342
|
const type = opts.type ?? opts.backend;
|
|
310
343
|
try {
|
|
344
|
+
if (type === "github" && opts.ghCli !== undefined) {
|
|
345
|
+
const result = await readGhCliToken(opts.ghCli === true ? undefined : opts.ghCli);
|
|
346
|
+
if (!result.ok) throw new Error(result.reason);
|
|
347
|
+
saveToken(opts.backend, { accessToken: result.token });
|
|
348
|
+
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" });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
311
351
|
if (type === "github") {
|
|
312
352
|
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");
|
|
353
|
+
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
354
|
const token = await loginWithGitHubDeviceFlow({
|
|
315
355
|
clientId,
|
|
316
356
|
scope: opts.scope,
|
|
@@ -408,6 +448,13 @@ auth
|
|
|
408
448
|
printJson({ backend, status: "logged_out" });
|
|
409
449
|
});
|
|
410
450
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
451
|
+
// Guarded so this module can be imported for introspection (e.g. a CLI-parity
|
|
452
|
+
// test walking `program`'s registered commands) without executing real CLI
|
|
453
|
+
// argument parsing against the importer's own process.argv.
|
|
454
|
+
if (import.meta.main) {
|
|
455
|
+
program.parseAsync(process.argv).catch(() => {
|
|
456
|
+
process.exitCode = 1;
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export { program };
|
package/src/config/config.ts
CHANGED
|
@@ -7,12 +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";
|
|
15
|
-
import { type TryEnigmaCredential, tryEnigmaCredential } from "
|
|
18
|
+
import { type TryEnigmaCredential, tryEnigmaCredential } from "@danypops/enigma-client";
|
|
16
19
|
|
|
17
20
|
export interface BackendConfig {
|
|
18
21
|
/** Adapter type: "github" | "gitlab" | "jira". Falls back to the config key when omitted. */
|
|
@@ -59,7 +62,7 @@ function resolveToken(cfg: BackendConfig, env: NodeJS.ProcessEnv, envFallback: s
|
|
|
59
62
|
* Resolution order, highest priority first: (1) a running Enigma vault, if
|
|
60
63
|
* one happens to be configured for this backend — entirely optional, never a
|
|
61
64
|
* hard dependency, and bounded so Tickets never waits long for it (see
|
|
62
|
-
*
|
|
65
|
+
* @danypops/enigma-client); (2) a locally stored, still-fresh delegated OAuth
|
|
63
66
|
* token (see auth/token-store.ts, populated by `tickets auth login`); (3) a
|
|
64
67
|
* static config/env PAT. (1) is additive to the pre-Enigma precedence this
|
|
65
68
|
* project already followed for GitHub, GitLab, and Jira — see RESEARCH.md
|
|
@@ -74,7 +77,10 @@ export async function preferredAuth(
|
|
|
74
77
|
envFallback: string,
|
|
75
78
|
tryEnigma: TryEnigmaCredential = tryEnigmaCredential,
|
|
76
79
|
): Promise<{ token: string | undefined; oauth: boolean; extra?: Record<string, string> }> {
|
|
77
|
-
|
|
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 });
|
|
78
84
|
if (fromEnigma) return { token: fromEnigma.accessToken, oauth: true, extra: fromEnigma.extra };
|
|
79
85
|
|
|
80
86
|
const stored = loadToken(name, { env });
|
|
@@ -118,6 +124,50 @@ export async function buildRepositories(
|
|
|
118
124
|
return repos;
|
|
119
125
|
}
|
|
120
126
|
|
|
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
|
+
|
|
121
171
|
async function createRepository(
|
|
122
172
|
name: string,
|
|
123
173
|
type: string,
|
|
@@ -155,6 +205,7 @@ async function createRepository(
|
|
|
155
205
|
accessToken: auth.token,
|
|
156
206
|
cloudId: auth.extra.cloudId,
|
|
157
207
|
project: cfg.project ?? env.JIRA_PROJECT,
|
|
208
|
+
configDir: configDir(),
|
|
158
209
|
});
|
|
159
210
|
}
|
|
160
211
|
const baseUrl = cfg.url ?? env.JIRA_URL;
|
|
@@ -165,6 +216,7 @@ async function createRepository(
|
|
|
165
216
|
email,
|
|
166
217
|
token: auth.token,
|
|
167
218
|
project: cfg.project ?? env.JIRA_PROJECT,
|
|
219
|
+
configDir: configDir(),
|
|
168
220
|
});
|
|
169
221
|
}
|
|
170
222
|
default:
|
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,6 +55,7 @@ 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
60
|
export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
|
|
52
61
|
const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
|
|
@@ -55,7 +64,9 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
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 async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
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/ops.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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
|
*/
|
|
7
7
|
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../domain/issue.js";
|
|
8
|
+
import type { Template } from "../domain/template.js";
|
|
8
9
|
import type { TicketFocusState } from "./focus.js";
|
|
9
10
|
|
|
10
11
|
export type TicketOperation =
|
|
@@ -24,6 +25,9 @@ export type TicketOperation =
|
|
|
24
25
|
| "focus.pause"
|
|
25
26
|
| "focus.unpause"
|
|
26
27
|
| "focus.clear"
|
|
28
|
+
| "discover.fields"
|
|
29
|
+
| "discover.statuses"
|
|
30
|
+
| "discover.template"
|
|
27
31
|
| "daemon.shutdown";
|
|
28
32
|
|
|
29
33
|
export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
@@ -43,6 +47,9 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
|
43
47
|
"focus.pause": { reason?: string };
|
|
44
48
|
"focus.unpause": Record<string, never>;
|
|
45
49
|
"focus.clear": Record<string, never>;
|
|
50
|
+
"discover.fields": { backend: string };
|
|
51
|
+
"discover.statuses": { backend: string };
|
|
52
|
+
"discover.template": { backend: string; project: string; issueType: string; sampleSize?: number };
|
|
46
53
|
"daemon.shutdown": Record<string, never>;
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -63,6 +70,9 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
|
|
|
63
70
|
"focus.pause": { focus: TicketFocusState };
|
|
64
71
|
"focus.unpause": { focus: TicketFocusState };
|
|
65
72
|
"focus.clear": { cleared: boolean };
|
|
73
|
+
"discover.fields": { mappings: Record<string, string> };
|
|
74
|
+
"discover.statuses": { mappings: Record<string, string> };
|
|
75
|
+
"discover.template": { template: Template | null };
|
|
66
76
|
"daemon.shutdown": { stopping: true };
|
|
67
77
|
}
|
|
68
78
|
|
|
@@ -83,6 +93,9 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
|
|
|
83
93
|
"focus.pause",
|
|
84
94
|
"focus.unpause",
|
|
85
95
|
"focus.clear",
|
|
96
|
+
"discover.fields",
|
|
97
|
+
"discover.statuses",
|
|
98
|
+
"discover.template",
|
|
86
99
|
"daemon.shutdown",
|
|
87
100
|
];
|
|
88
101
|
|
package/src/daemon/poller.ts
CHANGED
|
@@ -35,10 +35,16 @@ export async function syncOnce(
|
|
|
35
35
|
return results;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Reads the backend list fresh from service.backends() on every tick,
|
|
40
|
+
* rather than a list frozen at task-creation time -- a backend the
|
|
41
|
+
* refresh task (config.ts's createBackendRefreshTask) just added to the
|
|
42
|
+
* service is synced on this task's very next run, no daemon restart or
|
|
43
|
+
* task rebuild needed.
|
|
44
|
+
*/
|
|
38
45
|
export function createSyncTask(
|
|
39
46
|
service: TicketService,
|
|
40
47
|
ledger: Ledger,
|
|
41
|
-
backends: string[],
|
|
42
48
|
intervalMs: number,
|
|
43
49
|
logger?: Logger,
|
|
44
50
|
): MaintenanceTask {
|
|
@@ -46,7 +52,7 @@ export function createSyncTask(
|
|
|
46
52
|
name: "ledger-sync",
|
|
47
53
|
intervalMs,
|
|
48
54
|
run: async () => {
|
|
49
|
-
await syncOnce(service, ledger, backends, logger);
|
|
55
|
+
await syncOnce(service, ledger, service.backends(), logger);
|
|
50
56
|
},
|
|
51
57
|
};
|
|
52
58
|
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -63,6 +63,11 @@ const handlers: { [Op in TicketOperation]: Handler<Op> } = {
|
|
|
63
63
|
"focus.pause": async (deps, input) => ({ focus: deps.focusStore.pause(input.reason) }),
|
|
64
64
|
"focus.unpause": async (deps) => ({ focus: deps.focusStore.unpause() }),
|
|
65
65
|
"focus.clear": async (deps) => ({ cleared: deps.focusStore.clear() }),
|
|
66
|
+
"discover.fields": async (deps, input) => ({ mappings: await deps.service.discoverFields(input.backend) }),
|
|
67
|
+
"discover.statuses": async (deps, input) => ({ mappings: await deps.service.discoverStatuses(input.backend) }),
|
|
68
|
+
"discover.template": async (deps, input) => ({
|
|
69
|
+
template: (await deps.service.discoverTemplate(input.backend, input.project, input.issueType, input.sampleSize)) ?? null,
|
|
70
|
+
}),
|
|
66
71
|
"daemon.shutdown": async (deps) => {
|
|
67
72
|
// Deferred so this handler's own response has already been handed back
|
|
68
73
|
// to Bun.serve before the process starts tearing down.
|
package/src/domain/issue.ts
CHANGED
|
@@ -45,6 +45,25 @@ export interface Comment {
|
|
|
45
45
|
updatedAt?: string;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** A link to another issue on the same backend (e.g. Jira's issuelinks: blocks, relates to, caused by). */
|
|
49
|
+
export interface IssueLink {
|
|
50
|
+
/** The link type's own label from the backend's perspective of this issue, e.g. "blocks" or "is blocked by". */
|
|
51
|
+
type: string;
|
|
52
|
+
direction: "inward" | "outward";
|
|
53
|
+
targetRef: string;
|
|
54
|
+
targetKey: string;
|
|
55
|
+
targetTitle?: string;
|
|
56
|
+
targetStatus?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A link to something outside the issue tracker entirely (e.g. Jira's "Web Links"/remote links: a PR, a doc). */
|
|
60
|
+
export interface ExternalLink {
|
|
61
|
+
url: string;
|
|
62
|
+
title?: string;
|
|
63
|
+
/** The linked application's own name when the backend reports one, e.g. "GitHub". */
|
|
64
|
+
type?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
48
67
|
/** The unified representation of a work item, regardless of which platform it lives on. */
|
|
49
68
|
export interface Issue {
|
|
50
69
|
/** "backend:key", e.g. "jira:PROJ-42" or "github:#7". */
|
|
@@ -67,6 +86,14 @@ export interface Issue {
|
|
|
67
86
|
createdAt?: string;
|
|
68
87
|
updatedAt?: string;
|
|
69
88
|
url?: string;
|
|
89
|
+
/** Versions this issue is fixed in/targeted for release in (Jira: fixVersions). */
|
|
90
|
+
fixVersions?: string[];
|
|
91
|
+
/** Links to other issues on the same backend (Jira: issuelinks). */
|
|
92
|
+
issueLinks?: IssueLink[];
|
|
93
|
+
/** Links to things outside the tracker entirely -- PRs, docs (Jira: "Web Links"/remote links). Only populated by get(), not list()/search(), to avoid one extra API call per result. */
|
|
94
|
+
externalLinks?: ExternalLink[];
|
|
95
|
+
/** Custom fields keyed by their backend display name (e.g. Jira's "Target Version"), resolved via that backend's field-discovery manifest. Empty until discovery has run at least once for the backend. */
|
|
96
|
+
customFields?: Record<string, string>;
|
|
70
97
|
}
|
|
71
98
|
|
|
72
99
|
export interface CreateInput {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Description template discovery — ported from emcee's internal/domain/template.go
|
|
3
|
+
* (~/Workspace/emcee). Templates are discovered by sampling existing issues and
|
|
4
|
+
* extracting the section-header structure repeated across all of them, not
|
|
5
|
+
* hand-authored. Zero I/O: callers supply the sampled descriptions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Template {
|
|
9
|
+
project: string;
|
|
10
|
+
issueType: string;
|
|
11
|
+
sections: string[];
|
|
12
|
+
body: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Strips a Jira-style {code}...{code} wrapper, keeping only its inner content. */
|
|
16
|
+
const JIRA_CODE_BLOCK_RE = /\{code(?::[^}]*)?\}([\s\S]*?)\{code\}/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns the section headers ("Label:") that appear in every one of the given
|
|
20
|
+
* descriptions, in the order they appear in the first description. Returns
|
|
21
|
+
* undefined if no common sections are found (including on empty input).
|
|
22
|
+
*/
|
|
23
|
+
export function extractTemplateSections(descriptions: string[]): string[] | undefined {
|
|
24
|
+
if (descriptions.length === 0) return undefined;
|
|
25
|
+
|
|
26
|
+
const allSections: string[][] = [];
|
|
27
|
+
for (const desc of descriptions) {
|
|
28
|
+
const sections = extractSections(desc);
|
|
29
|
+
if (sections.length > 0) allSections.push(sections);
|
|
30
|
+
}
|
|
31
|
+
if (allSections.length === 0) return undefined;
|
|
32
|
+
|
|
33
|
+
let common = allSections[0]!;
|
|
34
|
+
for (const other of allSections.slice(1)) {
|
|
35
|
+
const set = new Set(other);
|
|
36
|
+
common = common.filter((s) => set.has(s));
|
|
37
|
+
}
|
|
38
|
+
return common.length > 0 ? common : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Produces the empty template body from a list of section headers. */
|
|
42
|
+
export function buildTemplateBody(sections: string[] | undefined): string {
|
|
43
|
+
if (!sections || sections.length === 0) return "";
|
|
44
|
+
return sections.join("\n\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function extractSections(rawDesc: string): string[] {
|
|
48
|
+
let desc = rawDesc.trim();
|
|
49
|
+
if (desc === "") return [];
|
|
50
|
+
|
|
51
|
+
const codeMatch = desc.match(JIRA_CODE_BLOCK_RE);
|
|
52
|
+
if (codeMatch?.[1] !== undefined) desc = codeMatch[1].trim();
|
|
53
|
+
|
|
54
|
+
const sections: string[] = [];
|
|
55
|
+
const seen = new Set<string>();
|
|
56
|
+
for (const rawLine of desc.split("\n")) {
|
|
57
|
+
const line = rawLine.trim();
|
|
58
|
+
if (line === "") continue;
|
|
59
|
+
|
|
60
|
+
const idx = line.indexOf(":");
|
|
61
|
+
if (idx < 2) continue;
|
|
62
|
+
const label = line.slice(0, idx + 1);
|
|
63
|
+
// Must start with an uppercase letter.
|
|
64
|
+
if (label[0]! < "A" || label[0]! > "Z") continue;
|
|
65
|
+
if (!isCleanLabel(label.slice(0, -1))) continue;
|
|
66
|
+
if (!seen.has(label)) {
|
|
67
|
+
seen.add(label);
|
|
68
|
+
sections.push(label);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return sections.length >= 2 ? sections : [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const CLEAN_LABEL_RE = /^[\p{L}\p{N} /(),-]*$/u;
|
|
75
|
+
|
|
76
|
+
function isCleanLabel(label: string): boolean {
|
|
77
|
+
return CLEAN_LABEL_RE.test(label);
|
|
78
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-backend manifests — YAML files mapping semantic names to backend-specific
|
|
3
|
+
* values (e.g. Jira's "Target Version" -> "customfield_10855", or a Jira status
|
|
4
|
+
* name -> a domain Status). Ported from emcee's internal/manifest package
|
|
5
|
+
* (~/Workspace/emcee), same file layout and workflow:
|
|
6
|
+
*
|
|
7
|
+
* 1. Run discovery once per backend (adapter-specific: field/status listing).
|
|
8
|
+
* 2. The manifest is loaded from disk (no network) on subsequent reads.
|
|
9
|
+
* 3. Config-file entries override individual mappings.
|
|
10
|
+
*
|
|
11
|
+
* File location: $XDG_CONFIG_HOME/tickets/<kind>/<backend>.yaml (default
|
|
12
|
+
* ~/.config/tickets/<kind>/<backend>.yaml) -- kind is "fields" or "statuses".
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
17
|
+
|
|
18
|
+
export interface Manifest {
|
|
19
|
+
backend: string;
|
|
20
|
+
/** ISO timestamp of the last successful discovery run. */
|
|
21
|
+
discoveredAt?: string;
|
|
22
|
+
mappings: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function get(manifest: Manifest | undefined, key: string): string | undefined {
|
|
26
|
+
return manifest?.mappings[key];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Returns a new Manifest with overrides applied on top; does not mutate manifest. */
|
|
30
|
+
export function merge(manifest: Manifest, overrides: Record<string, string>): Manifest {
|
|
31
|
+
return { ...manifest, mappings: { ...manifest.mappings, ...overrides } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Builds a fresh Manifest from already-mapped semantic-name -> value pairs. */
|
|
35
|
+
export function discover(backend: string, mappings: Record<string, string>): Manifest {
|
|
36
|
+
return { backend, discoveredAt: new Date().toISOString(), mappings: { ...mappings } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function defaultPath(kind: string, backend: string, configDir: string): string {
|
|
40
|
+
return join(configDir, kind, `${backend}.yaml`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Reads a backend's manifest; returns an empty one (not an error) if the file doesn't exist yet. */
|
|
44
|
+
export function load(kind: string, backend: string, configDir: string): Manifest {
|
|
45
|
+
const path = defaultPath(kind, backend, configDir);
|
|
46
|
+
if (!existsSync(path)) return { backend, mappings: {} };
|
|
47
|
+
const data = readFileSync(path, "utf8");
|
|
48
|
+
const parsed = parseYaml(data) as Partial<Manifest> | undefined;
|
|
49
|
+
return { backend, discoveredAt: parsed?.discoveredAt, mappings: parsed?.mappings ?? {} };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Writes a backend's manifest, creating the <kind> subdirectory if needed. Overwrites any existing file. */
|
|
53
|
+
export function save(kind: string, backend: string, configDir: string, manifest: Manifest): void {
|
|
54
|
+
const dir = join(configDir, kind);
|
|
55
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
56
|
+
const header =
|
|
57
|
+
`# Tickets ${kind} manifest — ${backend}\n` +
|
|
58
|
+
`# Run \`tickets discover ${kind === "fields" ? "fields" : "statuses"} --backend ${backend}\` to regenerate.\n` +
|
|
59
|
+
"# Entries in config.yaml override individual mappings.\n\n";
|
|
60
|
+
const body = stringifyYaml({ backend, discoveredAt: manifest.discoveredAt, mappings: manifest.mappings });
|
|
61
|
+
writeFileSync(defaultPath(kind, backend, configDir), header + body, { mode: 0o600 });
|
|
62
|
+
}
|
package/src/ports/repository.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* The application layer depends only on these interfaces, never on a concrete adapter.
|
|
4
4
|
*/
|
|
5
5
|
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../domain/issue.js";
|
|
6
|
+
import type { Template } from "../domain/template.js";
|
|
6
7
|
|
|
7
8
|
export interface IssueRepository {
|
|
8
9
|
/** Backend identifier used in refs, e.g. "github", "gitlab", "jira". */
|
|
@@ -25,3 +26,35 @@ export interface CommentCapable {
|
|
|
25
26
|
export function hasComments(repo: IssueRepository): repo is IssueRepository & CommentCapable {
|
|
26
27
|
return typeof (repo as Partial<CommentCapable>).listComments === "function";
|
|
27
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Optional capability — discovers a backend's custom field display names ->
|
|
32
|
+
* IDs (e.g. Jira's "Target Version" -> "customfield_10855") and persists them
|
|
33
|
+
* to a manifest for reuse without a repeat network call. Only Jira supports
|
|
34
|
+
* this today (GitHub/GitLab have no tenant-specific custom field IDs).
|
|
35
|
+
*/
|
|
36
|
+
export interface FieldDiscoverable {
|
|
37
|
+
discoverFields(): Promise<Record<string, string>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hasFieldDiscovery(repo: IssueRepository): repo is IssueRepository & FieldDiscoverable {
|
|
41
|
+
return typeof (repo as Partial<FieldDiscoverable>).discoverFields === "function";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Optional capability — discovers a backend's status names -> domain Status and persists them to a manifest. */
|
|
45
|
+
export interface StatusDiscoverable {
|
|
46
|
+
discoverStatuses(): Promise<Record<string, string>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function hasStatusDiscovery(repo: IssueRepository): repo is IssueRepository & StatusDiscoverable {
|
|
50
|
+
return typeof (repo as Partial<StatusDiscoverable>).discoverStatuses === "function";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Optional capability — discovers a reusable description template by sampling existing issues. */
|
|
54
|
+
export interface TemplateDiscoverable {
|
|
55
|
+
discoverTemplate(project: string, issueType: string, sampleSize?: number): Promise<Template | undefined>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function hasTemplateDiscovery(repo: IssueRepository): repo is IssueRepository & TemplateDiscoverable {
|
|
59
|
+
return typeof (repo as Partial<TemplateDiscoverable>).discoverTemplate === "function";
|
|
60
|
+
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Optional credential source: a running Enigma vault (github.com/DanyPops/enigma),
|
|
3
|
-
* if one happens to be configured on this machine. Purely additive — Tickets
|
|
4
|
-
* has never imported Enigma's package and never will; this talks to Enigma's
|
|
5
|
-
* loopback HTTP API using only @danypops/daemon-kit, which Tickets already
|
|
6
|
-
* depends on for its own daemon plumbing. Enigma's discovery contract is three
|
|
7
|
-
* stable, documented constants (its state-directory name and its handle/token
|
|
8
|
-
* filenames), not an import of Enigma's own source.
|
|
9
|
-
*
|
|
10
|
-
* Never creates Enigma's handle or token files — those are strictly Enigma's
|
|
11
|
-
* own job on its first boot. A consumer that could mint them would be a real
|
|
12
|
-
* security problem, not a convenience. Absence of either file means "Enigma
|
|
13
|
-
* isn't running or isn't configured for this backend," not an error: every
|
|
14
|
-
* failure path here resolves `undefined` rather than throwing, and the whole
|
|
15
|
-
* attempt is time-bounded so a slow or hung Enigma can never stall Tickets'
|
|
16
|
-
* own startup.
|
|
17
|
-
*/
|
|
18
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
-
import { readDaemonHandle, resolveDaemonPaths } from "@danypops/daemon-kit/paths";
|
|
20
|
-
import { createVaultClient, type RefreshableAccessToken } from "@danypops/daemon-kit/vault";
|
|
21
|
-
|
|
22
|
-
const ENIGMA_STATE_DIRECTORY_NAME = "enigma";
|
|
23
|
-
const ENIGMA_HANDLE_FILENAME = "handle.json";
|
|
24
|
-
const ENIGMA_TOKEN_FILENAME = "token";
|
|
25
|
-
const ENIGMA_LOOKUP_TIMEOUT_MS = 500;
|
|
26
|
-
|
|
27
|
-
export interface TryEnigmaCredentialEnv {
|
|
28
|
-
env?: Record<string, string | undefined>;
|
|
29
|
-
/** Injectable for tests; production default is the real fetch, bounded by AbortSignal.timeout. */
|
|
30
|
-
fetchImpl?: typeof fetch;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type TryEnigmaCredential = (backend: string, opts?: TryEnigmaCredentialEnv) => Promise<RefreshableAccessToken | undefined>;
|
|
34
|
-
|
|
35
|
-
export const tryEnigmaCredential: TryEnigmaCredential = async (backend, opts = {}) => {
|
|
36
|
-
const env = opts.env ?? process.env;
|
|
37
|
-
const paths = resolveDaemonPaths(
|
|
38
|
-
{ stateDirectoryName: ENIGMA_STATE_DIRECTORY_NAME, handleFilename: ENIGMA_HANDLE_FILENAME, tokenFilename: ENIGMA_TOKEN_FILENAME, databaseFilename: "", systemdUnitName: "" },
|
|
39
|
-
{ env },
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
const handle = readDaemonHandle(paths.handle);
|
|
43
|
-
if (!handle) return undefined; // Enigma isn't running -- not an error, just not present
|
|
44
|
-
|
|
45
|
-
if (!existsSync(paths.token)) return undefined; // never ensureAuthToken here -- read-only, never mint Enigma's own token
|
|
46
|
-
let token: string;
|
|
47
|
-
try {
|
|
48
|
-
token = readFileSync(paths.token, "utf8").trim();
|
|
49
|
-
} catch {
|
|
50
|
-
return undefined;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
54
|
-
const client = createVaultClient({
|
|
55
|
-
baseUrl: `http://${handle.host}:${handle.port}`,
|
|
56
|
-
authToken: token,
|
|
57
|
-
fetchImpl: (url, init) => fetchImpl(url, { ...init, signal: AbortSignal.timeout(ENIGMA_LOOKUP_TIMEOUT_MS) }),
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
return await client.getCredentials(backend);
|
|
62
|
-
} catch {
|
|
63
|
-
return undefined; // unreachable, timed out, or any other transport failure -- fall through silently
|
|
64
|
-
}
|
|
65
|
-
};
|