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/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
- /** Model IDs accepted by the Claude Agent SDK / Claude Code. */
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
- /** GitHub post-PR actions (issues have no Jira-like workflow statuses). */
21
- export const GITHUB_AFTER_PR_ACTIONS = /** @type {const} */ ([
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
- const ALLOWED_MODELS = new Set(MODEL_OPTIONS.map((m) => m.id));
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 ALLOWED_GITHUB_AFTER_PR_ACTIONS = new Set(GITHUB_AFTER_PR_ACTIONS);
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
- * Normalize / validate jiraRules from config or PATCH body.
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
- export function normalizeJiraRules(raw, opts = {}) {
79
+ function normalizeAfterPrRules(prefix, raw, opts = {}) {
69
80
  const throwOnInvalid = opts.throwOnInvalid === true;
70
- const base = structuredClone(DEFAULT_JIRA_RULES);
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('jiraRules must be an object');
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('jiraRules.afterPrOpened must be an object');
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('jiraRules.afterPrOpened.enabled must be a boolean');
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('jiraRules.afterPrOpened.targetStatus must be a string');
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('jiraRules.afterPrOpened.targetStatus must be a non-empty string');
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('githubRules.afterPrOpened.label must be a string');
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('githubRules.afterPrOpened.label is required when action is add_label');
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
- * Unknown / removed model IDs are mapped to {@link DEFAULT_MODEL}.
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 (!ALLOWED_MODELS.has(config.model)) {
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 (!ALLOWED_MODELS.has(patch.model)) {
301
+ if (!isValidModelId(patch.model)) {
288
302
  throw new Error(
289
- `Invalid model "${patch.model}". Allowed: ${[...ALLOWED_MODELS].join(', ')}`
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 the full Jira API token.
390
+ * Never returns full secrets (Jira token, GH PAT, Anthropic keys).
377
391
  * @param {object} config
378
- * @param {{ repoRoot?: string }} [opts]
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 = ALLOWED_MODELS.has(config.model) ? config.model : DEFAULT_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_*, JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, etc.
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
- * Verify GitHub CLI is installed and the active github.com account is authenticated.
25
- * @returns {{ ok: true } | { ok: false, reason: 'not-found' | 'not-authenticated' }}
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 { ok: false, reason: 'not-found' };
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
- execFileSync(gh, ['auth', 'status', '--hostname', 'github.com', '--active'], {
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 { ok: true };
123
+ return {
124
+ ok: true,
125
+ ghInstalled: true,
126
+ tokenSet,
127
+ method: tokenSet ? 'token' : 'gh-login',
128
+ };
38
129
  } catch {
39
- return { ok: false, reason: 'not-authenticated' };
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
+ }