@danypops/tickets 0.1.0 → 0.2.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.
@@ -1,14 +1,21 @@
1
1
  /**
2
2
  * GitLab adapter — driven implementation of IssueRepository/CommentCapable against
3
- * the GitLab REST API v4 (docs: https://docs.gitlab.com/api/issues/).
4
- * Self-hosted base URLs are validated to reject SSRF-prone targets (private/loopback
5
- * IPs, non-HTTPS non-localhost) before any request is made.
3
+ * the GitLab REST API v4, via @gitbeaker/rest (github.com/jdalrymple/gitbeaker)
4
+ * rather than a hand-rolled HTTP client: a mature, actively-maintained,
5
+ * TypeScript-native GitLab SDK supporting both gitlab.com and self-managed
6
+ * instances. Its typed `assignee_ids: number[]` (not a username) matches GitLab's
7
+ * real write contract exactly — a class of bug this adapter used to have (see
8
+ * RESEARCH.md): assignee was silently dropped in update() because a hand-rolled
9
+ * body never resolved a username to the numeric ID GitLab's API actually requires.
10
+ * Self-hosted base URLs are still validated to reject SSRF-prone targets before
11
+ * any request is made.
6
12
  */
13
+ import { Gitlab } from "@gitbeaker/rest";
14
+ import { GitbeakerRequestError, type RequesterType, type ResourceOptions } from "@gitbeaker/requester-utils";
7
15
  import { isIP } from "node:net";
8
16
  import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
9
17
  import { parsePriority } from "../domain/issue.js";
10
- import { AuthRequiredError, InvalidUrlError } from "./errors.js";
11
- import { type FetchLike, HttpClient } from "./http.js";
18
+ import { ApiError, AuthRequiredError, InvalidUrlError, IssueNotFoundError } from "./errors.js";
12
19
 
