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/README.md +5 -3
- package/package.json +1 -1
- package/public/app.js +273 -18
- package/public/index.html +14 -7
- package/public/styles.css +37 -0
- package/src/afterPrRules.js +19 -3
- package/src/config.js +1 -1
- package/src/github.js +439 -1
- package/src/jira.js +178 -0
- package/src/server.js +104 -7
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
|