acdev 1.0.13 → 1.0.15

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/src/github.js CHANGED
@@ -5,6 +5,63 @@ const execFileAsync = promisify(execFile);
5
5
 
6
6
  const ISSUE_URL_RE = /github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/;
7
7
 
8
+ const REPO_FROM_REMOTE_RE =
9
+ /(?:github\.com[:/]|github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/i;
10
+
11
+ const PROJECT_STATUS_FIELDS_QUERY = `query($owner: String!, $name: String!) {
12
+ repository(owner: $owner, name: $name) {
13
+ projectsV2(first: 20) {
14
+ nodes {
15
+ id
16
+ title
17
+ fields(first: 30) {
18
+ nodes {
19
+ __typename
20
+ ... on ProjectV2SingleSelectField {
21
+ id
22
+ name
23
+ options { id name }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }`;
31
+
32
+ const ISSUE_PROJECT_ITEMS_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
33
+ repository(owner: $owner, name: $name) {
34
+ issue(number: $number) {
35
+ id
36
+ projectItems(first: 20) {
37
+ nodes {
38
+ id
39
+ project { id title }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }`;
45
+
46
+ const ADD_PROJECT_ITEM_MUTATION = `mutation($projectId: ID!, $contentId: ID!) {
47
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
48
+ item { id }
49
+ }
50
+ }`;
51
+
52
+ const SET_PROJECT_STATUS_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
53
+ updateProjectV2ItemFieldValue(
54
+ input: {
55
+ projectId: $projectId
56
+ itemId: $itemId
57
+ fieldId: $fieldId
58
+ value: { singleSelectOptionId: $optionId }
59
+ }
60
+ ) {
61
+ projectV2Item { id }
62
+ }
63
+ }`;
64
+
8
65
  /**
9
66
  * @param {string} issueUrl
10
67
  * @returns {{ owner: string, repo: string, number: number }}
@@ -18,6 +75,272 @@ export function parseIssueUrl(issueUrl) {
18
75
  return { owner, repo, number: Number(number) };
19
76
  }
20
77
 
78
+ /**
79
+ * Owner/repo from an origin remote (HTTPS or SSH).
80
+ * @param {string | null | undefined} remoteUrl
81
+ * @returns {{ owner: string, repo: string } | null}
82
+ */
83
+ export function parseGithubRepoFromRemote(remoteUrl) {
84
+ const raw = String(remoteUrl || '').trim().replace(/\/+$/, '');
85
+ if (!raw) return null;
86
+ const match = raw.match(REPO_FROM_REMOTE_RE);
87
+ if (!match) return null;
88
+ const owner = match[1];
89
+ const repo = match[2];
90
+ if (!owner || !repo) return null;
91
+ return { owner, repo };
92
+ }
93
+
94
+ /**
95
+ * @param {{
96
+ * query: string,
97
+ * variables?: Record<string, string | number | boolean>,
98
+ * cwd: string,
99
+ * runGh?: typeof execFileAsync,
100
+ * }} params
101
+ */
102
+ async function ghGraphql({ query, variables = {}, cwd, runGh = execFileAsync }) {
103
+ const args = ['api', 'graphql', '-f', `query=${query}`];
104
+ for (const [key, value] of Object.entries(variables)) {
105
+ if (typeof value === 'number' || typeof value === 'boolean') {
106
+ args.push('-F', `${key}=${value}`);
107
+ } else {
108
+ args.push('-f', `${key}=${value}`);
109
+ }
110
+ }
111
+ const { stdout } = await runGh('gh', args, { cwd });
112
+ const payload = JSON.parse(stdout);
113
+ if (Array.isArray(payload.errors) && payload.errors.length > 0) {
114
+ throw new Error(payload.errors.map((e) => e.message || String(e)).join('; '));
115
+ }
116
+ return payload.data;
117
+ }
118
+
119
+ /**
120
+ * @param {unknown} data
121
+ * @returns {Array<{
122
+ * name: string,
123
+ * id?: string,
124
+ * projectId?: string,
125
+ * projectTitle?: string,
126
+ * fieldId?: string,
127
+ * }>}
128
+ */
129
+ export function extractProjectStatusOptions(data) {
130
+ const nodes = data?.repository?.projectsV2?.nodes;
131
+ const projects = Array.isArray(nodes) ? nodes : [];
132
+ /** @type {Array<{ name: string, id?: string, projectId?: string, projectTitle?: string, fieldId?: string }>} */
133
+ const out = [];
134
+ const seen = new Set();
135
+ for (const project of projects) {
136
+ if (!project || typeof project !== 'object') continue;
137
+ const projectId = project.id != null ? String(project.id) : '';
138
+ const projectTitle = String(project.title || '').trim();
139
+ const fields = Array.isArray(project.fields?.nodes) ? project.fields.nodes : [];
140
+ for (const field of fields) {
141
+ if (!field || typeof field !== 'object') continue;
142
+ const fieldName = String(field.name || '').trim();
143
+ if (!/status/i.test(fieldName)) continue;
144
+ const fieldId = field.id != null ? String(field.id) : '';
145
+ const options = Array.isArray(field.options) ? field.options : [];
146
+ for (const opt of options) {
147
+ const name = String(opt?.name || '').trim();
148
+ if (!name) continue;
149
+ const key = name.toLowerCase();
150
+ if (seen.has(key)) continue;
151
+ seen.add(key);
152
+ out.push({
153
+ name,
154
+ ...(opt?.id != null ? { id: String(opt.id) } : {}),
155
+ ...(projectId ? { projectId } : {}),
156
+ ...(projectTitle ? { projectTitle } : {}),
157
+ ...(fieldId ? { fieldId } : {}),
158
+ });
159
+ }
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /**
166
+ * @param {{
167
+ * cwd: string,
168
+ * originUrl?: string | null,
169
+ * runGh?: typeof execFileAsync,
170
+ * }} opts
171
+ * @returns {Promise<{
172
+ * ok: true,
173
+ * source: 'project' | 'labels',
174
+ * statuses: Array<{ name: string, id?: string, projectId?: string, projectTitle?: string, fieldId?: string }>,
175
+ * } | {
176
+ * ok: false,
177
+ * error: string,
178
+ * statuses: [],
179
+ * }>}
180
+ */
181
+ export async function listGithubIssueStatuses(opts) {
182
+ const runGh = opts.runGh || execFileAsync;
183
+ const cwd = opts.cwd;
184
+ const parsed = parseGithubRepoFromRemote(opts.originUrl);
185
+ try {
186
+ if (parsed) {
187
+ try {
188
+ const data = await ghGraphql({
189
+ query: PROJECT_STATUS_FIELDS_QUERY,
190
+ variables: { owner: parsed.owner, name: parsed.repo },
191
+ cwd,
192
+ runGh,
193
+ });
194
+ const statuses = extractProjectStatusOptions(data);
195
+ if (statuses.length > 0) {
196
+ return { ok: true, source: 'project', statuses };
197
+ }
198
+ } catch {
199
+ // Projects v2 may be disabled or unauthorized — try labels.
200
+ }
201
+ }
202
+
203
+ const { stdout } = await runGh(
204
+ 'gh',
205
+ ['label', 'list', '--limit', '100', '--json', 'name'],
206
+ { cwd }
207
+ );
208
+ const labels = JSON.parse(stdout);
209
+ const list = Array.isArray(labels) ? labels : [];
210
+ const statuses = [];
211
+ const seen = new Set();
212
+ for (const label of list) {
213
+ const name = String(label?.name || '').trim();
214
+ if (!name) continue;
215
+ const key = name.toLowerCase();
216
+ if (seen.has(key)) continue;
217
+ seen.add(key);
218
+ statuses.push({ name });
219
+ }
220
+ if (statuses.length > 0) {
221
+ return { ok: true, source: 'labels', statuses };
222
+ }
223
+ return {
224
+ ok: false,
225
+ error: parsed
226
+ ? 'No GitHub Project Status options or labels found for this repo.'
227
+ : 'Could not parse origin remote as GitHub owner/repo, and no labels were found.',
228
+ statuses: [],
229
+ };
230
+ } catch (err) {
231
+ return {
232
+ ok: false,
233
+ error: err instanceof Error ? err.message : String(err),
234
+ statuses: [],
235
+ };
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Set a GitHub Projects v2 Status field on the issue (add to the first matching
241
+ * project if needed). Returns ok:false when no Status field matches.
242
+ * @param {{
243
+ * issueNumber: number | string,
244
+ * statusName: string,
245
+ * cwd: string,
246
+ * originUrl?: string | null,
247
+ * runGh?: typeof execFileAsync,
248
+ * }} opts
249
+ * @returns {Promise<
250
+ * | { ok: true, statusName: string, projectTitle?: string }
251
+ * | { ok: false, reason: 'no_repo' | 'no_match' | 'error', error: string }
252
+ * >}
253
+ */
254
+ export async function setGithubIssueProjectStatus(opts) {
255
+ const runGh = opts.runGh || execFileAsync;
256
+ const cwd = opts.cwd;
257
+ const want = String(opts.statusName || '').trim();
258
+ const n = Number(opts.issueNumber);
259
+ if (!want) {
260
+ return { ok: false, reason: 'no_match', error: 'Status name is required' };
261
+ }
262
+ if (!Number.isInteger(n) || n <= 0) {
263
+ return { ok: false, reason: 'error', error: `Invalid GitHub issue number: ${opts.issueNumber}` };
264
+ }
265
+ const parsed = parseGithubRepoFromRemote(opts.originUrl);
266
+ if (!parsed) {
267
+ return { ok: false, reason: 'no_repo', error: 'Could not parse GitHub owner/repo from origin remote' };
268
+ }
269
+
270
+ try {
271
+ const fieldsData = await ghGraphql({
272
+ query: PROJECT_STATUS_FIELDS_QUERY,
273
+ variables: { owner: parsed.owner, name: parsed.repo },
274
+ cwd,
275
+ runGh,
276
+ });
277
+ const options = extractProjectStatusOptions(fieldsData);
278
+ const match = options.find((s) => s.name.toLowerCase() === want.toLowerCase());
279
+ if (!match?.projectId || !match.fieldId || !match.id) {
280
+ return {
281
+ ok: false,
282
+ reason: 'no_match',
283
+ error: `No GitHub Project Status option named "${want}"`,
284
+ };
285
+ }
286
+
287
+ const issueData = await ghGraphql({
288
+ query: ISSUE_PROJECT_ITEMS_QUERY,
289
+ variables: { owner: parsed.owner, name: parsed.repo, number: n },
290
+ cwd,
291
+ runGh,
292
+ });
293
+ const issue = issueData?.repository?.issue;
294
+ if (!issue?.id) {
295
+ return { ok: false, reason: 'error', error: `GitHub issue #${n} not found` };
296
+ }
297
+
298
+ const items = Array.isArray(issue.projectItems?.nodes) ? issue.projectItems.nodes : [];
299
+ let itemId = '';
300
+ for (const item of items) {
301
+ if (item?.project?.id === match.projectId && item.id) {
302
+ itemId = String(item.id);
303
+ break;
304
+ }
305
+ }
306
+ if (!itemId) {
307
+ const added = await ghGraphql({
308
+ query: ADD_PROJECT_ITEM_MUTATION,
309
+ variables: { projectId: match.projectId, contentId: String(issue.id) },
310
+ cwd,
311
+ runGh,
312
+ });
313
+ itemId = String(added?.addProjectV2ItemById?.item?.id || '');
314
+ }
315
+ if (!itemId) {
316
+ return { ok: false, reason: 'error', error: `Could not add issue #${n} to GitHub Project` };
317
+ }
318
+
319
+ await ghGraphql({
320
+ query: SET_PROJECT_STATUS_MUTATION,
321
+ variables: {
322
+ projectId: match.projectId,
323
+ itemId,
324
+ fieldId: match.fieldId,
325
+ optionId: match.id,
326
+ },
327
+ cwd,
328
+ runGh,
329
+ });
330
+ return {
331
+ ok: true,
332
+ statusName: match.name,
333
+ projectTitle: match.projectTitle,
334
+ };
335
+ } catch (err) {
336
+ return {
337
+ ok: false,
338
+ reason: 'error',
339
+ error: err instanceof Error ? err.message : String(err),
340
+ };
341
+ }
342
+ }
343
+
21
344
  /**
22
345
  * Fetch issue title/body/labels via `gh` for branch naming and type detection.
23
346
  * @param {string} issueUrl
@@ -70,6 +393,122 @@ export function buildPrCreateArgs({ title, body, baseBranch, branchName, draft =
70
393
  return args;
71
394
  }
72
395
 
396
+ /**
397
+ * @typedef {{
398
+ * ok: true,
399
+ * url: string,
400
+ * title: string,
401
+ * number: number,
402
+ * state: string,
403
+ * isDraft: boolean,
404
+ * reviewDecision: string | null,
405
+ * mergeStateStatus: string | null,
406
+ * closedAt: string | null,
407
+ * mergedAt: string | null,
408
+ * author: string | null,
409
+ * requestedReviewers: string[],
410
+ * latestReviews: Array<{
411
+ * author: string | null,
412
+ * state: string,
413
+ * body: string,
414
+ * submittedAt: string | null,
415
+ * }>,
416
+ * }} GithubPrStatusOk
417
+ *
418
+ * @typedef {{
419
+ * ok: false,
420
+ * error: string,
421
+ * }} GithubPrStatusError
422
+ *
423
+ * @typedef {GithubPrStatusOk | GithubPrStatusError} GithubPrStatusResult
424
+ */
425
+
426
+ /**
427
+ * @param {unknown} user
428
+ * @returns {string | null}
429
+ */
430
+ function userName(user) {
431
+ if (!user || typeof user !== 'object') return null;
432
+ const name = String(user.login || user.name || user.displayName || '').trim();
433
+ return name || null;
434
+ }
435
+
436
+ /**
437
+ * @param {unknown} review
438
+ * @returns {{ author: string | null, state: string, body: string, submittedAt: string | null } | null}
439
+ */
440
+ function normalizeReview(review) {
441
+ if (!review || typeof review !== 'object') return null;
442
+ const state = String(review.state || '').trim().toUpperCase();
443
+ const body = String(review.body || '').trim();
444
+ const submittedAt = String(review.submittedAt || review.createdAt || review.updatedAt || '').trim();
445
+ return {
446
+ author: userName(review.author),
447
+ state: state || 'UNKNOWN',
448
+ body,
449
+ submittedAt: submittedAt || null,
450
+ };
451
+ }
452
+
453
+ /**
454
+ * Current GitHub PR review state + summary via `gh pr view`.
455
+ * @param {{
456
+ * prUrl: string,
457
+ * cwd: string,
458
+ * runGh?: typeof execFileAsync,
459
+ * }} opts
460
+ * @returns {Promise<GithubPrStatusResult>}
461
+ */
462
+ export async function fetchGithubPrStatus({ prUrl, cwd, runGh = execFileAsync }) {
463
+ const url = String(prUrl || '').trim();
464
+ if (!url) {
465
+ return { ok: false, error: 'PR URL is required' };
466
+ }
467
+ try {
468
+ const { stdout } = await runGh(
469
+ 'gh',
470
+ [
471
+ 'pr',
472
+ 'view',
473
+ url,
474
+ '--json',
475
+ 'url,title,number,state,isDraft,reviewDecision,mergeStateStatus,closedAt,mergedAt,author,reviewRequests,latestReviews',
476
+ ],
477
+ { cwd }
478
+ );
479
+ const raw = JSON.parse(stdout);
480
+ const latestReviews = Array.isArray(raw.latestReviews)
481
+ ? raw.latestReviews.map(normalizeReview).filter(Boolean)
482
+ : [];
483
+ const requestedReviewers = Array.isArray(raw.reviewRequests || raw.requestedReviewers)
484
+ ? (raw.reviewRequests || raw.requestedReviewers).map((item) => {
485
+ if (item && typeof item === 'object' && 'requestedReviewer' in item) {
486
+ return userName(item.requestedReviewer);
487
+ }
488
+ return userName(item);
489
+ }).filter(Boolean)
490
+ : [];
491
+ return {
492
+ ok: true,
493
+ url: String(raw.url || url),
494
+ title: String(raw.title || '').trim(),
495
+ number: Number(raw.number || 0),
496
+ state: String(raw.state || '').trim().toUpperCase() || 'UNKNOWN',
497
+ isDraft: Boolean(raw.isDraft),
498
+ reviewDecision: String(raw.reviewDecision || '').trim().toUpperCase() || null,
499
+ mergeStateStatus: String(raw.mergeStateStatus || '').trim().toUpperCase() || null,
500
+ closedAt: String(raw.closedAt || '').trim() || null,
501
+ mergedAt: String(raw.mergedAt || '').trim() || null,
502
+ author: userName(raw.author),
503
+ requestedReviewers,
504
+ latestReviews,
505
+ };
506
+ } catch (err) {
507
+ const message = err.stderr?.toString() || err.message || String(err);
508
+ return { ok: false, error: `Failed to fetch PR status for ${url}: ${message}` };
509
+ }
510
+ }
511
+
73
512
  /**
74
513
  * Create a GitHub PR (draft or ready for review).
75
514
  * @param {{
@@ -119,7 +558,6 @@ export async function createPr({
119
558
  throw err;
120
559
  }
121
560
  }
122
-
123
561
  /**
124
562
  * Add a label to a GitHub issue via `gh`.
125
563
  * @param {number | string} issueNumber
package/src/jira.js CHANGED
@@ -316,6 +316,184 @@ export async function fetchJiraIssue(key, creds) {
316
316
  };
317
317
  }
318
318
 
319
+ /**
320
+ * @param {{
321
+ * baseUrl: string,
322
+ * email: string,
323
+ * apiToken: string,
324
+ * fetchFn?: typeof fetch,
325
+ * }} creds
326
+ * @param {string} path
327
+ */
328
+ async function jiraGetJson(creds, path) {
329
+ const fetchFn = creds.fetchFn || fetch;
330
+ const base = normalizeJiraBaseUrl(creds.baseUrl);
331
+ const url = `${base}${path.startsWith('/') ? path : `/${path}`}`;
332
+ const res = await fetchFn(url, {
333
+ method: 'GET',
334
+ headers: {
335
+ Authorization: jiraAuthHeader({
336
+ email: creds.email,
337
+ apiToken: creds.apiToken,
338
+ }),
339
+ Accept: 'application/json',
340
+ },
341
+ });
342
+ const text = await res.text().catch(() => '');
343
+ if (!res.ok) {
344
+ const detail = text ? `: ${text.slice(0, 200)}` : '';
345
+ const err = new Error(`Jira GET ${path} failed (${res.status} ${res.statusText})${detail}`);
346
+ err.status = res.status;
347
+ throw err;
348
+ }
349
+ if (!text) return {};
350
+ try {
351
+ return JSON.parse(text);
352
+ } catch {
353
+ throw new Error(`Jira GET ${path} returned invalid JSON`);
354
+ }
355
+ }
356
+
357
+ /**
358
+ * @param {unknown} raw
359
+ * @returns {Map<string, string>}
360
+ */
361
+ export function jiraStatusIdNameMap(raw) {
362
+ const list = Array.isArray(raw)
363
+ ? raw
364
+ : raw && typeof raw === 'object' && Array.isArray(/** @type {{ values?: unknown }} */ (raw).values)
365
+ ? /** @type {{ values: unknown[] }} */ (raw).values
366
+ : [];
367
+ /** @type {Map<string, string>} */
368
+ const map = new Map();
369
+ for (const item of list) {
370
+ if (!item || typeof item !== 'object') continue;
371
+ const obj = /** @type {{ id?: unknown, name?: unknown }} */ (item);
372
+ const id = obj.id != null ? String(obj.id) : '';
373
+ const name = String(obj.name || '').trim();
374
+ if (id && name) map.set(id, name);
375
+ }
376
+ return map;
377
+ }
378
+
379
+ /**
380
+ * @param {unknown} columns
381
+ * @param {Map<string, string>} statusNames
382
+ * @param {string} [boardName]
383
+ * @returns {Array<{ name: string, id?: string, column?: string, board?: string }>}
384
+ */
385
+ export function statusesFromBoardColumns(columns, statusNames, boardName) {
386
+ const cols = Array.isArray(columns) ? columns : [];
387
+ /** @type {Array<{ name: string, id?: string, column?: string, board?: string }>} */
388
+ const out = [];
389
+ const seen = new Set();
390
+ for (const col of cols) {
391
+ if (!col || typeof col !== 'object') continue;
392
+ const colObj = /** @type {{ name?: unknown, statuses?: unknown }} */ (col);
393
+ const column = String(colObj.name || '').trim();
394
+ const sts = Array.isArray(colObj.statuses) ? colObj.statuses : [];
395
+ for (const st of sts) {
396
+ if (!st || typeof st !== 'object') continue;
397
+ const obj = /** @type {{ id?: unknown, name?: unknown }} */ (st);
398
+ const id = obj.id != null ? String(obj.id) : '';
399
+ const name = String(obj.name || '').trim() || (id ? statusNames.get(id) || '' : '');
400
+ if (!name) continue;
401
+ const key = name.toLowerCase();
402
+ if (seen.has(key)) continue;
403
+ seen.add(key);
404
+ out.push({
405
+ name,
406
+ ...(id ? { id } : {}),
407
+ ...(column ? { column } : {}),
408
+ ...(boardName ? { board: boardName } : {}),
409
+ });
410
+ }
411
+ }
412
+ return out;
413
+ }
414
+
415
+ /**
416
+ * Live Jira statuses for the Rules dropdown.
417
+ * Prefers Agile board column statuses (kanban/scrum); falls back to /rest/api/3/status.
418
+ * @param {{
419
+ * baseUrl: string,
420
+ * email: string,
421
+ * apiToken: string,
422
+ * fetchFn?: typeof fetch,
423
+ * }} creds
424
+ * @returns {Promise<{
425
+ * ok: true,
426
+ * source: 'board' | 'status',
427
+ * statuses: Array<{ name: string, id?: string, column?: string, board?: string }>,
428
+ * } | {
429
+ * ok: false,
430
+ * error: string,
431
+ * statuses: [],
432
+ * }>}
433
+ */
434
+ export async function listJiraBoardStatuses(creds) {
435
+ try {
436
+ let statusMap = new Map();
437
+ try {
438
+ statusMap = jiraStatusIdNameMap(await jiraGetJson(creds, '/rest/api/3/status'));
439
+ } catch {
440
+ // Board config sometimes includes status names without this lookup.
441
+ }
442
+
443
+ try {
444
+ const boards = await jiraGetJson(creds, '/rest/agile/1.0/board?maxResults=50');
445
+ const values = Array.isArray(boards?.values) ? boards.values : [];
446
+ /** @type {Array<{ name: string, id?: string, column?: string, board?: string }>} */
447
+ const statuses = [];
448
+ const seen = new Set();
449
+ for (const board of values) {
450
+ if (!board || board.id == null) continue;
451
+ const boardName = String(board.name || '').trim();
452
+ try {
453
+ const cfg = await jiraGetJson(
454
+ creds,
455
+ `/rest/agile/1.0/board/${encodeURIComponent(String(board.id))}/configuration`
456
+ );
457
+ const fromBoard = statusesFromBoardColumns(
458
+ cfg?.columnConfig?.columns,
459
+ statusMap,
460
+ boardName
461
+ );
462
+ for (const st of fromBoard) {
463
+ const key = st.name.toLowerCase();
464
+ if (seen.has(key)) continue;
465
+ seen.add(key);
466
+ statuses.push(st);
467
+ }
468
+ } catch {
469
+ // Skip boards the token cannot read.
470
+ }
471
+ }
472
+ if (statuses.length > 0) {
473
+ return { ok: true, source: 'board', statuses };
474
+ }
475
+ } catch {
476
+ // Agile API missing / 403 — use global status catalog.
477
+ }
478
+
479
+ const statuses = [...statusMap.entries()].map(([id, name]) => ({ name, id }));
480
+ if (statuses.length > 0) {
481
+ return { ok: true, source: 'status', statuses };
482
+ }
483
+ return {
484
+ ok: false,
485
+ error: 'No Jira statuses found. Check board access or Jira Software permissions.',
486
+ statuses: [],
487
+ };
488
+ } catch (err) {
489
+ return {
490
+ ok: false,
491
+ error: err instanceof Error ? err.message : String(err),
492
+ statuses: [],
493
+ };
494
+ }
495
+ }
496
+
319
497
  /**
320
498
  * Find a transition whose target status name matches (case-insensitive).
321
499
  * @param {Array<{ id?: string, name?: string, to?: { name?: string } }>} transitions