@danypops/tickets 0.3.0 → 0.4.1

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 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,6 +171,22 @@ tickets auth status
157
171
  tickets auth logout github
158
172
  ```
159
173
 
174
+ ### Static token instead (API key/PAT, no OAuth)
175
+
176
+ For a backend with no OAuth app to register against (e.g. a plain Atlassian
177
+ API token from `id.atlassian.com/manage-profile/security/api-tokens`),
178
+ store it directly -- same 0600 local file `auth login` writes to, checked
179
+ ahead of any `token`/`token_env` config-file or plain env-var fallback:
180
+
181
+ ```bash
182
+ tickets auth set-token jira
183
+ # Paste the "jira" token (input hidden): ****
184
+
185
+ # or non-interactively, e.g. from a password manager (piped stdin works too,
186
+ # since a non-TTY stdin is read as-is with nothing to mask):
187
+ pass show jira-api-token | tickets auth set-token jira
188
+ ```
189
+
160
190
  ### GitHub: reuse an already-authenticated `gh` CLI session
161
191
 
162
192
  `tickets auth login --backend github --gh-cli [account]` skips the device
@@ -208,7 +238,8 @@ Published as `@danypops/pi-tickets`. `../pi-tickets/` (this repo's workspace mem
208
238
  [pi](https://github.com/badlogic/pi) with one action per CLI command (`list`,
209
239
  `get`, `create`, `update`, `search`, `children`, `comments`, `comment_add`,
210
240
  `backends`, `ledger_search`, `ledger_stats`, `focus_set`, `focus_get`,
211
- `focus_pause`, `focus_unpause`, `focus_clear`). It talks to the same daemon
241
+ `focus_pause`, `focus_unpause`, `focus_clear`, `discover_fields`,
242
+ `discover_statuses`, `discover_template`). It talks to the same daemon
212
243
  through the same authenticated RPC client the CLI uses — never a direct
213
244
  backend call or a direct SQLite open. OAuth login and daemon lifecycle
214
245
  control are deliberately **not** exposed here (neither as a tool action nor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",
@@ -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 this.call<JiraIssue>(() => this.client.issues.getIssue({ issueIdOrKey: key }), key);
137
- return toDomain(raw);
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
- 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());
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
- function mapStatusFromJira(categoryKey: string | undefined): Status {
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 { hasComments, type IssueRepository } from "../ports/repository.js";
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[]) {
@@ -92,4 +99,22 @@ export class TicketService {
92
99
  if (!hasComments(repo)) throw new NotSupportedError(backend, "comments");
93
100
  return repo.addComment(key, body);
94
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
+ }
95
120
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Reads a secret from a real TTY with keystrokes masked -- never echoed to
3
+ * the terminal, so it can't land in scrollback or a screen share the way a
4
+ * typed-and-visible value would. Falls back to reading one line from stdin
5
+ * unmasked when stdin isn't a TTY (piped input, e.g. `pass show jira |
6
+ * tickets auth set-token jira`) -- there is nothing to mask once the value
7
+ * never touched an interactive terminal in the first place.
8
+ */
9
+ import { createInterface } from "node:readline";
10
+
11
+ export function promptMaskedSecret(
12
+ promptText: string,
13
+ input: NodeJS.ReadableStream = process.stdin,
14
+ output: NodeJS.WritableStream = process.stdout,
15
+ ): Promise<string> {
16
+ const isTTY = (input as NodeJS.ReadStream).isTTY === true;
17
+
18
+ if (!isTTY) {
19
+ return new Promise((resolve, reject) => {
20
+ let data = "";
21
+ input.setEncoding?.("utf8");
22
+ input.on("data", (chunk) => {
23
+ data += chunk;
24
+ });
25
+ input.on("end", () => resolve(data.split("\n")[0]?.trim() ?? ""));
26
+ input.on("error", reject);
27
+ });
28
+ }
29
+
30
+ return new Promise((resolve, reject) => {
31
+ const rl = createInterface({ input, output, terminal: true });
32
+ // readline has no public masking option; overriding the internal
33
+ // _writeToOutput hook (long-standing, widely-used pattern for exactly
34
+ // this) is the only way to suppress echoed keystrokes while still
35
+ // letting the prompt text itself render once.
36
+ // biome-ignore lint/suspicious/noExplicitAny: readline's internal _writeToOutput has no public type
37
+ const rlInternal = rl as any;
38
+ const originalWriteToOutput = rlInternal._writeToOutput.bind(rl);
39
+ let promptShown = false;
40
+ rlInternal._writeToOutput = (stringToWrite: string) => {
41
+ if (!promptShown) {
42
+ originalWriteToOutput(stringToWrite);
43
+ if (stringToWrite.includes(promptText)) promptShown = true;
44
+ }
45
+ // Every keystroke after the prompt itself is swallowed -- masked.
46
+ };
47
+ rl.question(promptText, (answer) => {
48
+ rl.close();
49
+ output.write("\n");
50
+ resolve(answer.trim());
51
+ });
52
+ rl.on("error", reject);
53
+ });
54
+ }
package/src/cli/index.ts CHANGED
@@ -15,6 +15,7 @@ import { readGhCliToken } from "../auth/gh-cli.js";
15
15
  import { gitlabDeviceEndpoints, loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
16
16
  import { loginWithJiraAuthorizationCode } from "../auth/jira-oauth.js";
17
17
  import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
18
+ import { promptMaskedSecret } from "../auth/masked-prompt.js";
18
19
  import { installTicketsService, systemctlTickets, systemdUnitPath } from "./systemd-service.js";
19
20
 
20
21
  function printJson(value: unknown): void {
@@ -195,6 +196,37 @@ focus
195
196
  await withClient((client) => client.call("focus.clear", {}));
196
197
  });
197
198
 
199
+ const discoverCmd = program.command("discover").description("discover and persist backend-specific mappings (custom fields, statuses) or a description template");
200
+
201
+ discoverCmd
202
+ .command("fields")
203
+ .description("discover a backend's custom field display names -> IDs and persist them for reuse (Jira only today)")
204
+ .requiredOption("-b, --backend <name>", "backend name")
205
+ .action(async (opts) => {
206
+ await withClient((client) => client.call("discover.fields", { backend: opts.backend }));
207
+ });
208
+
209
+ discoverCmd
210
+ .command("statuses")
211
+ .description("discover a backend's status names -> domain status and persist them (Jira only today)")
212
+ .requiredOption("-b, --backend <name>", "backend name")
213
+ .action(async (opts) => {
214
+ await withClient((client) => client.call("discover.statuses", { backend: opts.backend }));
215
+ });
216
+
217
+ discoverCmd
218
+ .command("template")
219
+ .description("sample recent issues for a project/issue-type and extract a reusable description template (Jira only today)")
220
+ .requiredOption("-b, --backend <name>", "backend name")
221
+ .requiredOption("--project <project>", "project key")
222
+ .requiredOption("--issue-type <type>", "issue type, e.g. Bug")
223
+ .option("--sample-size <n>", "how many recent issues to sample", (v) => Number.parseInt(v, 10))
224
+ .action(async (opts) => {
225
+ await withClient((client) =>
226
+ client.call("discover.template", { backend: opts.backend, project: opts.project, issueType: opts.issueType, sampleSize: opts.sampleSize }),
227
+ );
228
+ });
229
+
198
230
  const daemon = program.command("daemon").description("manage the tickets daemon process");
199
231
 
200
232
  daemon
@@ -391,6 +423,23 @@ auth
391
423
  }
392
424
  });
393
425
 
426
+ auth
427
+ .command("set-token <backend>")
428
+ .description("store a plain static token (API key/PAT, no OAuth) for a backend -- e.g. an Atlassian API token for jira")
429
+ .action(async (backend: string) => {
430
+ // TICKETS_TOKEN_VALUE remains for non-interactive/scripted use (a provisioning
431
+ // script, `pass show jira | tickets auth set-token jira`) -- never accepted as a
432
+ // plain CLI argument, which would land in shell history the way this would not.
433
+ const value = process.env.TICKETS_TOKEN_VALUE ?? (await promptMaskedSecret(`Paste the "${backend}" token (input hidden): `));
434
+ if (!value) {
435
+ process.stderr.write("no token value provided — paste one at the prompt, or set TICKETS_TOKEN_VALUE for non-interactive use\n");
436
+ process.exitCode = 1;
437
+ return;
438
+ }
439
+ saveToken(backend, { accessToken: value });
440
+ printJson({ backend, status: "stored", note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token" });
441
+ });
442
+
394
443
  auth
395
444
  .command("status")
396
445
  .description("list backends with a locally stored delegated token")
@@ -417,6 +466,13 @@ auth
417
466
  printJson({ backend, status: "logged_out" });
418
467
  });
419
468
 
420
- program.parseAsync(process.argv).catch(() => {
421
- process.exitCode = 1;
422
- });
469
+ // Guarded so this module can be imported for introspection (e.g. a CLI-parity
470
+ // test walking `program`'s registered commands) without executing real CLI
471
+ // argument parsing against the importer's own process.argv.
472
+ if (import.meta.main) {
473
+ program.parseAsync(process.argv).catch(() => {
474
+ process.exitCode = 1;
475
+ });
476
+ }
477
+
478
+ export { program };
@@ -205,6 +205,7 @@ async function createRepository(
205
205
  accessToken: auth.token,
206
206
  cloudId: auth.extra.cloudId,
207
207
  project: cfg.project ?? env.JIRA_PROJECT,
208
+ configDir: configDir(),
208
209
  });
209
210
  }
210
211
  const baseUrl = cfg.url ?? env.JIRA_URL;
@@ -215,6 +216,7 @@ async function createRepository(
215
216
  email,
216
217
  token: auth.token,
217
218
  project: cfg.project ?? env.JIRA_PROJECT,
219
+ configDir: configDir(),
218
220
  });
219
221
  }
220
222
  default:
package/src/daemon/ops.ts CHANGED
@@ -5,6 +5,7 @@
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
 
@@ -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.
@@ -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
+ }
@@ -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
+ }