acdev 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/github.js ADDED
@@ -0,0 +1,179 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ const ISSUE_URL_RE = /github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/;
7
+
8
+ /**
9
+ * @param {string} issueUrl
10
+ * @returns {{ owner: string, repo: string, number: number }}
11
+ */
12
+ export function parseIssueUrl(issueUrl) {
13
+ const match = issueUrl.match(ISSUE_URL_RE);
14
+ if (!match) {
15
+ throw new Error(`Not a recognizable GitHub issue URL: ${issueUrl}`);
16
+ }
17
+ const [, owner, repo, number] = match;
18
+ return { owner, repo, number: Number(number) };
19
+ }
20
+
21
+ /**
22
+ * Fetch issue title/body/labels via `gh` for branch naming and type detection.
23
+ * @param {string} issueUrl
24
+ * @param {string} cwd
25
+ * @returns {Promise<{ title: string, body: string, labels: string[] }>}
26
+ */
27
+ export async function fetchIssueDetails(issueUrl, cwd) {
28
+ const { stdout } = await execFileAsync(
29
+ 'gh',
30
+ ['issue', 'view', issueUrl, '--json', 'title,body,labels'],
31
+ { cwd }
32
+ );
33
+ const data = JSON.parse(stdout);
34
+ const labels = Array.isArray(data.labels)
35
+ ? data.labels.map((l) => (typeof l === 'string' ? l : l?.name || '')).filter(Boolean)
36
+ : [];
37
+ return {
38
+ title: data.title || '',
39
+ body: data.body || '',
40
+ labels,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Build `gh pr create` argv (without the leading `gh`).
46
+ * @param {{
47
+ * title: string,
48
+ * body: string,
49
+ * baseBranch: string,
50
+ * branchName: string,
51
+ * draft?: boolean,
52
+ * }} params
53
+ * @returns {string[]}
54
+ */
55
+ export function buildPrCreateArgs({ title, body, baseBranch, branchName, draft = true }) {
56
+ const args = ['pr', 'create'];
57
+ if (draft) {
58
+ args.push('--draft');
59
+ }
60
+ args.push(
61
+ '--title',
62
+ title,
63
+ '--body',
64
+ body,
65
+ '--base',
66
+ baseBranch,
67
+ '--head',
68
+ branchName
69
+ );
70
+ return args;
71
+ }
72
+
73
+ /**
74
+ * Create a GitHub PR (draft or ready for review).
75
+ * @param {{
76
+ * worktreePath: string,
77
+ * branchName: string,
78
+ * baseBranch: string,
79
+ * title: string,
80
+ * body: string,
81
+ * draft?: boolean,
82
+ * }} params
83
+ * @returns {Promise<string>} PR URL
84
+ */
85
+ export async function createPr({
86
+ worktreePath,
87
+ branchName,
88
+ baseBranch,
89
+ title,
90
+ body,
91
+ draft = true,
92
+ }) {
93
+ try {
94
+ const { stdout } = await execFileAsync(
95
+ 'gh',
96
+ buildPrCreateArgs({ title, body, baseBranch, branchName, draft }),
97
+ { cwd: worktreePath }
98
+ );
99
+ const url = stdout.trim();
100
+ if (url.startsWith('http')) {
101
+ return url;
102
+ }
103
+ const { stdout: jsonOut } = await execFileAsync(
104
+ 'gh',
105
+ ['pr', 'view', branchName, '--json', 'url'],
106
+ { cwd: worktreePath }
107
+ );
108
+ return JSON.parse(jsonOut).url;
109
+ } catch (err) {
110
+ const message = err.stderr?.toString() || err.message || String(err);
111
+ if (message.includes('already exists')) {
112
+ const { stdout } = await execFileAsync(
113
+ 'gh',
114
+ ['pr', 'view', branchName, '--json', 'url'],
115
+ { cwd: worktreePath }
116
+ );
117
+ return JSON.parse(stdout).url;
118
+ }
119
+ throw err;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Add a label to a GitHub issue via `gh`.
125
+ * @param {number | string} issueNumber
126
+ * @param {string} label
127
+ * @param {string} cwd
128
+ * @returns {Promise<void>}
129
+ */
130
+ export async function addIssueLabel(issueNumber, label, cwd) {
131
+ const n = String(issueNumber);
132
+ const name = String(label || '').trim();
133
+ if (!n || !/^\d+$/.test(n)) {
134
+ throw new Error(`Invalid GitHub issue number: ${issueNumber}`);
135
+ }
136
+ if (!name) {
137
+ throw new Error('Label name is required');
138
+ }
139
+ try {
140
+ await execFileAsync('gh', ['issue', 'edit', n, '--add-label', name], { cwd });
141
+ } catch (err) {
142
+ const message = err.stderr?.toString() || err.message || String(err);
143
+ throw new Error(`Failed to add label "${name}" to issue #${n}: ${message}`);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Close a GitHub issue via `gh`.
149
+ * @param {number | string} issueNumber
150
+ * @param {string} cwd
151
+ * @returns {Promise<void>}
152
+ */
153
+ export async function closeIssue(issueNumber, cwd) {
154
+ const n = String(issueNumber);
155
+ if (!n || !/^\d+$/.test(n)) {
156
+ throw new Error(`Invalid GitHub issue number: ${issueNumber}`);
157
+ }
158
+ try {
159
+ await execFileAsync('gh', ['issue', 'close', n], { cwd });
160
+ } catch (err) {
161
+ const message = err.stderr?.toString() || err.message || String(err);
162
+ throw new Error(`Failed to close issue #${n}: ${message}`);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * @deprecated Prefer createPr({ draft: true })
168
+ * @param {{
169
+ * worktreePath: string,
170
+ * branchName: string,
171
+ * baseBranch: string,
172
+ * title: string,
173
+ * body: string,
174
+ * }} params
175
+ * @returns {Promise<string>} PR URL
176
+ */
177
+ export async function createDraftPr(params) {
178
+ return createPr({ ...params, draft: true });
179
+ }
package/src/jira.js ADDED
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Jira Cloud REST API v3 helpers — parse refs, fetch issues, test connection,
3
+ * map issuetype → feat/fix.
4
+ */
5
+
6
+ const JIRA_KEY_RE = /^([A-Z][A-Z0-9]+)-(\d+)$/i;
7
+ const JIRA_BROWSE_RE =
8
+ /^(?:https?:\/\/)?([^/]+\.atlassian\.net)\/browse\/([A-Z][A-Z0-9]+-\d+)(?:[/?#]|$)/i;
9
+ const JIRA_KEY_IN_TEXT_RE = /\b([A-Z][A-Z0-9]+-\d+)\b/i;
10
+
11
+ /**
12
+ * @param {string} baseUrl
13
+ * @returns {string} normalized origin without trailing slash
14
+ */
15
+ export function normalizeJiraBaseUrl(baseUrl) {
16
+ const raw = String(baseUrl || '').trim();
17
+ if (!raw) {
18
+ throw new Error('Jira base URL is required');
19
+ }
20
+ let withScheme = raw;
21
+ if (!/^https?:\/\//i.test(withScheme)) {
22
+ withScheme = `https://${withScheme}`;
23
+ }
24
+ let parsed;
25
+ try {
26
+ parsed = new URL(withScheme);
27
+ } catch {
28
+ throw new Error(`Invalid Jira base URL: ${baseUrl}`);
29
+ }
30
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
31
+ throw new Error(`Invalid Jira base URL: ${baseUrl}`);
32
+ }
33
+ return `${parsed.protocol}//${parsed.host}`;
34
+ }
35
+
36
+ /**
37
+ * Parse a Jira issue key or browse URL.
38
+ * Accepts: `PROJ-123`, `https://xxx.atlassian.net/browse/PROJ-123`
39
+ * @param {string} input
40
+ * @param {{ baseUrl?: string }} [opts]
41
+ * @returns {{ key: string, browseUrl: string, host?: string }}
42
+ */
43
+ export function parseJiraIssueRef(input, opts = {}) {
44
+ const raw = String(input || '').trim();
45
+ if (!raw) {
46
+ throw new Error('Empty Jira issue reference');
47
+ }
48
+
49
+ const browse = raw.match(JIRA_BROWSE_RE);
50
+ if (browse) {
51
+ const host = browse[1].toLowerCase();
52
+ const key = browse[2].toUpperCase();
53
+ return {
54
+ key,
55
+ host,
56
+ browseUrl: `https://${host}/browse/${key}`,
57
+ };
58
+ }
59
+
60
+ const keyOnly = raw.match(JIRA_KEY_RE);
61
+ if (keyOnly) {
62
+ const key = `${keyOnly[1].toUpperCase()}-${keyOnly[2]}`;
63
+ let browseUrl = key;
64
+ if (opts.baseUrl) {
65
+ const base = normalizeJiraBaseUrl(opts.baseUrl);
66
+ browseUrl = `${base}/browse/${key}`;
67
+ }
68
+ return { key, browseUrl };
69
+ }
70
+
71
+ // Fallback: key embedded in free text / odd URL shapes
72
+ const embedded = raw.match(JIRA_KEY_IN_TEXT_RE);
73
+ if (embedded && !raw.includes('github.com')) {
74
+ const key = embedded[1].toUpperCase();
75
+ let browseUrl = key;
76
+ if (opts.baseUrl) {
77
+ const base = normalizeJiraBaseUrl(opts.baseUrl);
78
+ browseUrl = `${base}/browse/${key}`;
79
+ }
80
+ return { key, browseUrl };
81
+ }
82
+
83
+ throw new Error(`Not a recognizable Jira issue key or URL: ${input}`);
84
+ }
85
+
86
+ /**
87
+ * Map Jira issuetype name → branch type.
88
+ * Bug → fix; Story / Feature / Task / Improvement → feat; else fix (safer).
89
+ * @param {string | { name?: string } | null | undefined} issueType
90
+ * @returns {'feat' | 'fix'}
91
+ */
92
+ export function mapJiraIssueType(issueType) {
93
+ const name = (
94
+ typeof issueType === 'string' ? issueType : issueType?.name || ''
95
+ )
96
+ .trim()
97
+ .toLowerCase();
98
+
99
+ if (!name) return 'fix';
100
+ if (name === 'bug' || name === 'defect' || name.includes('bug')) return 'fix';
101
+
102
+ const featNames = new Set([
103
+ 'story',
104
+ 'feature',
105
+ 'task',
106
+ 'improvement',
107
+ 'enhancement',
108
+ 'new feature',
109
+ 'epic',
110
+ ]);
111
+ if (featNames.has(name)) return 'feat';
112
+ if (name.includes('feature') || name.includes('story') || name.includes('improvement')) {
113
+ return 'feat';
114
+ }
115
+ return 'fix';
116
+ }
117
+
118
+ /**
119
+ * Flatten Atlassian Document Format (ADF) to plain text.
120
+ * @param {unknown} node
121
+ * @returns {string}
122
+ */
123
+ export function adfToPlainText(node) {
124
+ if (node == null) return '';
125
+ if (typeof node === 'string') return node;
126
+ if (typeof node !== 'object') return String(node);
127
+
128
+ /** @type {Record<string, unknown>} */
129
+ const n = /** @type {Record<string, unknown>} */ (node);
130
+
131
+ if (Array.isArray(n)) {
132
+ return n.map((child) => adfToPlainText(child)).join('');
133
+ }
134
+
135
+ const type = n.type;
136
+ const content = Array.isArray(n.content) ? n.content : [];
137
+ const text = typeof n.text === 'string' ? n.text : '';
138
+
139
+ if (type === 'text') return text;
140
+
141
+ const inner = content.map((child) => adfToPlainText(child)).join('');
142
+
143
+ if (type === 'hardBreak') return '\n';
144
+ if (type === 'paragraph' || type === 'heading') return inner ? `${inner}\n\n` : '';
145
+ if (type === 'bulletList' || type === 'orderedList') return `${inner}\n`;
146
+ if (type === 'listItem') return `- ${inner.trim()}\n`;
147
+ if (type === 'codeBlock') return inner ? `\`\`\`\n${inner}\n\`\`\`\n\n` : '';
148
+ if (type === 'blockquote') return inner ? `> ${inner.trim()}\n\n` : '';
149
+
150
+ return inner || text;
151
+ }
152
+
153
+ /**
154
+ * @param {{ email: string, apiToken: string }} creds
155
+ * @returns {string}
156
+ */
157
+ export function jiraAuthHeader({ email, apiToken }) {
158
+ const token = Buffer.from(`${email}:${apiToken}`, 'utf8').toString('base64');
159
+ return `Basic ${token}`;
160
+ }
161
+
162
+ /**
163
+ * Resolve Jira credentials from env (and optional overrides).
164
+ * @param {{
165
+ * baseUrl?: string,
166
+ * email?: string,
167
+ * apiToken?: string,
168
+ * configBaseUrl?: string,
169
+ * }} [opts]
170
+ * @returns {{ baseUrl: string, email: string, apiToken: string } | { error: string }}
171
+ */
172
+ export function resolveJiraCredentials(opts = {}) {
173
+ const baseUrlRaw =
174
+ opts.baseUrl ||
175
+ process.env.JIRA_BASE_URL ||
176
+ opts.configBaseUrl ||
177
+ '';
178
+ const email = (opts.email || process.env.JIRA_EMAIL || '').trim();
179
+ const apiToken = (opts.apiToken || process.env.JIRA_API_TOKEN || '').trim();
180
+
181
+ if (!baseUrlRaw.trim()) {
182
+ return { error: 'Jira base URL is missing. Set JIRA_BASE_URL or configure it in Settings.' };
183
+ }
184
+ if (!email) {
185
+ return { error: 'Jira email is missing. Set JIRA_EMAIL in `.acdev/.env` or Settings.' };
186
+ }
187
+ if (!apiToken) {
188
+ return {
189
+ error: 'Jira API token is missing. Set JIRA_API_TOKEN in `.acdev/.env` or Settings.',
190
+ };
191
+ }
192
+
193
+ try {
194
+ return {
195
+ baseUrl: normalizeJiraBaseUrl(baseUrlRaw),
196
+ email,
197
+ apiToken,
198
+ };
199
+ } catch (err) {
200
+ return { error: err instanceof Error ? err.message : String(err) };
201
+ }
202
+ }
203
+
204
+ /**
205
+ * @param {{
206
+ * baseUrl: string,
207
+ * email: string,
208
+ * apiToken: string,
209
+ * fetchFn?: typeof fetch,
210
+ * }} params
211
+ * @returns {Promise<{ ok: true, displayName: string, accountId?: string, emailAddress?: string } | { ok: false, error: string }>}
212
+ */
213
+ export async function testJiraConnection({
214
+ baseUrl,
215
+ email,
216
+ apiToken,
217
+ fetchFn = fetch,
218
+ }) {
219
+ const url = `${normalizeJiraBaseUrl(baseUrl)}/rest/api/3/myself`;
220
+ try {
221
+ const res = await fetchFn(url, {
222
+ method: 'GET',
223
+ headers: {
224
+ Authorization: jiraAuthHeader({ email, apiToken }),
225
+ Accept: 'application/json',
226
+ },
227
+ });
228
+ if (!res.ok) {
229
+ const body = await res.text().catch(() => '');
230
+ const detail = body ? `: ${body.slice(0, 200)}` : '';
231
+ return {
232
+ ok: false,
233
+ error: `Jira connection failed (${res.status} ${res.statusText})${detail}`,
234
+ };
235
+ }
236
+ const data = await res.json();
237
+ return {
238
+ ok: true,
239
+ displayName: data.displayName || data.emailAddress || email,
240
+ accountId: data.accountId,
241
+ emailAddress: data.emailAddress,
242
+ };
243
+ } catch (err) {
244
+ return {
245
+ ok: false,
246
+ error: err instanceof Error ? err.message : String(err),
247
+ };
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Fetch a Jira issue for agent context + branch naming.
253
+ * @param {string} key
254
+ * @param {{
255
+ * baseUrl: string,
256
+ * email: string,
257
+ * apiToken: string,
258
+ * fetchFn?: typeof fetch,
259
+ * }} creds
260
+ * @returns {Promise<{
261
+ * key: string,
262
+ * summary: string,
263
+ * description: string,
264
+ * issueType: string,
265
+ * labels: string[],
266
+ * comments: Array<{ author: string, body: string, created?: string }>,
267
+ * browseUrl: string,
268
+ * }>}
269
+ */
270
+ export async function fetchJiraIssue(key, creds) {
271
+ const fetchFn = creds.fetchFn || fetch;
272
+ const base = normalizeJiraBaseUrl(creds.baseUrl);
273
+ const normalizedKey = String(key).toUpperCase();
274
+ const fields = ['summary', 'description', 'issuetype', 'labels', 'comment'].join(',');
275
+ const url = `${base}/rest/api/3/issue/${encodeURIComponent(normalizedKey)}?fields=${fields}`;
276
+
277
+ const res = await fetchFn(url, {
278
+ method: 'GET',
279
+ headers: {
280
+ Authorization: jiraAuthHeader({
281
+ email: creds.email,
282
+ apiToken: creds.apiToken,
283
+ }),
284
+ Accept: 'application/json',
285
+ },
286
+ });
287
+
288
+ if (!res.ok) {
289
+ const body = await res.text().catch(() => '');
290
+ const detail = body ? `: ${body.slice(0, 300)}` : '';
291
+ throw new Error(
292
+ `Failed to fetch Jira issue ${normalizedKey} (${res.status} ${res.statusText})${detail}`
293
+ );
294
+ }
295
+
296
+ const data = await res.json();
297
+ const f = data.fields || {};
298
+ const issueType =
299
+ typeof f.issuetype === 'string' ? f.issuetype : f.issuetype?.name || '';
300
+
301
+ const commentsRaw = f.comment?.comments || [];
302
+ const comments = commentsRaw.map((c) => ({
303
+ author: c.author?.displayName || c.author?.emailAddress || 'unknown',
304
+ body: adfToPlainText(c.body).trim(),
305
+ created: c.created,
306
+ }));
307
+
308
+ return {
309
+ key: data.key || normalizedKey,
310
+ summary: f.summary || '',
311
+ description: adfToPlainText(f.description).trim(),
312
+ issueType,
313
+ labels: Array.isArray(f.labels) ? f.labels.map(String) : [],
314
+ comments,
315
+ browseUrl: `${base}/browse/${data.key || normalizedKey}`,
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Find a transition whose target status name matches (case-insensitive).
321
+ * @param {Array<{ id?: string, name?: string, to?: { name?: string } }>} transitions
322
+ * @param {string} targetStatusName
323
+ * @returns {{ id: string, name?: string, to?: { name?: string } } | null}
324
+ */
325
+ export function findTransitionByStatusName(transitions, targetStatusName) {
326
+ const want = String(targetStatusName || '').trim().toLowerCase();
327
+ if (!want) return null;
328
+ const list = Array.isArray(transitions) ? transitions : [];
329
+ for (const t of list) {
330
+ if (!t || t.id == null) continue;
331
+ const toName = String(t.to?.name || '').trim().toLowerCase();
332
+ if (toName && toName === want) {
333
+ return /** @type {{ id: string, name?: string, to?: { name?: string } }} */ (t);
334
+ }
335
+ }
336
+ return null;
337
+ }
338
+
339
+ /**
340
+ * Transition a Jira issue to a status by target status name (best-effort helper).
341
+ * Lists available transitions, matches `to.name` case-insensitively, then POSTs the id.
342
+ * @param {string} key
343
+ * @param {string} targetStatusName
344
+ * @param {{
345
+ * baseUrl: string,
346
+ * email: string,
347
+ * apiToken: string,
348
+ * fetchFn?: typeof fetch,
349
+ * }} creds
350
+ * @returns {Promise<{
351
+ * ok: true,
352
+ * key: string,
353
+ * transitionId: string,
354
+ * statusName: string,
355
+ * }>}
356
+ */
357
+ export async function transitionJiraIssue(key, targetStatusName, creds) {
358
+ const fetchFn = creds.fetchFn || fetch;
359
+ const base = normalizeJiraBaseUrl(creds.baseUrl);
360
+ const normalizedKey = String(key).toUpperCase();
361
+ const want = String(targetStatusName || '').trim();
362
+ if (!want) {
363
+ throw new Error('Jira target status name is required');
364
+ }
365
+
366
+ const headers = {
367
+ Authorization: jiraAuthHeader({
368
+ email: creds.email,
369
+ apiToken: creds.apiToken,
370
+ }),
371
+ Accept: 'application/json',
372
+ 'Content-Type': 'application/json',
373
+ };
374
+
375
+ const listUrl = `${base}/rest/api/3/issue/${encodeURIComponent(normalizedKey)}/transitions`;
376
+ const listRes = await fetchFn(listUrl, { method: 'GET', headers });
377
+ if (!listRes.ok) {
378
+ const body = await listRes.text().catch(() => '');
379
+ const detail = body ? `: ${body.slice(0, 300)}` : '';
380
+ throw new Error(
381
+ `Failed to list Jira transitions for ${normalizedKey} (${listRes.status} ${listRes.statusText})${detail}`
382
+ );
383
+ }
384
+
385
+ const listData = await listRes.json();
386
+ const match = findTransitionByStatusName(listData.transitions || [], want);
387
+ if (!match) {
388
+ const available = (listData.transitions || [])
389
+ .map((t) => t?.to?.name)
390
+ .filter(Boolean)
391
+ .join(', ');
392
+ throw new Error(
393
+ `No Jira transition to status "${want}" for ${normalizedKey}` +
394
+ (available ? ` (available: ${available})` : '')
395
+ );
396
+ }
397
+
398
+ const postRes = await fetchFn(listUrl, {
399
+ method: 'POST',
400
+ headers,
401
+ body: JSON.stringify({ transition: { id: String(match.id) } }),
402
+ });
403
+
404
+ if (!postRes.ok) {
405
+ const body = await postRes.text().catch(() => '');
406
+ const detail = body ? `: ${body.slice(0, 300)}` : '';
407
+ throw new Error(
408
+ `Failed to transition Jira issue ${normalizedKey} to "${want}" (${postRes.status} ${postRes.statusText})${detail}`
409
+ );
410
+ }
411
+
412
+ return {
413
+ ok: true,
414
+ key: normalizedKey,
415
+ transitionId: String(match.id),
416
+ statusName: match.to?.name || want,
417
+ };
418
+ }