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/.acdev/.env.example +13 -0
- package/README.md +231 -0
- package/bin/acdev.js +138 -0
- package/package.json +56 -0
- package/public/acdev_wordmark_logo.svg +10 -0
- package/public/app.js +3291 -0
- package/public/index.html +449 -0
- package/public/styles.css +1870 -0
- package/src/afterPrRules.js +116 -0
- package/src/agent.js +669 -0
- package/src/claude-auth.js +81 -0
- package/src/config.js +426 -0
- package/src/env.js +98 -0
- package/src/gh-auth.js +41 -0
- package/src/git.js +867 -0
- package/src/github.js +179 -0
- package/src/jira.js +418 -0
- package/src/paths.js +128 -0
- package/src/server.js +988 -0
- package/src/store.js +135 -0
- package/src/urls.js +16 -0
- package/src/usage.js +122 -0
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
|
+
}
|