acdev 1.0.1 → 1.0.3
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 +8 -2
- package/README.md +59 -21
- package/bin/acdev.js +4 -24
- package/package.json +1 -1
- package/public/app.js +551 -129
- package/public/index.html +195 -63
- package/public/styles.css +81 -21
- package/src/afterPrRules.js +63 -8
- package/src/claude-auth.js +30 -0
- package/src/config.js +128 -88
- package/src/env.js +1 -1
- package/src/gh-auth.js +123 -6
- package/src/jira.js +171 -0
- package/src/models.js +366 -0
- package/src/server.js +44 -11
package/src/config.js
CHANGED
|
@@ -1,47 +1,58 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { checkClaudeAuth, publicClaudeAuthMethod } from './claude-auth.js';
|
|
3
4
|
import { maskSecret } from './env.js';
|
|
5
|
+
import { checkGhAuth, githubTokenFromEnv, originRemoteInfo } from './gh-auth.js';
|
|
4
6
|
import { normalizeJiraBaseUrl } from './jira.js';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_MODEL,
|
|
9
|
+
MODEL_OPTIONS,
|
|
10
|
+
isValidModelId,
|
|
11
|
+
} from './models.js';
|
|
5
12
|
import { dataDir } from './paths.js';
|
|
6
13
|
|
|
7
|
-
|
|
8
|
-
export const MODEL_OPTIONS = [
|
|
9
|
-
{ id: 'claude-sonnet-5', label: 'Sonnet 5' },
|
|
10
|
-
{ id: 'claude-opus-5', label: 'Opus 5' },
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
export const DEFAULT_MODEL = 'claude-sonnet-5';
|
|
14
|
+
export { DEFAULT_MODEL, MODEL_OPTIONS } from './models.js';
|
|
14
15
|
|
|
15
16
|
/** Tool names the agent may be granted via config. */
|
|
16
17
|
export const KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
|
|
17
18
|
|
|
18
19
|
export const TICKET_SOURCES = /** @type {const} */ (['github', 'jira']);
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Shared post-PR actions for Jira and GitHub Issues rules.
|
|
23
|
+
* - Jira `set_status` / `close_issue`: workflow transitions (close → Done-like status).
|
|
24
|
+
* - GitHub `set_status`: applies a label named after `targetStatus` (no board API).
|
|
25
|
+
* - Both `add_label`: add the configured label name.
|
|
26
|
+
*/
|
|
27
|
+
export const AFTER_PR_ACTIONS = /** @type {const} */ ([
|
|
22
28
|
'none',
|
|
29
|
+
'set_status',
|
|
23
30
|
'add_label',
|
|
24
31
|
'close_issue',
|
|
25
32
|
]);
|
|
26
33
|
|
|
27
|
-
|
|
34
|
+
/** @deprecated Prefer AFTER_PR_ACTIONS — same vocabulary for both sources. */
|
|
35
|
+
export const GITHUB_AFTER_PR_ACTIONS = AFTER_PR_ACTIONS;
|
|
36
|
+
|
|
28
37
|
const ALLOWED_TOOLS_SET = new Set(KNOWN_TOOLS);
|
|
29
38
|
const ALLOWED_TICKET_SOURCES = new Set(TICKET_SOURCES);
|
|
30
|
-
const
|
|
39
|
+
const ALLOWED_AFTER_PR_ACTIONS = new Set(AFTER_PR_ACTIONS);
|
|
40
|
+
|
|
41
|
+
/** @typedef {'none' | 'set_status' | 'add_label' | 'close_issue'} AfterPrAction */
|
|
42
|
+
|
|
43
|
+
const DEFAULT_AFTER_PR_OPENED = {
|
|
44
|
+
enabled: false,
|
|
45
|
+
action: /** @type {AfterPrAction} */ ('none'),
|
|
46
|
+
targetStatus: 'In Review',
|
|
47
|
+
label: '',
|
|
48
|
+
};
|
|
31
49
|
|
|
32
50
|
const DEFAULT_JIRA_RULES = {
|
|
33
|
-
afterPrOpened:
|
|
34
|
-
enabled: false,
|
|
35
|
-
targetStatus: 'In Review',
|
|
36
|
-
},
|
|
51
|
+
afterPrOpened: structuredClone(DEFAULT_AFTER_PR_OPENED),
|
|
37
52
|
};
|
|
38
53
|
|
|
39
54
|
const DEFAULT_GITHUB_RULES = {
|
|
40
|
-
afterPrOpened:
|
|
41
|
-
enabled: false,
|
|
42
|
-
action: /** @type {'none' | 'add_label' | 'close_issue'} */ ('none'),
|
|
43
|
-
label: '',
|
|
44
|
-
},
|
|
55
|
+
afterPrOpened: structuredClone(DEFAULT_AFTER_PR_OPENED),
|
|
45
56
|
};
|
|
46
57
|
|
|
47
58
|
const DEFAULTS = {
|
|
@@ -60,17 +71,19 @@ const DEFAULTS = {
|
|
|
60
71
|
};
|
|
61
72
|
|
|
62
73
|
/**
|
|
63
|
-
*
|
|
74
|
+
* @param {string} prefix e.g. jiraRules / githubRules
|
|
64
75
|
* @param {unknown} raw
|
|
65
|
-
* @param {{ throwOnInvalid?: boolean }} [opts]
|
|
76
|
+
* @param {{ throwOnInvalid?: boolean, migrateLegacyJira?: boolean }} [opts]
|
|
66
77
|
* @returns {typeof DEFAULT_JIRA_RULES}
|
|
67
78
|
*/
|
|
68
|
-
|
|
79
|
+
function normalizeAfterPrRules(prefix, raw, opts = {}) {
|
|
69
80
|
const throwOnInvalid = opts.throwOnInvalid === true;
|
|
70
|
-
const base =
|
|
81
|
+
const base = {
|
|
82
|
+
afterPrOpened: structuredClone(DEFAULT_AFTER_PR_OPENED),
|
|
83
|
+
};
|
|
71
84
|
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
72
85
|
if (throwOnInvalid && raw != null) {
|
|
73
|
-
throw new Error(
|
|
86
|
+
throw new Error(`${prefix} must be an object`);
|
|
74
87
|
}
|
|
75
88
|
return base;
|
|
76
89
|
}
|
|
@@ -82,25 +95,40 @@ export function normalizeJiraRules(raw, opts = {}) {
|
|
|
82
95
|
: {};
|
|
83
96
|
|
|
84
97
|
if (throwOnInvalid && obj.afterPrOpened != null && (typeof obj.afterPrOpened !== 'object' || Array.isArray(obj.afterPrOpened))) {
|
|
85
|
-
throw new Error(
|
|
98
|
+
throw new Error(`${prefix}.afterPrOpened must be an object`);
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
if (after.enabled !== undefined) {
|
|
89
102
|
if (typeof after.enabled !== 'boolean') {
|
|
90
|
-
if (throwOnInvalid) throw new Error(
|
|
103
|
+
if (throwOnInvalid) throw new Error(`${prefix}.afterPrOpened.enabled must be a boolean`);
|
|
91
104
|
} else {
|
|
92
105
|
base.afterPrOpened.enabled = after.enabled;
|
|
93
106
|
}
|
|
94
107
|
}
|
|
95
108
|
|
|
109
|
+
if (after.action !== undefined) {
|
|
110
|
+
if (typeof after.action !== 'string' || !ALLOWED_AFTER_PR_ACTIONS.has(after.action)) {
|
|
111
|
+
if (throwOnInvalid) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Invalid ${prefix}.afterPrOpened.action "${after.action}". Allowed: ${AFTER_PR_ACTIONS.join(', ')}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
base.afterPrOpened.action = /** @type {AfterPrAction} */ (after.action);
|
|
118
|
+
}
|
|
119
|
+
} else if (opts.migrateLegacyJira && base.afterPrOpened.enabled) {
|
|
120
|
+
// Legacy Jira configs only had enabled + targetStatus (implied move status).
|
|
121
|
+
base.afterPrOpened.action = 'set_status';
|
|
122
|
+
}
|
|
123
|
+
|
|
96
124
|
if (after.targetStatus !== undefined) {
|
|
97
125
|
if (typeof after.targetStatus !== 'string') {
|
|
98
|
-
if (throwOnInvalid) throw new Error(
|
|
126
|
+
if (throwOnInvalid) throw new Error(`${prefix}.afterPrOpened.targetStatus must be a string`);
|
|
99
127
|
} else {
|
|
100
128
|
const trimmed = after.targetStatus.trim();
|
|
101
129
|
if (!trimmed) {
|
|
102
130
|
if (throwOnInvalid) {
|
|
103
|
-
throw new Error(
|
|
131
|
+
throw new Error(`${prefix}.afterPrOpened.targetStatus must be a non-empty string`);
|
|
104
132
|
}
|
|
105
133
|
} else {
|
|
106
134
|
base.afterPrOpened.targetStatus = trimmed;
|
|
@@ -108,58 +136,9 @@ export function normalizeJiraRules(raw, opts = {}) {
|
|
|
108
136
|
}
|
|
109
137
|
}
|
|
110
138
|
|
|
111
|
-
return base;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Normalize / validate githubRules from config or PATCH body.
|
|
116
|
-
* @param {unknown} raw
|
|
117
|
-
* @param {{ throwOnInvalid?: boolean }} [opts]
|
|
118
|
-
* @returns {typeof DEFAULT_GITHUB_RULES}
|
|
119
|
-
*/
|
|
120
|
-
export function normalizeGithubRules(raw, opts = {}) {
|
|
121
|
-
const throwOnInvalid = opts.throwOnInvalid === true;
|
|
122
|
-
const base = structuredClone(DEFAULT_GITHUB_RULES);
|
|
123
|
-
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
124
|
-
if (throwOnInvalid && raw != null) {
|
|
125
|
-
throw new Error('githubRules must be an object');
|
|
126
|
-
}
|
|
127
|
-
return base;
|
|
128
|
-
}
|
|
129
|
-
/** @type {Record<string, unknown>} */
|
|
130
|
-
const obj = /** @type {Record<string, unknown>} */ (raw);
|
|
131
|
-
const after =
|
|
132
|
-
obj.afterPrOpened != null && typeof obj.afterPrOpened === 'object' && !Array.isArray(obj.afterPrOpened)
|
|
133
|
-
? /** @type {Record<string, unknown>} */ (obj.afterPrOpened)
|
|
134
|
-
: {};
|
|
135
|
-
|
|
136
|
-
if (throwOnInvalid && obj.afterPrOpened != null && (typeof obj.afterPrOpened !== 'object' || Array.isArray(obj.afterPrOpened))) {
|
|
137
|
-
throw new Error('githubRules.afterPrOpened must be an object');
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (after.enabled !== undefined) {
|
|
141
|
-
if (typeof after.enabled !== 'boolean') {
|
|
142
|
-
if (throwOnInvalid) throw new Error('githubRules.afterPrOpened.enabled must be a boolean');
|
|
143
|
-
} else {
|
|
144
|
-
base.afterPrOpened.enabled = after.enabled;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (after.action !== undefined) {
|
|
149
|
-
if (typeof after.action !== 'string' || !ALLOWED_GITHUB_AFTER_PR_ACTIONS.has(after.action)) {
|
|
150
|
-
if (throwOnInvalid) {
|
|
151
|
-
throw new Error(
|
|
152
|
-
`Invalid githubRules.afterPrOpened.action "${after.action}". Allowed: ${GITHUB_AFTER_PR_ACTIONS.join(', ')}`
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
} else {
|
|
156
|
-
base.afterPrOpened.action = /** @type {'none' | 'add_label' | 'close_issue'} */ (after.action);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
139
|
if (after.label !== undefined) {
|
|
161
140
|
if (typeof after.label !== 'string') {
|
|
162
|
-
if (throwOnInvalid) throw new Error(
|
|
141
|
+
if (throwOnInvalid) throw new Error(`${prefix}.afterPrOpened.label must be a string`);
|
|
163
142
|
} else {
|
|
164
143
|
base.afterPrOpened.label = after.label.trim();
|
|
165
144
|
}
|
|
@@ -171,12 +150,45 @@ export function normalizeGithubRules(raw, opts = {}) {
|
|
|
171
150
|
base.afterPrOpened.action === 'add_label' &&
|
|
172
151
|
!base.afterPrOpened.label
|
|
173
152
|
) {
|
|
174
|
-
throw new Error(
|
|
153
|
+
throw new Error(`${prefix}.afterPrOpened.label is required when action is add_label`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (
|
|
157
|
+
throwOnInvalid &&
|
|
158
|
+
base.afterPrOpened.enabled &&
|
|
159
|
+
base.afterPrOpened.action === 'set_status' &&
|
|
160
|
+
!base.afterPrOpened.targetStatus
|
|
161
|
+
) {
|
|
162
|
+
throw new Error(`${prefix}.afterPrOpened.targetStatus is required when action is set_status`);
|
|
175
163
|
}
|
|
176
164
|
|
|
177
165
|
return base;
|
|
178
166
|
}
|
|
179
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Normalize / validate jiraRules from config or PATCH body.
|
|
170
|
+
* Legacy `{ enabled, targetStatus }` (no `action`) migrates to `set_status` when enabled.
|
|
171
|
+
* @param {unknown} raw
|
|
172
|
+
* @param {{ throwOnInvalid?: boolean }} [opts]
|
|
173
|
+
* @returns {typeof DEFAULT_JIRA_RULES}
|
|
174
|
+
*/
|
|
175
|
+
export function normalizeJiraRules(raw, opts = {}) {
|
|
176
|
+
return normalizeAfterPrRules('jiraRules', raw, {
|
|
177
|
+
...opts,
|
|
178
|
+
migrateLegacyJira: true,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Normalize / validate githubRules from config or PATCH body.
|
|
184
|
+
* @param {unknown} raw
|
|
185
|
+
* @param {{ throwOnInvalid?: boolean }} [opts]
|
|
186
|
+
* @returns {typeof DEFAULT_GITHUB_RULES}
|
|
187
|
+
*/
|
|
188
|
+
export function normalizeGithubRules(raw, opts = {}) {
|
|
189
|
+
return normalizeAfterPrRules('githubRules', raw, opts);
|
|
190
|
+
}
|
|
191
|
+
|
|
180
192
|
/**
|
|
181
193
|
* Persist config to `.acdev/config.json`.
|
|
182
194
|
* @param {string} repoRoot
|
|
@@ -222,7 +234,7 @@ function positiveInt(value, field) {
|
|
|
222
234
|
|
|
223
235
|
/**
|
|
224
236
|
* Load or create `.acdev/config.json` inside the repo root.
|
|
225
|
-
*
|
|
237
|
+
* Empty / malformed model IDs are mapped to {@link DEFAULT_MODEL}.
|
|
226
238
|
* @param {string} repoRoot
|
|
227
239
|
* @returns {typeof DEFAULTS}
|
|
228
240
|
*/
|
|
@@ -254,9 +266,11 @@ export function loadConfig(repoRoot) {
|
|
|
254
266
|
githubRules: normalizeGithubRules(raw.githubRules),
|
|
255
267
|
};
|
|
256
268
|
|
|
257
|
-
if (!
|
|
269
|
+
if (!isValidModelId(config.model)) {
|
|
258
270
|
config.model = DEFAULT_MODEL;
|
|
259
271
|
saveConfig(repoRoot, persistable(config));
|
|
272
|
+
} else {
|
|
273
|
+
config.model = String(config.model).trim();
|
|
260
274
|
}
|
|
261
275
|
|
|
262
276
|
if (!ALLOWED_TICKET_SOURCES.has(config.ticketSource)) {
|
|
@@ -284,12 +298,12 @@ export function loadConfig(repoRoot) {
|
|
|
284
298
|
*/
|
|
285
299
|
export function updateConfig(repoRoot, config, patch) {
|
|
286
300
|
if (patch.model != null) {
|
|
287
|
-
if (!
|
|
301
|
+
if (!isValidModelId(patch.model)) {
|
|
288
302
|
throw new Error(
|
|
289
|
-
`Invalid model "${patch.model}".
|
|
303
|
+
`Invalid model "${patch.model}". Expected a non-empty Claude model id`
|
|
290
304
|
);
|
|
291
305
|
}
|
|
292
|
-
config.model = patch.model;
|
|
306
|
+
config.model = String(patch.model).trim();
|
|
293
307
|
}
|
|
294
308
|
|
|
295
309
|
if (patch.baseBranch !== undefined) {
|
|
@@ -373,12 +387,18 @@ export function updateConfig(repoRoot, config, patch) {
|
|
|
373
387
|
|
|
374
388
|
/**
|
|
375
389
|
* Public-safe config snapshot for the UI / API.
|
|
376
|
-
* Never returns
|
|
390
|
+
* Never returns full secrets (Jira token, GH PAT, Anthropic keys).
|
|
377
391
|
* @param {object} config
|
|
378
|
-
* @param {{
|
|
392
|
+
* @param {{
|
|
393
|
+
* repoRoot?: string,
|
|
394
|
+
* ghAuth?: import('./gh-auth.js').GhAuthResult,
|
|
395
|
+
* claudeAuth?: import('./claude-auth.js').ClaudeAuthResult,
|
|
396
|
+
* }} [opts]
|
|
379
397
|
*/
|
|
380
398
|
export function publicConfig(config, opts = {}) {
|
|
381
|
-
const model =
|
|
399
|
+
const model = isValidModelId(config.model)
|
|
400
|
+
? String(config.model).trim()
|
|
401
|
+
: DEFAULT_MODEL;
|
|
382
402
|
const repoName = opts.repoRoot ? path.basename(opts.repoRoot) : undefined;
|
|
383
403
|
const ticketSource = ALLOWED_TICKET_SOURCES.has(config.ticketSource)
|
|
384
404
|
? config.ticketSource
|
|
@@ -401,6 +421,13 @@ export function publicConfig(config, opts = {}) {
|
|
|
401
421
|
jiraBaseUrl && jiraEmail && tokenMask.set
|
|
402
422
|
);
|
|
403
423
|
|
|
424
|
+
const ghAuth = opts.ghAuth ?? checkGhAuth();
|
|
425
|
+
const claudeAuth = opts.claudeAuth ?? checkClaudeAuth();
|
|
426
|
+
const origin = originRemoteInfo(opts.repoRoot);
|
|
427
|
+
const ghTokenMask = maskSecret(githubTokenFromEnv());
|
|
428
|
+
const anthropicMask = maskSecret(process.env.ANTHROPIC_API_KEY);
|
|
429
|
+
const claudeOauthMask = maskSecret(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
|
430
|
+
|
|
404
431
|
return {
|
|
405
432
|
model,
|
|
406
433
|
baseBranch: config.baseBranch,
|
|
@@ -421,6 +448,19 @@ export function publicConfig(config, opts = {}) {
|
|
|
421
448
|
jiraApiTokenSet: tokenMask.set,
|
|
422
449
|
jiraApiTokenMasked: tokenMask.masked,
|
|
423
450
|
jiraConfigured,
|
|
451
|
+
ghAuthOk: ghAuth.ok === true,
|
|
452
|
+
ghInstalled: Boolean(ghAuth.ghInstalled),
|
|
453
|
+
ghTokenSet: ghTokenMask.set,
|
|
454
|
+
ghTokenMasked: ghTokenMask.masked,
|
|
455
|
+
ghAuthMethod: ghAuth.ok ? ghAuth.method : null,
|
|
456
|
+
originRemoteUrl: origin.url,
|
|
457
|
+
originRemoteSsh: origin.ssh,
|
|
458
|
+
claudeAuthOk: claudeAuth.ok === true,
|
|
459
|
+
claudeAuthMethod: publicClaudeAuthMethod(claudeAuth),
|
|
460
|
+
anthropicApiKeySet: anthropicMask.set,
|
|
461
|
+
anthropicApiKeyMasked: anthropicMask.masked,
|
|
462
|
+
claudeOauthTokenSet: claudeOauthMask.set,
|
|
463
|
+
claudeOauthTokenMasked: claudeOauthMask.masked,
|
|
424
464
|
...(repoName ? { repoName } : {}),
|
|
425
465
|
};
|
|
426
466
|
}
|
package/src/env.js
CHANGED
|
@@ -6,7 +6,7 @@ import { dataDir } from './paths.js';
|
|
|
6
6
|
/**
|
|
7
7
|
* Load environment variables from `.acdev/.env` in the repo root.
|
|
8
8
|
* Existing process.env values take precedence (standard dotenv behavior).
|
|
9
|
-
* Loads ANTHROPIC_*, CLAUDE_*,
|
|
9
|
+
* Loads ANTHROPIC_*, CLAUDE_*, GH_TOKEN, GITHUB_TOKEN, JIRA_*, etc.
|
|
10
10
|
* @param {string} repoRoot
|
|
11
11
|
*/
|
|
12
12
|
export function loadEnv(repoRoot) {
|
package/src/gh-auth.js
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { execFileSync, execSync } from 'node:child_process';
|
|
2
2
|
|
|
3
|
+
/** @typedef {'token' | 'gh-login'} GhAuthMethod */
|
|
4
|
+
/** @typedef {{
|
|
5
|
+
* ok: true,
|
|
6
|
+
* ghInstalled: true,
|
|
7
|
+
* tokenSet: boolean,
|
|
8
|
+
* method: GhAuthMethod,
|
|
9
|
+
* } | {
|
|
10
|
+
* ok: false,
|
|
11
|
+
* reason: 'not-found' | 'not-authenticated',
|
|
12
|
+
* ghInstalled: boolean,
|
|
13
|
+
* tokenSet: boolean,
|
|
14
|
+
* method: null,
|
|
15
|
+
* }} GhAuthResult */
|
|
16
|
+
|
|
3
17
|
/** @type {() => string | null} */
|
|
4
18
|
let ghBinaryResolver = findGhBinary;
|
|
5
19
|
|
|
20
|
+
/** @type {() => NodeJS.ProcessEnv} */
|
|
21
|
+
let envResolver = () => process.env;
|
|
22
|
+
|
|
6
23
|
/** @param {() => string | null} fn */
|
|
7
24
|
export function _setGhBinaryResolver(fn) {
|
|
8
25
|
ghBinaryResolver = fn;
|
|
@@ -12,6 +29,15 @@ export function _resetGhBinaryResolver() {
|
|
|
12
29
|
ghBinaryResolver = findGhBinary;
|
|
13
30
|
}
|
|
14
31
|
|
|
32
|
+
/** @param {() => NodeJS.ProcessEnv} fn */
|
|
33
|
+
export function _setEnvResolver(fn) {
|
|
34
|
+
envResolver = fn;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function _resetEnvResolver() {
|
|
38
|
+
envResolver = () => process.env;
|
|
39
|
+
}
|
|
40
|
+
|
|
15
41
|
function findGhBinary() {
|
|
16
42
|
try {
|
|
17
43
|
return execSync('command -v gh', { encoding: 'utf8', shell: true }).trim();
|
|
@@ -21,21 +47,112 @@ function findGhBinary() {
|
|
|
21
47
|
}
|
|
22
48
|
|
|
23
49
|
/**
|
|
24
|
-
*
|
|
25
|
-
* @
|
|
50
|
+
* Token gh honors for github.com API calls (`GH_TOKEN` then `GITHUB_TOKEN`).
|
|
51
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function githubTokenFromEnv(env = process.env) {
|
|
55
|
+
return String(env.GH_TOKEN || env.GITHUB_TOKEN || '').trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* SSH remotes can push/fetch git; they do not authenticate `gh` for issues/PRs.
|
|
60
|
+
* @param {string | null | undefined} url
|
|
61
|
+
*/
|
|
62
|
+
export function originRemoteLooksSsh(url) {
|
|
63
|
+
return /^(?:git@|ssh:\/\/)/i.test(String(url || ''));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read-only origin remote URL (SSH vs HTTPS). SSH is not gh API auth.
|
|
68
|
+
* @param {string | undefined | null} repoRoot
|
|
69
|
+
* @returns {{ url: string | null, ssh: boolean }}
|
|
70
|
+
*/
|
|
71
|
+
export function originRemoteInfo(repoRoot) {
|
|
72
|
+
if (!repoRoot) return { url: null, ssh: false };
|
|
73
|
+
try {
|
|
74
|
+
const url = execFileSync('git', ['-C', repoRoot, 'remote', 'get-url', 'origin'], {
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
77
|
+
}).trim();
|
|
78
|
+
if (!url) return { url: null, ssh: false };
|
|
79
|
+
return { url, ssh: originRemoteLooksSsh(url) };
|
|
80
|
+
} catch {
|
|
81
|
+
return { url: null, ssh: false };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verify GitHub CLI is installed and github.com is authenticated.
|
|
87
|
+
*
|
|
88
|
+
* Success covers interactive `gh auth login` and a PAT in `GH_TOKEN` /
|
|
89
|
+
* `GITHUB_TOKEN` (gh reads those env vars). SSH remotes are not checked here.
|
|
90
|
+
*
|
|
91
|
+
* @returns {GhAuthResult}
|
|
26
92
|
*/
|
|
27
93
|
export function checkGhAuth() {
|
|
94
|
+
const env = { ...process.env, ...envResolver() };
|
|
95
|
+
const token = githubTokenFromEnv(env);
|
|
96
|
+
const tokenSet = Boolean(token);
|
|
97
|
+
if (token) {
|
|
98
|
+
env.GH_TOKEN = token;
|
|
99
|
+
}
|
|
100
|
+
|
|
28
101
|
const gh = ghBinaryResolver();
|
|
29
102
|
if (!gh) {
|
|
30
|
-
return {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: 'not-found',
|
|
106
|
+
ghInstalled: false,
|
|
107
|
+
tokenSet,
|
|
108
|
+
method: null,
|
|
109
|
+
};
|
|
31
110
|
}
|
|
32
111
|
|
|
33
112
|
try {
|
|
34
|
-
|
|
113
|
+
// `--active` refers to stored gh accounts. Env tokens are not "active"
|
|
114
|
+
// in hosts.yml, so omit it when GH_TOKEN / GITHUB_TOKEN is set.
|
|
115
|
+
const args = ['auth', 'status', '--hostname', 'github.com'];
|
|
116
|
+
if (!tokenSet) {
|
|
117
|
+
args.push('--active');
|
|
118
|
+
}
|
|
119
|
+
execFileSync(gh, args, {
|
|
35
120
|
stdio: 'ignore',
|
|
121
|
+
env,
|
|
36
122
|
});
|
|
37
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
ghInstalled: true,
|
|
126
|
+
tokenSet,
|
|
127
|
+
method: tokenSet ? 'token' : 'gh-login',
|
|
128
|
+
};
|
|
38
129
|
} catch {
|
|
39
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
reason: 'not-authenticated',
|
|
133
|
+
ghInstalled: true,
|
|
134
|
+
tokenSet,
|
|
135
|
+
method: null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Human-readable startup error for a failed {@link checkGhAuth}.
|
|
142
|
+
* @param {GhAuthResult} result
|
|
143
|
+
*/
|
|
144
|
+
export function formatGhAuthError(result) {
|
|
145
|
+
if (result.reason === 'not-found') {
|
|
146
|
+
return '✖ GitHub CLI (gh) not found in PATH. Install it from https://cli.github.com/';
|
|
40
147
|
}
|
|
148
|
+
return [
|
|
149
|
+
'✖ GitHub CLI is not authenticated for github.com.',
|
|
150
|
+
'',
|
|
151
|
+
'Authenticate with one of:',
|
|
152
|
+
' 1. Interactive (browser/device): gh auth login -h github.com',
|
|
153
|
+
' 2. Personal access token: Settings → GitHub / PR auth, or set GH_TOKEN in `.acdev/.env`',
|
|
154
|
+
' 3. Non-interactive CLI: echo YOUR_PAT | gh auth login --with-token -h github.com',
|
|
155
|
+
'',
|
|
156
|
+
'SSH remotes can push/fetch git, but do not authenticate gh for issues or PRs.',
|
|
157
|
+
].join('\n');
|
|
41
158
|
}
|
package/src/jira.js
CHANGED
|
@@ -416,3 +416,174 @@ export async function transitionJiraIssue(key, targetStatusName, creds) {
|
|
|
416
416
|
statusName: match.to?.name || want,
|
|
417
417
|
};
|
|
418
418
|
}
|
|
419
|
+
|
|
420
|
+
/** Status names commonly used for “done / closed” when category is unavailable. */
|
|
421
|
+
const CLOSE_STATUS_NAME_HINTS = new Set([
|
|
422
|
+
'done',
|
|
423
|
+
'closed',
|
|
424
|
+
'resolved',
|
|
425
|
+
'complete',
|
|
426
|
+
'completed',
|
|
427
|
+
]);
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Prefer a transition whose target statusCategory.key is `done`, else a
|
|
431
|
+
* well-known Done/Closed/Resolved-like status name (case-insensitive).
|
|
432
|
+
* @param {Array<{
|
|
433
|
+
* id?: string,
|
|
434
|
+
* name?: string,
|
|
435
|
+
* to?: { name?: string, statusCategory?: { key?: string, name?: string } },
|
|
436
|
+
* }>} transitions
|
|
437
|
+
* @returns {{ id: string, name?: string, to?: { name?: string } } | null}
|
|
438
|
+
*/
|
|
439
|
+
export function findCloseTransition(transitions) {
|
|
440
|
+
const list = Array.isArray(transitions) ? transitions : [];
|
|
441
|
+
for (const t of list) {
|
|
442
|
+
if (!t || t.id == null) continue;
|
|
443
|
+
const catKey = String(t.to?.statusCategory?.key || '')
|
|
444
|
+
.trim()
|
|
445
|
+
.toLowerCase();
|
|
446
|
+
if (catKey === 'done') {
|
|
447
|
+
return /** @type {{ id: string, name?: string, to?: { name?: string } }} */ (t);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (const t of list) {
|
|
451
|
+
if (!t || t.id == null) continue;
|
|
452
|
+
const toName = String(t.to?.name || '')
|
|
453
|
+
.trim()
|
|
454
|
+
.toLowerCase();
|
|
455
|
+
if (toName && CLOSE_STATUS_NAME_HINTS.has(toName)) {
|
|
456
|
+
return /** @type {{ id: string, name?: string, to?: { name?: string } }} */ (t);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Add a label to a Jira issue (REST update `labels` add op).
|
|
464
|
+
* @param {string} key
|
|
465
|
+
* @param {string} label
|
|
466
|
+
* @param {{
|
|
467
|
+
* baseUrl: string,
|
|
468
|
+
* email: string,
|
|
469
|
+
* apiToken: string,
|
|
470
|
+
* fetchFn?: typeof fetch,
|
|
471
|
+
* }} creds
|
|
472
|
+
* @returns {Promise<{ ok: true, key: string, label: string }>}
|
|
473
|
+
*/
|
|
474
|
+
export async function addJiraIssueLabel(key, label, creds) {
|
|
475
|
+
const fetchFn = creds.fetchFn || fetch;
|
|
476
|
+
const base = normalizeJiraBaseUrl(creds.baseUrl);
|
|
477
|
+
const normalizedKey = String(key).toUpperCase();
|
|
478
|
+
const name = String(label || '').trim();
|
|
479
|
+
if (!name) {
|
|
480
|
+
throw new Error('Jira label name is required');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const headers = {
|
|
484
|
+
Authorization: jiraAuthHeader({
|
|
485
|
+
email: creds.email,
|
|
486
|
+
apiToken: creds.apiToken,
|
|
487
|
+
}),
|
|
488
|
+
Accept: 'application/json',
|
|
489
|
+
'Content-Type': 'application/json',
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const url = `${base}/rest/api/3/issue/${encodeURIComponent(normalizedKey)}`;
|
|
493
|
+
const res = await fetchFn(url, {
|
|
494
|
+
method: 'PUT',
|
|
495
|
+
headers,
|
|
496
|
+
body: JSON.stringify({
|
|
497
|
+
update: {
|
|
498
|
+
labels: [{ add: name }],
|
|
499
|
+
},
|
|
500
|
+
}),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
if (!res.ok) {
|
|
504
|
+
const body = await res.text().catch(() => '');
|
|
505
|
+
const detail = body ? `: ${body.slice(0, 300)}` : '';
|
|
506
|
+
throw new Error(
|
|
507
|
+
`Failed to add Jira label "${name}" on ${normalizedKey} (${res.status} ${res.statusText})${detail}`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { ok: true, key: normalizedKey, label: name };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Close a Jira issue by transitioning to a Done-category (or Done/Closed-like) status.
|
|
516
|
+
* There is no dedicated “close” REST endpoint; this reuses the transitions API.
|
|
517
|
+
* @param {string} key
|
|
518
|
+
* @param {{
|
|
519
|
+
* baseUrl: string,
|
|
520
|
+
* email: string,
|
|
521
|
+
* apiToken: string,
|
|
522
|
+
* fetchFn?: typeof fetch,
|
|
523
|
+
* }} creds
|
|
524
|
+
* @returns {Promise<{
|
|
525
|
+
* ok: true,
|
|
526
|
+
* key: string,
|
|
527
|
+
* transitionId: string,
|
|
528
|
+
* statusName: string,
|
|
529
|
+
* }>}
|
|
530
|
+
*/
|
|
531
|
+
export async function closeJiraIssue(key, creds) {
|
|
532
|
+
const fetchFn = creds.fetchFn || fetch;
|
|
533
|
+
const base = normalizeJiraBaseUrl(creds.baseUrl);
|
|
534
|
+
const normalizedKey = String(key).toUpperCase();
|
|
535
|
+
|
|
536
|
+
const headers = {
|
|
537
|
+
Authorization: jiraAuthHeader({
|
|
538
|
+
email: creds.email,
|
|
539
|
+
apiToken: creds.apiToken,
|
|
540
|
+
}),
|
|
541
|
+
Accept: 'application/json',
|
|
542
|
+
'Content-Type': 'application/json',
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const listUrl = `${base}/rest/api/3/issue/${encodeURIComponent(normalizedKey)}/transitions`;
|
|
546
|
+
const listRes = await fetchFn(listUrl, { method: 'GET', headers });
|
|
547
|
+
if (!listRes.ok) {
|
|
548
|
+
const body = await listRes.text().catch(() => '');
|
|
549
|
+
const detail = body ? `: ${body.slice(0, 300)}` : '';
|
|
550
|
+
throw new Error(
|
|
551
|
+
`Failed to list Jira transitions for ${normalizedKey} (${listRes.status} ${listRes.statusText})${detail}`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const listData = await listRes.json();
|
|
556
|
+
const match = findCloseTransition(listData.transitions || []);
|
|
557
|
+
if (!match) {
|
|
558
|
+
const available = (listData.transitions || [])
|
|
559
|
+
.map((t) => t?.to?.name)
|
|
560
|
+
.filter(Boolean)
|
|
561
|
+
.join(', ');
|
|
562
|
+
throw new Error(
|
|
563
|
+
`No Jira close transition (Done/Closed-like status) for ${normalizedKey}` +
|
|
564
|
+
(available ? ` (available: ${available})` : '')
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const postRes = await fetchFn(listUrl, {
|
|
569
|
+
method: 'POST',
|
|
570
|
+
headers,
|
|
571
|
+
body: JSON.stringify({ transition: { id: String(match.id) } }),
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
if (!postRes.ok) {
|
|
575
|
+
const body = await postRes.text().catch(() => '');
|
|
576
|
+
const detail = body ? `: ${body.slice(0, 300)}` : '';
|
|
577
|
+
const statusName = match.to?.name || 'Done';
|
|
578
|
+
throw new Error(
|
|
579
|
+
`Failed to close Jira issue ${normalizedKey} via "${statusName}" (${postRes.status} ${postRes.statusText})${detail}`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
ok: true,
|
|
585
|
+
key: normalizedKey,
|
|
586
|
+
transitionId: String(match.id),
|
|
587
|
+
statusName: match.to?.name || 'Done',
|
|
588
|
+
};
|
|
589
|
+
}
|