13
20
  export interface GitLabOptions {
14
21
  projectId: string;
@@ -20,10 +27,13 @@ export interface GitLabOptions {
20
27
  */
21
28
  tokenType?: "private" | "oauth";
22
29
  baseUrl?: string;
23
- fetchImpl?: FetchLike;
30
+ timeoutMs?: number;
31
+ /** Injected in tests instead of a real network call — see @gitbeaker/requester-utils' requesterFn. */
32
+ requesterFn?: (resourceOptions: ResourceOptions) => RequesterType;
24
33
  }
25
34
 
26
35
  interface GlUser {
36
+ id: number;
27
37
  username: string;
28
38
  name: string;
29
39
  }
@@ -49,10 +59,11 @@ interface GlNote {
49
59
  }
50
60
 
51
61
  const DEFAULT_URL = "https://gitlab.com";
62
+ const DEFAULT_TIMEOUT_MS = 30_000;
52
63
 
53
64
  export class GitLabRepository {
54
65
  readonly name: string;
55
- private readonly http: HttpClient;
66
+ private readonly client: InstanceType<typeof Gitlab>;
56
67
  private readonly projectId: string;
57
68
  private readonly readOnly: boolean;
58
69
 
@@ -61,17 +72,20 @@ export class GitLabRepository {
61
72
  const baseUrl = opts.baseUrl?.trim() || DEFAULT_URL;
62
73
  validateUrl(baseUrl);
63
74
  this.name = name;
64
- this.projectId = encodeURIComponent(opts.projectId);
75
+ this.projectId = opts.projectId;
65
76
  this.readOnly = !opts.token;
66
- this.http = new HttpClient({
67
- baseUrl,
68
- backend: "gitlab",
69
- fetchImpl: opts.fetchImpl,
70
- headers: opts.token
77
+ this.client = new Gitlab({
78
+ host: baseUrl,
79
+ // gitbeaker generates a fresh AbortSignal.timeout() per request internally when this
80
+ // is set (confirmed by reading its source), so a stalled call fails predictably
81
+ // instead of hanging -- same class of gap the octokit adapter needed a manual fix for.
82
+ queryTimeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
83
+ ...(opts.token
71
84
  ? opts.tokenType === "oauth"
72
- ? { Authorization: `Bearer ${opts.token}` }
73
- : { "PRIVATE-TOKEN": opts.token }
74
- : {},
85
+ ? { oauthToken: opts.token }
86
+ : { token: opts.token }
87
+ : {}),
88
+ ...(opts.requesterFn ? { requesterFn: opts.requesterFn } : {}),
75
89
  });
76
90
  }
77
91
 
@@ -81,47 +95,56 @@ export class GitLabRepository {
81
95
 
82
96
  async list(filter: ListFilter): Promise<Issue[]> {
83
97
  const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
84
- const params = new URLSearchParams({ per_page: String(limit) });
85
- if (filter.status) params.set("state", mapStatusToGitLab(filter.status));
86
- if (filter.assignee) params.set("assignee_username", filter.assignee);
87
- if (filter.labels?.length) params.set("labels", filter.labels.join(","));
88
-
89
- const raw = (await this.http.get<GlIssue[]>(`/api/v4/projects/${this.projectId}/issues?${params}`)) ?? [];
98
+ const raw = await this.call<GlIssue[]>(() =>
99
+ this.client.Issues.all({
100
+ projectId: this.projectId,
101
+ perPage: limit,
102
+ state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
103
+ assigneeUsername: filter.assignee ? [filter.assignee] : undefined,
104
+ labels: filter.labels?.length ? filter.labels.join(",") : undefined,
105
+ }),
106
+ );
90
107
  return raw.map(toDomain);
91
108
  }
92
109
 
93
110
  async get(key: string): Promise<Issue> {
94
111
  const iid = parseIid(key);
95
- const raw = await this.http.get<GlIssue>(`/api/v4/projects/${this.projectId}/issues/${iid}`);
96
- if (!raw) throw new Error(`gitlab: empty response for #${iid}`);
112
+ const raw = await this.call<GlIssue>(() => this.client.Issues.show(iid, { projectId: this.projectId }));
97
113
  return toDomain(raw);
98
114
  }
99
115
 
100
116
  async create(input: CreateInput): Promise<Issue> {
101
117
  this.requireAuth();
102
- const body: Record<string, unknown> = { title: input.title, description: input.description ?? "" };
103
- if (input.labels?.length) body.labels = input.labels.join(",");
104
- const raw = await this.http.post<GlIssue>(`/api/v4/projects/${this.projectId}/issues`, body);
105
- if (!raw) throw new Error("gitlab: create returned no body");
118
+ const assigneeIds = input.assignee ? [await this.resolveUserId(input.assignee)] : undefined;
119
+ const raw = await this.call<GlIssue>(() =>
120
+ this.client.Issues.create(this.projectId, input.title, {
121
+ description: input.description ?? "",
122
+ labels: input.labels?.length ? input.labels.join(",") : undefined,
123
+ assigneeIds,
124
+ }),
125
+ );
106
126
  return toDomain(raw);
107
127
  }
108
128
 
109
129
  async update(key: string, input: UpdateInput): Promise<Issue> {
110
130
  this.requireAuth();
111
131
  const iid = parseIid(key);
112
- const body: Record<string, unknown> = {};
113
- if (input.title !== undefined) body.title = input.title;
114
- if (input.description !== undefined) body.description = input.description;
115
- if (input.status !== undefined) body.state_event = mapStatusEventToGitLab(input.status);
116
- if (input.labels !== undefined) body.labels = input.labels.join(",");
117
- const raw = await this.http.put<GlIssue>(`/api/v4/projects/${this.projectId}/issues/${iid}`, body);
118
- if (!raw) throw new Error("gitlab: update returned no body");
132
+ const options: Record<string, unknown> = {};
133
+ if (input.title !== undefined) options.title = input.title;
134
+ if (input.description !== undefined) options.description = input.description;
135
+ if (input.status !== undefined) options.stateEvent = mapStatusEventToGitLab(input.status);
136
+ if (input.labels !== undefined) options.labels = input.labels.join(",");
137
+ if (input.assignee !== undefined) {
138
+ options.assigneeIds = input.assignee ? [await this.resolveUserId(input.assignee)] : [];
139
+ }
140
+ const raw = await this.call<GlIssue>(() => this.client.Issues.edit(this.projectId, iid, options));
119
141
  return toDomain(raw);
120
142
  }
121
143
 
122
144
  async search(query: string, limit = 50): Promise<Issue[]> {
123
- const params = new URLSearchParams({ search: query, per_page: String(limit) });
124
- const raw = (await this.http.get<GlIssue[]>(`/api/v4/projects/${this.projectId}/issues?${params}`)) ?? [];
145
+ const raw = await this.call<GlIssue[]>(() =>
146
+ this.client.Issues.all({ projectId: this.projectId, search: query, perPage: limit }),
147
+ );
125
148
  return raw.map(toDomain);
126
149
  }
127
150
 
@@ -132,21 +155,54 @@ export class GitLabRepository {
132
155
 
133
156
  async listComments(key: string): Promise<Comment[]> {
134
157
  const iid = parseIid(key);
135
- const raw = (await this.http.get<GlNote[]>(`/api/v4/projects/${this.projectId}/issues/${iid}/notes`)) ?? [];
158
+ const raw = await this.call<GlNote[]>(() => this.client.IssueNotes.all(this.projectId, iid));
136
159
  return raw.map(noteToDomain);
137
160
  }
138
161
 
139
162
  async addComment(key: string, body: string): Promise<Comment> {
140
163
  this.requireAuth();
141
164
  const iid = parseIid(key);
142
- const raw = await this.http.post<GlNote>(`/api/v4/projects/${this.projectId}/issues/${iid}/notes`, { body });
143
- if (!raw) throw new Error("gitlab: add comment returned no body");
165
+ const raw = await this.call<GlNote>(() => this.client.IssueNotes.create(this.projectId, iid, body));
144
166
  return noteToDomain(raw);
145
167
  }
168
+
169
+ /**
170
+ * GitLab's assignee write contract takes a numeric user ID, not a username
171
+ * (`assignee_ids: number[]`, confirmed against @gitbeaker/rest's generated
172
+ * types) — the exact gap the old hand-rolled adapter had (never resolved
173
+ * this, never even attempted to set assignee at all). GitLab's own username
174
+ * filter is an exact match, but we still confirm the returned user's
175
+ * username matches exactly before trusting its id.
176
+ */
177
+ private async resolveUserId(username: string): Promise<number> {
178
+ const users = await this.call<GlUser[]>(() => this.client.Users.all({ username }));
179
+ const match = users.find((u) => u.username === username);
180
+ if (!match) throw new Error(`gitlab: no user found with username "${username}"`);
181
+ return match.id;
182
+ }
183
+
184
+ /** Runs a gitbeaker call and maps GitbeakerRequestError onto this project's shared error taxonomy. */
185
+ private async call<T>(fn: () => Promise<unknown>): Promise<T> {
186
+ try {
187
+ return (await fn()) as T;
188
+ } catch (err) {
189
+ if (err instanceof GitbeakerRequestError) {
190
+ const status = err.cause?.response?.status ?? 500;
191
+ const url = err.cause?.request?.url ?? "";
192
+ if (status === 404) throw new IssueNotFoundError("gitlab", url);
193
+ throw new ApiError("gitlab", err.cause?.request?.method ?? "?", url, status, redact(err.message));
194
+ }
195
+ throw err;
196
+ }
197
+ }
198
+ }
199
+
200
+ function redact(text: string): string {
201
+ return text.replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"').slice(0, 2000);
146
202
  }
147
203
 
148
- function parseIid(key: string): string {
149
- return key.replace(/^#/, "");
204
+ function parseIid(key: string): number {
205
+ return Number(key.replace(/^#/, ""));
150
206
  }
151
207
 
152
208
  function mapStatusToGitLab(status: Status): "opened" | "closed" {
@@ -1,36 +1,52 @@
1
1
  /**
2
- * Jira adapter — driven implementation of IssueRepository/CommentCapable against the
3
- * Jira Cloud/Server REST API v2 (docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/).
2
+ * Jira adapter — driven implementation of IssueRepository/CommentCapable against
3
+ * the Jira Cloud REST API v2, via jira.js's Version2Client (github.com/MrRefactoring/jira.js)
4
+ * rather than a hand-rolled HTTP client: a mature, actively-maintained,
5
+ * TypeScript-native client generated directly from Atlassian's own OpenAPI spec.
4
6
  * Status changes go through Jira's workflow transitions, not a direct field PUT —
5
7
  * Jira statuses are workflow-owned, so we resolve the transition whose name matches
6
8
  * the mapped target status and post to it.
9
+ *
10
+ * NOTE: assignee is deliberately NOT handled in create()/update() here — out of
11
+ * scope for this migration per explicit user direction, even though the mature
12
+ * client's `UserDetails.accountId` typing would make the fix straightforward
13
+ * (see RESEARCH.md for the full analysis of the bug this leaves unfixed).
7
14
  */
15
+ import { Version2Client } from "jira.js";
16
+ import type { HttpException } from "jira.js";
17
+ import type { AxiosAdapter } from "axios";
8
18
  import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
9
19
  import { parsePriority } from "../domain/issue.js";
10
- import { type FetchLike, HttpClient } from "./http.js";
20
+ import { ApiError, IssueNotFoundError } from "./errors.js";
11
21
 
12
22
  /**
13
23
  * Basic-auth mode (email + API token) hits the tenant's own *.atlassian.net
14
24
  * domain directly. OAuth 2.0 (3LO) mode (accessToken + cloudId, see
15
25
  * auth/jira-oauth.ts) instead goes through api.atlassian.com/ex/jira/{cloudId}
16
26
  * with a Bearer token — Atlassian does not accept 3LO tokens against the
17
- * tenant domain directly. Exactly one of the two auth modes must be given.
27
+ * tenant domain directly; jira.js resolves that gateway routing itself when
28
+ * given `authentication.oauth2`. Exactly one of the two auth modes must be given.
18
29
  */
19
30
  export type JiraOptions = JiraBasicAuthOptions | JiraOAuthOptions;
20
31
 
32
+ const DEFAULT_TIMEOUT_MS = 30_000;
33
+
21
34
  export interface JiraBasicAuthOptions {
22
35
  baseUrl: string;
23
36
  email: string;
24
37
  token: string;
25
38
  project?: string;
26
- fetchImpl?: FetchLike;
39
+ timeoutMs?: number;
40
+ /** Injected in tests instead of a real network call — see axios's AxiosRequestConfig.adapter. */
41
+ axiosAdapter?: AxiosAdapter;
27
42
  }
28
43
 
29
44
  export interface JiraOAuthOptions {
30
45
  accessToken: string;
31
46
  cloudId: string;
32
47
  project?: string;
33
- fetchImpl?: FetchLike;
48
+ timeoutMs?: number;
49
+ axiosAdapter?: AxiosAdapter;
34
50
  }
35
51
 
36
52
  function isOAuthOptions(opts: JiraOptions): opts is JiraOAuthOptions {
@@ -58,22 +74,26 @@ interface JiraIssue {
58
74
  self: string;
59
75
  fields: JiraIssueFields;
60
76
  }
61
- interface JiraTransition {
62
- id: string;
63
- name: string;
64
- }
65
77
  interface JiraComment {
66
- id: string;
67
- body: string;
68
- created: string;
69
- updated: string;
70
- author?: { displayName: string };
78
+ id?: string;
79
+ comment?: string;
80
+ created?: string;
81
+ updated?: string;
82
+ author?: { displayName?: string };
83
+ }
84
+ interface JiraFieldDetails {
85
+ id?: string;
86
+ name?: string;
87
+ custom?: boolean;
88
+ schema?: { type: string; items?: string };
71
89
  }
72
90
 
73
91
  export class JiraRepository {
74
92
  readonly name: string;
75
- private readonly http: HttpClient;
93
+ private readonly client: Version2Client;
76
94
  private readonly project?: string;
95
+ /** display name (lowercased) -> { fieldId, schema type/items }, populated lazily from client.issueFields.getFields(). */
96
+ private customFieldCache?: Map<string, { id: string; type: string; items?: string }>;
77
97
 
78
98
  constructor(name: string, opts: JiraOptions) {
79
99
  this.name = name;
@@ -81,23 +101,23 @@ export class JiraRepository {
81
101
 
82
102
  if (isOAuthOptions(opts)) {
83
103
  if (!opts.accessToken || !opts.cloudId) throw new Error("jira: accessToken and cloudId are required for OAuth mode");
84
- this.http = new HttpClient({
85
- baseUrl: `https://api.atlassian.com/ex/jira/${opts.cloudId}`,
86
- backend: "jira",
87
- fetchImpl: opts.fetchImpl,
88
- headers: { Authorization: `Bearer ${opts.accessToken}` },
104
+ this.client = new Version2Client({
105
+ authentication: { oauth2: { accessToken: opts.accessToken, cloudId: opts.cloudId } },
106
+ // axios's own native, tested timeout handling -- a stalled call fails predictably
107
+ // instead of hanging (see RESEARCH.md for the octokit throttling-plugin hang this
108
+ // migration found and fixed; jira.js/axios has no equivalent auto-retry-and-wait
109
+ // behavior by default, but had no explicit timeout either until now).
110
+ baseRequestConfig: { timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(opts.axiosAdapter ? { adapter: opts.axiosAdapter } : {}) },
89
111
  });
90
112
  return;
91
113
  }
92
114
 
93
115
  if (!opts.baseUrl) throw new Error("jira: baseUrl is required");
94
116
  if (!opts.email || !opts.token) throw new Error("jira: email and token are required");
95
- const basic = Buffer.from(`${opts.email}:${opts.token}`).toString("base64");
96
- this.http = new HttpClient({
97
- baseUrl: opts.baseUrl,
98
- backend: "jira",
99
- fetchImpl: opts.fetchImpl,
100
- headers: { Authorization: `Basic ${basic}` },
117
+ this.client = new Version2Client({
118
+ host: opts.baseUrl,
119
+ authentication: { basic: { email: opts.email, apiToken: opts.token } },
120
+ baseRequestConfig: { timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(opts.axiosAdapter ? { adapter: opts.axiosAdapter } : {}) },
101
121
  });
102
122
  }
103
123
 
@@ -113,11 +133,25 @@ export class JiraRepository {
113
133
  }
114
134
 
115
135
  async get(key: string): Promise<Issue> {
116
- const raw = await this.http.get<JiraIssue>(`/rest/api/2/issue/${key}`);
117
- if (!raw) throw new Error(`jira: empty response for ${key}`);
136
+ const raw = await this.call<JiraIssue>(() => this.client.issues.getIssue({ issueIdOrKey: key }), key);
118
137
  return toDomain(raw);
119
138
  }
120
139
 
140
+ /** Runs a jira.js call and maps its HttpException onto this project's shared error taxonomy. */
141
+ private async call<T>(fn: () => Promise<unknown>, key?: string): Promise<T> {
142
+ try {
143
+ return (await fn()) as T;
144
+ } catch (err) {
145
+ const status = (err as Partial<HttpException>)?.status;
146
+ if (typeof status === "number") {
147
+ if (status === 404) throw new IssueNotFoundError("jira", key ?? "?");
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ throw new ApiError("jira", "?", key ?? "?", status, redact(message));
150
+ }
151
+ throw err;
152
+ }
153
+ }
154
+
121
155
  async create(input: CreateInput): Promise<Issue> {
122
156
  const project = input.project ?? this.project;
123
157
  if (!project) throw new Error("jira: project is required (pass project or set a default)");
@@ -132,8 +166,8 @@ export class JiraRepository {
132
166
  if (input.priority && input.priority !== "none") {
133
167
  fields.priority = { name: mapPriorityToJira(input.priority) };
134
168
  }
135
- const result = await this.http.post<{ key: string }>("/rest/api/2/issue", { fields });
136
- if (!result) throw new Error("jira: create returned no body");
169
+ if (input.customFields) await this.applyCustomFields(fields, input.customFields);
170
+ const result = await this.call<{ key: string }>(() => this.client.issues.createIssue({ fields } as never));
137
171
  return this.get(result.key);
138
172
  }
139
173
 
@@ -143,8 +177,10 @@ export class JiraRepository {
143
177
  if (input.description !== undefined) fields.description = input.description;
144
178
  if (input.priority !== undefined) fields.priority = { name: mapPriorityToJira(input.priority) };
145
179
  if (input.labels !== undefined) fields.labels = input.labels;
180
+ if (input.customFields) await this.applyCustomFields(fields, input.customFields);
181
+ // assignee intentionally not handled here — see file header.
146
182
  if (Object.keys(fields).length > 0) {
147
- await this.http.put(`/rest/api/2/issue/${key}`, { fields });
183
+ await this.call(() => this.client.issues.editIssue({ issueIdOrKey: key, fields }), key);
148
184
  }
149
185
  if (input.status !== undefined) {
150
186
  await this.transitionTo(key, input.status, input.resolution);
@@ -163,28 +199,33 @@ export class JiraRepository {
163
199
  }
164
200
 
165
201
  async listComments(key: string): Promise<Comment[]> {
166
- const result = await this.http.get<{ comments: JiraComment[] }>(`/rest/api/2/issue/${key}/comment`);
202
+ const result = await this.call<{ comments?: JiraComment[] }>(
203
+ () => this.client.issueComments.getComments({ issueIdOrKey: key }),
204
+ key,
205
+ );
167
206
  return (result?.comments ?? []).map(commentToDomain);
168
207
  }
169
208
 
170
209
  async addComment(key: string, body: string): Promise<Comment> {
171
- const raw = await this.http.post<JiraComment>(`/rest/api/2/issue/${key}/comment`, { body });
172
- if (!raw) throw new Error("jira: add comment returned no body");
210
+ const raw = await this.call<JiraComment>(
211
+ () => this.client.issueComments.addComment({ issueIdOrKey: key, comment: body }),
212
+ key,
213
+ );
173
214
  return commentToDomain(raw);
174
215
  }
175
216
 
176
217
  private async searchJql(jql: string, limit: number): Promise<Issue[]> {
177
- const result = await this.http.post<{ issues: JiraIssue[] }>("/rest/api/2/search", {
178
- jql,
179
- maxResults: limit,
180
- });
218
+ const result = await this.call<{ issues?: JiraIssue[] }>(() =>
219
+ this.client.issueSearch.searchForIssuesUsingJqlPost({ jql, maxResults: limit }),
220
+ );
181
221
  return (result?.issues ?? []).map(toDomain);
182
222
  }
183
223
 
184
224
  private async transitionTo(key: string, status: Status, resolution?: string): Promise<void> {
185
225
  const target = mapStatusToJira(status);
186
- const result = await this.http.get<{ transitions: JiraTransition[] }>(
187
- `/rest/api/2/issue/${key}/transitions`,
226
+ const result = await this.call<{ transitions?: { id: string; name: string }[] }>(
227
+ () => this.client.issues.getTransitions({ issueIdOrKey: key }),
228
+ key,
188
229
  );
189
230
  const transitions = result?.transitions ?? [];
190
231
  const match = transitions.find((t) => t.name.toLowerCase() === target.toLowerCase());
@@ -192,10 +233,51 @@ export class JiraRepository {
192
233
  const available = transitions.map((t) => t.name).join(", ");
193
234
  throw new Error(`jira: no transition matching "${target}" (available: ${available})`);
194
235
  }
195
- const body: Record<string, unknown> = { transition: { id: match.id } };
196
- if (resolution) body.fields = { resolution: { name: resolution } };
197
- await this.http.post(`/rest/api/2/issue/${key}/transitions`, body);
236
+ const fields = resolution ? { resolution: { name: resolution } } : undefined;
237
+ await this.call(() => this.client.issues.doTransition({ issueIdOrKey: key, transition: { id: match.id }, fields }), key);
238
+ }
239
+
240
+ /**
241
+ * Resolves each custom field's display name to its `customfield_XXXXX` ID via
242
+ * client.issueFields.getFields() (GET /rest/api/2/field) -- not a hand-maintained
243
+ * map -- and coerces the given string value to the shape that field's own Jira
244
+ * schema type expects (a plain "option"/select field wants `{value}`, an array
245
+ * field splits on commas, everything else passes through as a raw string).
246
+ */
247
+ private async applyCustomFields(fields: Record<string, unknown>, customFields: Record<string, string>): Promise<void> {
248
+ for (const [displayName, rawValue] of Object.entries(customFields)) {
249
+ const field = await this.resolveCustomField(displayName);
250
+ fields[field.id] = coerceCustomFieldValue(field, rawValue);
251
+ }
252
+ }
253
+
254
+ private async resolveCustomField(displayName: string): Promise<{ id: string; type: string; items?: string }> {
255
+ if (!this.customFieldCache) {
256
+ const all = await this.call<JiraFieldDetails[]>(() => this.client.issueFields.getFields());
257
+ this.customFieldCache = new Map(
258
+ all
259
+ .filter((f) => f.custom && f.id && f.name)
260
+ .map((f) => [f.name!.toLowerCase(), { id: f.id!, type: f.schema?.type ?? "string", items: f.schema?.items }]),
261
+ );
262
+ }
263
+ const field = this.customFieldCache.get(displayName.toLowerCase());
264
+ if (!field) throw new Error(`jira: unknown custom field "${displayName}"`);
265
+ return field;
266
+ }
267
+ }
268
+
269
+ function coerceCustomFieldValue(field: { type: string; items?: string }, rawValue: string): unknown {
270
+ if (field.type === "array") {
271
+ const parts = rawValue.split(",").map((v) => v.trim()).filter(Boolean);
272
+ return field.items === "option" ? parts.map((v) => ({ value: v })) : parts;
198
273
  }
274
+ if (field.type === "option") return { value: rawValue };
275
+ if (field.type === "number") return Number(rawValue);
276
+ return rawValue;
277
+ }
278
+
279
+ function redact(text: string): string {
280
+ return text.replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"').slice(0, 2000);
199
281
  }
200
282
 
201
283
  function jqlQuote(value: string): string {
@@ -302,8 +384,8 @@ function toDomain(j: JiraIssue): Issue {
302
384
 
303
385
  function commentToDomain(c: JiraComment): Comment {
304
386
  return {
305
- id: c.id,
306
- body: c.body,
387
+ id: c.id ?? "",
388
+ body: c.comment ?? "",
307
389
  author: c.author?.displayName,
308
390
  createdAt: c.created,
309
391
  updatedAt: c.updated,
@@ -0,0 +1,65 @@
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
+ };