acdev 1.0.12 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -206,7 +206,7 @@ On first run, creates `.acdev/config.json`:
206
206
 
207
207
  | Action | Jira | GitHub |
208
208
  |--------|------|--------|
209
- | `set_status` | Workflow transition to a status whose name matches `targetStatus` (case-insensitive) | Applies a label named after `targetStatus` via `gh issue edit --add-label` (Projects board API is not used) |
209
+ | `set_status` | Workflow transition to a status whose name matches `targetStatus` (case-insensitive). Settings loads live board statuses. | Sets GitHub Projects v2 **Status** (adds the issue to the project if needed); falls back to a label with that name |
210
210
  | `add_label` | Adds the Jira label via REST `update.labels` `{ add }` | Adds the issue label via `gh` |
211
211
  | `close_issue` | Transitions to a Done-category status, or else a Done/Closed/Resolved-like name | Closes the GitHub issue |
212
212
 
@@ -264,6 +264,8 @@ npm test
264
264
  | `GET` | `/api/models` | `{ models, selected, source }` — live Anthropic list or curated fallback |
265
265
  | `PATCH` | `/api/config` | Partial update including `ticketSource`, `jiraBaseUrl`; secrets (`jiraEmail`, etc.) → `.env` |
266
266
  | `POST` | `/api/jira/test` | Test Jira credentials (`GET /rest/api/3/myself`) |
267
+ | `GET` | `/api/jira/statuses` | Live Jira board/workflow statuses for Rules |
268
+ | `GET` | `/api/github/statuses` | Live GitHub Project Status options (or labels) |
267
269
 
268
270
  ### Branch naming
269
271
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -162,6 +162,10 @@ let availableModels = [];
162
162
  let ticketSource = 'github';
163
163
  /** @type {'claude' | 'openrouter'} */
164
164
  let llmProvider = 'claude';
165
+ /** @type {Array<{ name: string, column?: string, board?: string }>} */
166
+ let jiraStatusOptions = [];
167
+ /** @type {Array<{ name: string, projectTitle?: string }>} */
168
+ let githubStatusOptions = [];
165
169
  /** @type {{
166
170
  * repoName?: string,
167
171
  * baseBranch?: string,
@@ -319,12 +323,14 @@ const els = {
319
323
  settingsJiraRuleAction: document.getElementById('settings-jira-rule-action'),
320
324
  settingsJiraRuleStatus: document.getElementById('settings-jira-rule-status'),
321
325
  settingsJiraRuleStatusField: document.getElementById('settings-jira-rule-status-field'),
326
+ settingsJiraRuleStatusHint: document.getElementById('settings-jira-rule-status-hint'),
322
327
  settingsJiraRuleLabel: document.getElementById('settings-jira-rule-label'),
323
328
  settingsJiraRuleLabelField: document.getElementById('settings-jira-rule-label-field'),
324
329
  settingsGithubRuleEnabled: document.getElementById('settings-github-rule-enabled'),
325
330
  settingsGithubRuleAction: document.getElementById('settings-github-rule-action'),
326
331
  settingsGithubRuleStatus: document.getElementById('settings-github-rule-status'),
327
332
  settingsGithubRuleStatusField: document.getElementById('settings-github-rule-status-field'),
333
+ settingsGithubRuleStatusHint: document.getElementById('settings-github-rule-status-hint'),
328
334
  settingsGithubRuleLabel: document.getElementById('settings-github-rule-label'),
329
335
  settingsGithubRuleLabelField: document.getElementById('settings-github-rule-label-field'),
330
336
  settingsGhStatus: document.getElementById('settings-gh-status'),
@@ -344,6 +350,7 @@ const els = {
344
350
  settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
345
351
  settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
346
352
  settingsLlmProvider: document.getElementById('settings-llm-provider'),
353
+ settingsLlmProviderHint: document.getElementById('settings-llm-provider-hint'),
347
354
  overviewLlmProvider: document.getElementById('overview-llm-provider'),
348
355
  sidebarAgentLabel: document.getElementById('sidebar-agent-label'),
349
356
  settingsTabs: document.getElementById('settings-tabs'),
@@ -3816,6 +3823,9 @@ function setSettingsTab(tab) {
3816
3823
  if (next === 'config') {
3817
3824
  void fetchModels();
3818
3825
  }
3826
+ if (next === 'rules') {
3827
+ void fetchRuleStatuses();
3828
+ }
3819
3829
  }
3820
3830
 
3821
3831
  /**
@@ -3973,7 +3983,7 @@ function fillSettingsForm(cfg) {
3973
3983
  fillAuthSettings(cfg);
3974
3984
 
3975
3985
  updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
3976
- updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
3986
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude', cfg);
3977
3987
 
3978
3988
  if (els.settingsJiraBaseUrl) {
3979
3989
  els.settingsJiraBaseUrl.value = cfg.jiraBaseUrl || '';
@@ -4007,7 +4017,12 @@ function fillSettingsForm(cfg) {
4007
4017
  els.settingsJiraRuleAction.value = action;
4008
4018
  }
4009
4019
  if (els.settingsJiraRuleStatus) {
4010
- els.settingsJiraRuleStatus.value = jiraRule.targetStatus || 'In Review';
4020
+ fillStatusSelect(
4021
+ els.settingsJiraRuleStatus,
4022
+ jiraStatusOptions,
4023
+ jiraRule.targetStatus || '',
4024
+ { hintEl: els.settingsJiraRuleStatusHint }
4025
+ );
4011
4026
  }
4012
4027
  if (els.settingsJiraRuleLabel) {
4013
4028
  els.settingsJiraRuleLabel.value = jiraRule.label || '';
@@ -4024,7 +4039,12 @@ function fillSettingsForm(cfg) {
4024
4039
  els.settingsGithubRuleAction.value = action;
4025
4040
  }
4026
4041
  if (els.settingsGithubRuleStatus) {
4027
- els.settingsGithubRuleStatus.value = ghRule.targetStatus || 'In Review';
4042
+ fillStatusSelect(
4043
+ els.settingsGithubRuleStatus,
4044
+ githubStatusOptions,
4045
+ ghRule.targetStatus || '',
4046
+ { hintEl: els.settingsGithubRuleStatusHint }
4047
+ );
4028
4048
  }
4029
4049
  if (els.settingsGithubRuleLabel) {
4030
4050
  els.settingsGithubRuleLabel.value = ghRule.label || '';
@@ -4096,6 +4116,131 @@ function fillSettingsForm(cfg) {
4096
4116
  }
4097
4117
  }
4098
4118
 
4119
+ /**
4120
+ * @param {HTMLSelectElement | null} selectEl
4121
+ * @param {Array<{ name: string, column?: string, board?: string, projectTitle?: string }>} statuses
4122
+ * @param {string} selected
4123
+ * @param {{ loading?: boolean, error?: string, empty?: string, hintEl?: HTMLElement | null, hintOk?: string }} [meta]
4124
+ */
4125
+ function fillStatusSelect(selectEl, statuses, selected, meta = {}) {
4126
+ if (!selectEl) return;
4127
+ const current = String(selected || '').trim();
4128
+ selectEl.innerHTML = '';
4129
+ if (meta.loading) {
4130
+ selectEl.disabled = true;
4131
+ const opt = document.createElement('option');
4132
+ opt.value = current;
4133
+ opt.textContent = current ? `${current} (loading…)` : 'Loading statuses…';
4134
+ selectEl.appendChild(opt);
4135
+ selectEl.value = current;
4136
+ if (meta.hintEl) meta.hintEl.textContent = 'Fetching live statuses…';
4137
+ return;
4138
+ }
4139
+ selectEl.disabled = false;
4140
+ const list = Array.isArray(statuses) ? statuses : [];
4141
+ if (current && !list.some((s) => s.name === current)) {
4142
+ const saved = document.createElement('option');
4143
+ saved.value = current;
4144
+ saved.textContent = `${current} (saved)`;
4145
+ selectEl.appendChild(saved);
4146
+ }
4147
+ for (const st of list) {
4148
+ const name = String(st.name || '').trim();
4149
+ if (!name) continue;
4150
+ const opt = document.createElement('option');
4151
+ opt.value = name;
4152
+ const extra = st.column && st.column !== name
4153
+ ? st.column
4154
+ : st.board || st.projectTitle || '';
4155
+ opt.textContent = extra ? `${name} — ${extra}` : name;
4156
+ selectEl.appendChild(opt);
4157
+ }
4158
+ if (!selectEl.options.length) {
4159
+ const opt = document.createElement('option');
4160
+ opt.value = current;
4161
+ opt.textContent = meta.error || meta.empty || 'No statuses found';
4162
+ selectEl.appendChild(opt);
4163
+ }
4164
+ if (current) selectEl.value = current;
4165
+ if (meta.hintEl) {
4166
+ if (meta.error) meta.hintEl.textContent = meta.error;
4167
+ else if (meta.hintOk) meta.hintEl.textContent = meta.hintOk;
4168
+ }
4169
+ }
4170
+
4171
+ async function fetchJiraRuleStatuses() {
4172
+ const selected =
4173
+ els.settingsJiraRuleStatus?.value?.trim() ||
4174
+ appConfig.jiraRules?.afterPrOpened?.targetStatus ||
4175
+ '';
4176
+ fillStatusSelect(els.settingsJiraRuleStatus, jiraStatusOptions, selected, {
4177
+ loading: true,
4178
+ hintEl: els.settingsJiraRuleStatusHint,
4179
+ });
4180
+ try {
4181
+ const res = await fetch('/api/jira/statuses');
4182
+ const data = await readJson(res);
4183
+ if (!res.ok || !data.ok) {
4184
+ fillStatusSelect(els.settingsJiraRuleStatus, jiraStatusOptions, selected, {
4185
+ error: data.error || `Failed to load Jira statuses (HTTP ${res.status})`,
4186
+ hintEl: els.settingsJiraRuleStatusHint,
4187
+ });
4188
+ return;
4189
+ }
4190
+ jiraStatusOptions = Array.isArray(data.statuses) ? data.statuses : [];
4191
+ const source = data.source === 'board' ? 'Jira board' : 'Jira workflow catalog';
4192
+ fillStatusSelect(els.settingsJiraRuleStatus, jiraStatusOptions, selected, {
4193
+ hintEl: els.settingsJiraRuleStatusHint,
4194
+ hintOk: `Live statuses from ${source}. Must match a reachable workflow status.`,
4195
+ });
4196
+ } catch (err) {
4197
+ fillStatusSelect(els.settingsJiraRuleStatus, jiraStatusOptions, selected, {
4198
+ error: err.message || 'Failed to load Jira statuses',
4199
+ hintEl: els.settingsJiraRuleStatusHint,
4200
+ });
4201
+ }
4202
+ }
4203
+
4204
+ async function fetchGithubRuleStatuses() {
4205
+ const selected =
4206
+ els.settingsGithubRuleStatus?.value?.trim() ||
4207
+ appConfig.githubRules?.afterPrOpened?.targetStatus ||
4208
+ '';
4209
+ fillStatusSelect(els.settingsGithubRuleStatus, githubStatusOptions, selected, {
4210
+ loading: true,
4211
+ hintEl: els.settingsGithubRuleStatusHint,
4212
+ });
4213
+ try {
4214
+ const res = await fetch('/api/github/statuses');
4215
+ const data = await readJson(res);
4216
+ if (!res.ok || !data.ok) {
4217
+ fillStatusSelect(els.settingsGithubRuleStatus, githubStatusOptions, selected, {
4218
+ error: data.error || `Failed to load GitHub statuses (HTTP ${res.status})`,
4219
+ hintEl: els.settingsGithubRuleStatusHint,
4220
+ });
4221
+ return;
4222
+ }
4223
+ githubStatusOptions = Array.isArray(data.statuses) ? data.statuses : [];
4224
+ const hintOk =
4225
+ data.source === 'project'
4226
+ ? 'Live GitHub Project Status options for this repo.'
4227
+ : 'No Project Status field — listing repo labels instead.';
4228
+ fillStatusSelect(els.settingsGithubRuleStatus, githubStatusOptions, selected, {
4229
+ hintEl: els.settingsGithubRuleStatusHint,
4230
+ hintOk,
4231
+ });
4232
+ } catch (err) {
4233
+ fillStatusSelect(els.settingsGithubRuleStatus, githubStatusOptions, selected, {
4234
+ error: err.message || 'Failed to load GitHub statuses',
4235
+ hintEl: els.settingsGithubRuleStatusHint,
4236
+ });
4237
+ }
4238
+ }
4239
+
4240
+ function fetchRuleStatuses() {
4241
+ return Promise.all([fetchJiraRuleStatuses(), fetchGithubRuleStatuses()]);
4242
+ }
4243
+
4099
4244
  /**
4100
4245
  * Show status / label fields based on the selected post-PR action.
4101
4246
  * @param {'jira' | 'github'} source
@@ -4200,20 +4345,76 @@ function updateTicketSourceUI(source) {
4200
4345
  }
4201
4346
  }
4202
4347
 
4348
+ /**
4349
+ * @param {'claude' | 'openrouter'} provider
4350
+ * @param {typeof appConfig} [cfg]
4351
+ */
4352
+ function llmProviderReady(provider, cfg = appConfig) {
4353
+ if (cfg?.stubAgent) return true;
4354
+ if (provider === 'openrouter') return cfg?.openrouterAuthOk === true;
4355
+ return cfg?.claudeAuthOk === true;
4356
+ }
4357
+
4203
4358
  /**
4204
4359
  * @param {'claude' | 'openrouter'} provider
4205
4360
  */
4206
- function updateLlmProviderUI(provider) {
4361
+ function llmProviderUnavailableReason(provider) {
4362
+ if (provider === 'openrouter') {
4363
+ return 'OpenRouter is not configured. Add an API key in Settings → Authentication first.';
4364
+ }
4365
+ return 'Claude is not configured. Add an API key or OAuth token in Settings → Authentication, or run claude auth login.';
4366
+ }
4367
+
4368
+ const LLM_PROVIDER_HINT_DEFAULT =
4369
+ 'Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model). A provider stays disabled until it is configured and authenticated.';
4370
+
4371
+ /**
4372
+ * @param {typeof appConfig} [cfg]
4373
+ */
4374
+ function updateLlmProviderHint(cfg = appConfig) {
4375
+ if (!els.settingsLlmProviderHint) return;
4376
+ if (cfg?.stubAgent) {
4377
+ els.settingsLlmProviderHint.innerHTML = LLM_PROVIDER_HINT_DEFAULT;
4378
+ return;
4379
+ }
4380
+ const missing = [];
4381
+ if (!llmProviderReady('claude', cfg)) {
4382
+ missing.push('Claude (API key, OAuth token, or <code>claude auth login</code>)');
4383
+ }
4384
+ if (!llmProviderReady('openrouter', cfg)) {
4385
+ missing.push('OpenRouter (API key in Authentication)');
4386
+ }
4387
+ if (!missing.length) {
4388
+ els.settingsLlmProviderHint.innerHTML = LLM_PROVIDER_HINT_DEFAULT;
4389
+ return;
4390
+ }
4391
+ els.settingsLlmProviderHint.innerHTML = `Cannot enable a provider until it is configured and authenticated. Missing: ${missing.join('; ')}.`;
4392
+ }
4393
+
4394
+ /**
4395
+ * @param {'claude' | 'openrouter'} provider
4396
+ * @param {typeof appConfig} [cfg]
4397
+ */
4398
+ function updateLlmProviderUI(provider, cfg = appConfig) {
4207
4399
  llmProvider = provider === 'openrouter' ? 'openrouter' : 'claude';
4208
4400
 
4209
4401
  for (const toggle of [els.settingsLlmProvider, els.overviewLlmProvider]) {
4210
4402
  if (!toggle) continue;
4211
4403
  toggle.querySelectorAll('.source-btn').forEach((btn) => {
4212
- const active = btn.dataset.provider === llmProvider;
4404
+ const id = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
4405
+ const active = id === llmProvider;
4406
+ const ready = llmProviderReady(id, cfg);
4213
4407
  btn.classList.toggle('active', active);
4214
4408
  btn.setAttribute('aria-pressed', active ? 'true' : 'false');
4409
+ btn.disabled = !ready;
4410
+ if (!ready) {
4411
+ btn.title = llmProviderUnavailableReason(id);
4412
+ } else {
4413
+ btn.removeAttribute('title');
4414
+ }
4215
4415
  });
4216
4416
  }
4417
+ updateLlmProviderHint(cfg);
4217
4418
  updateModelLabel();
4218
4419
  }
4219
4420
 
@@ -4240,6 +4441,13 @@ async function saveTicketSource(next) {
4240
4441
 
4241
4442
  async function saveLlmProvider(next) {
4242
4443
  const provider = next === 'openrouter' ? 'openrouter' : 'claude';
4444
+ if (!llmProviderReady(provider)) {
4445
+ const msg = llmProviderUnavailableReason(provider);
4446
+ setOverviewLlmFeedback(msg, 'error');
4447
+ setSettingsFeedback(msg, 'error');
4448
+ return;
4449
+ }
4450
+ const prev = llmProvider;
4243
4451
  updateLlmProviderUI(provider);
4244
4452
  clearAvailableModels();
4245
4453
  try {
@@ -4249,11 +4457,18 @@ async function saveLlmProvider(next) {
4249
4457
  body: JSON.stringify({ llmProvider: provider }),
4250
4458
  });
4251
4459
  const data = await readJson(res);
4252
- if (res.ok) {
4253
- applyConfigSnapshot({ ...appConfig, ...data });
4460
+ if (!res.ok) {
4461
+ updateLlmProviderUI(prev);
4462
+ const msg = data.error || `Update failed (HTTP ${res.status})`;
4463
+ setOverviewLlmFeedback(msg, 'error');
4464
+ setSettingsFeedback(msg, 'error');
4254
4465
  await fetchModels({ refresh: true, reconcile: true });
4466
+ return;
4255
4467
  }
4468
+ applyConfigSnapshot({ ...appConfig, ...data });
4469
+ await fetchModels({ refresh: true, reconcile: true });
4256
4470
  } catch {
4471
+ updateLlmProviderUI(prev);
4257
4472
  void fetchModels({ refresh: true, reconcile: true });
4258
4473
  }
4259
4474
  }
@@ -4285,7 +4500,7 @@ function setOverviewLlmFeedback(message, kind = 'ok') {
4285
4500
  */
4286
4501
  function fillOverviewLlmControls(cfg) {
4287
4502
  if (!cfg) return;
4288
- updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4503
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude', cfg);
4289
4504
  syncAllModelComboboxValues(currentModel);
4290
4505
  }
4291
4506
 
@@ -4333,7 +4548,10 @@ function applyConfigSnapshot(data) {
4333
4548
  updateModelLabel();
4334
4549
  fillOverviewLlmControls(data);
4335
4550
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
4336
- updateLlmProviderUI(data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4551
+ updateLlmProviderUI(
4552
+ data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude',
4553
+ data || appConfig
4554
+ );
4337
4555
  if (els.repoName) {
4338
4556
  els.repoName.textContent = data?.repoName || 'local repo';
4339
4557
  }
@@ -4730,6 +4948,12 @@ function handleLlmProviderToggleClick(e) {
4730
4948
  const next = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
4731
4949
  if (next === llmProvider) return;
4732
4950
  e.stopPropagation();
4951
+ if (btn.disabled || !llmProviderReady(next)) {
4952
+ const msg = llmProviderUnavailableReason(next);
4953
+ setOverviewLlmFeedback(msg, 'error');
4954
+ setSettingsFeedback(msg, 'error');
4955
+ return;
4956
+ }
4733
4957
  void saveLlmProvider(next);
4734
4958
  }
4735
4959
 
@@ -4766,6 +4990,7 @@ els.jiraTestBtn?.addEventListener('click', async () => {
4766
4990
  }
4767
4991
  els.jiraStatus.textContent = `Connected as ${data.displayName || 'OK'}`;
4768
4992
  els.jiraStatus.className = 'jira-status ok';
4993
+ if (settingsTab === 'rules') void fetchJiraRuleStatuses();
4769
4994
  } catch (err) {
4770
4995
  els.jiraStatus.textContent = err.message || 'Test failed';
4771
4996
  els.jiraStatus.className = 'jira-status err';
package/public/index.html CHANGED
@@ -604,9 +604,9 @@
604
604
  </select>
605
605
  </div>
606
606
  <div class="settings-field" id="settings-jira-rule-status-field">
607
- <label class="field-label" for="settings-jira-rule-status">Target status name</label>
608
- <input id="settings-jira-rule-status" class="input" type="text" name="jiraRuleTargetStatus" autocomplete="off" placeholder="In Review">
609
- <p class="field-hint">Must match a status reachable from the issue’s current workflow (exact name, any casing).</p>
607
+ <label class="field-label" for="settings-jira-rule-status">Target status</label>
608
+ <select id="settings-jira-rule-status" class="input" name="jiraRuleTargetStatus"></select>
609
+ <p class="field-hint" id="settings-jira-rule-status-hint">Live statuses from your Jira kanban/scrum boards. Must match a reachable workflow status.</p>
610
610
  </div>
611
611
  <div class="settings-field" id="settings-jira-rule-label-field">
612
612
  <label class="field-label" for="settings-jira-rule-label">Label</label>
@@ -619,7 +619,7 @@
619
619
  <div id="github-rules-panel" class="rules-block">
620
620
  <div class="field-label">GitHub Issues — after PR opened</div>
621
621
  <p class="field-hint">
622
- GitHub issues don’t have Jira-like workflow statuses. Move status applies a label named after the target status (e.g. <code>In Review</code>). You can also add a different label or close the issue. Projects board status is not supported.
622
+ Move status uses a live board Status when the repo has a GitHub Project; otherwise a matching issue label. Add label uses the labels API. Close closes the issue.
623
623
  </p>
624
624
  <div class="settings-grid">
625
625
  <div class="settings-field settings-field-full">
@@ -638,9 +638,9 @@
638
638
  </select>
639
639
  </div>
640
640
  <div class="settings-field" id="settings-github-rule-status-field">
641
- <label class="field-label" for="settings-github-rule-status">Target status name</label>
642
- <input id="settings-github-rule-status" class="input" type="text" name="githubRuleTargetStatus" autocomplete="off" placeholder="In Review">
643
- <p class="field-hint">Applied as an issue label (exact name). Create the label in the repo first if it doesn’t exist.</p>
641
+ <label class="field-label" for="settings-github-rule-status">Target status</label>
642
+ <select id="settings-github-rule-status" class="input" name="githubRuleTargetStatus"></select>
643
+ <p class="field-hint" id="settings-github-rule-status-hint">Live GitHub Project Status options, or repo labels if no Status field exists.</p>
644
644
  </div>
645
645
  <div class="settings-field" id="settings-github-rule-label-field">
646
646
  <label class="field-label" for="settings-github-rule-label">Label</label>
@@ -669,7 +669,7 @@
669
669
  <button type="button" class="source-btn active" data-provider="claude">Claude</button>
670
670
  <button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
671
671
  </div>
672
- <p class="field-hint">Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model).</p>
672
+ <p class="field-hint" id="settings-llm-provider-hint">Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model). A provider stays disabled until it is configured and authenticated.</p>
673
673
  </div>
674
674
  <div class="settings-grid">
675
675
  <div class="settings-field">
package/public/styles.css CHANGED
@@ -2475,7 +2475,7 @@ body.diff-fs-open {
2475
2475
  letter-spacing: 0.02em;
2476
2476
  }
2477
2477
 
2478
- .source-btn:hover:not(.active) {
2478
+ .source-btn:hover:not(.active):not(:disabled) {
2479
2479
  color: var(--text);
2480
2480
  background: color-mix(in srgb, var(--text) 4%, transparent);
2481
2481
  }
@@ -2487,6 +2487,16 @@ body.diff-fs-open {
2487
2487
  box-shadow: var(--shadow-sm);
2488
2488
  }
2489
2489
 
2490
+ .source-btn:disabled {
2491
+ opacity: 0.45;
2492
+ cursor: not-allowed;
2493
+ }
2494
+
2495
+ .source-btn.active:disabled {
2496
+ opacity: 0.7;
2497
+ cursor: default;
2498
+ }
2499
+
2490
2500
  .enqueue-jira-hint {
2491
2501
  margin: 0;
2492
2502
  font-size: 12px;
@@ -4,7 +4,8 @@
4
4
  */
5
5
 
6
6
  import { normalizeGithubRules, normalizeJiraRules } from './config.js';
7
- import { addIssueLabel, closeIssue } from './github.js';
7
+ import { addIssueLabel, closeIssue, setGithubIssueProjectStatus } from './github.js';
8
+ import { originRemoteInfo } from './gh-auth.js';
8
9
  import {
9
10
  addJiraIssueLabel,
10
11
  closeJiraIssue,
@@ -30,6 +31,7 @@ import {
30
31
  * closeJiraIssue?: typeof closeJiraIssue,
31
32
  * addIssueLabel?: typeof addIssueLabel,
32
33
  * closeIssue?: typeof closeIssue,
34
+ * setGithubIssueProjectStatus?: typeof setGithubIssueProjectStatus,
33
35
  * resolveJiraCredentials?: typeof resolveJiraCredentials,
34
36
  * },
35
37
  * }} params
@@ -47,6 +49,7 @@ export async function applyAfterPrOpenedRules({
47
49
  const doCloseJira = deps.closeJiraIssue || closeJiraIssue;
48
50
  const doAddLabel = deps.addIssueLabel || addIssueLabel;
49
51
  const doClose = deps.closeIssue || closeIssue;
52
+ const doSetProjectStatus = deps.setGithubIssueProjectStatus || setGithubIssueProjectStatus;
50
53
  const doResolveCreds = deps.resolveJiraCredentials || resolveJiraCredentials;
51
54
 
52
55
  const source = job.ticketSource === 'jira' || job.jiraKey ? 'jira' : 'github';
@@ -133,9 +136,22 @@ export async function applyAfterPrOpenedRules({
133
136
  appendLog(job, 'warn', msg);
134
137
  return { applied: false, message: msg };
135
138
  }
136
- // GitHub issues have no workflow statuses; model status as an issue label.
139
+ const originUrl = originRemoteInfo(repoRoot).url;
140
+ const project = await doSetProjectStatus({
141
+ issueNumber: job.issueNumber,
142
+ statusName: status,
143
+ cwd,
144
+ originUrl,
145
+ });
146
+ if (project?.ok) {
147
+ const board = project.projectTitle ? ` on "${project.projectTitle}"` : '';
148
+ const msg = `Moved GitHub issue #${job.issueNumber} status to "${project.statusName}"${board} after PR opened`;
149
+ appendLog(job, 'info', msg);
150
+ return { applied: true, message: msg };
151
+ }
137
152
  await doAddLabel(job.issueNumber, status, cwd);
138
- const msg = `Moved GitHub issue #${job.issueNumber} status to "${status}" (label) after PR opened`;
153
+ const fallback = project?.error ? ` (${project.error})` : '';
154
+ const msg = `Moved GitHub issue #${job.issueNumber} status to "${status}" (label)${fallback} after PR opened`;
139
155
  appendLog(job, 'info', msg);
140
156
  return { applied: true, message: msg };
141
157
  }
package/src/config.js CHANGED
@@ -31,7 +31,7 @@ export const LLM_PROVIDERS = /** @type {const} */ (['claude', 'openrouter']);
31
31
  /**
32
32
  * Shared post-PR actions for Jira and GitHub Issues rules.
33
33
  * - Jira `set_status` / `close_issue`: workflow transitions (close → Done-like status).
34
- * - GitHub `set_status`: applies a label named after `targetStatus` (no board API).
34
+ * - GitHub `set_status`: GitHub Projects v2 Status field, else a label named after `targetStatus`.
35
35
  * - Both `add_label`: add the configured label name.
36
36
  */
37
37
  export const AFTER_PR_ACTIONS = /** @type {const} */ ([
@@ -91,6 +91,42 @@ export function normalizeLlmProvider(value) {
91
91
  return value === 'openrouter' ? 'openrouter' : 'claude';
92
92
  }
93
93
 
94
+ /**
95
+ * Block enabling an LLM provider that is not authenticated.
96
+ * Stub-agent skips the check (UI-only testing without credentials).
97
+ * @param {unknown} nextProvider
98
+ * @param {{
99
+ * stubAgent?: boolean,
100
+ * claudeAuth?: import('./claude-auth.js').ClaudeAuthResult,
101
+ * openrouterAuth?: import('./openrouter-auth.js').OpenRouterAuthResult,
102
+ * }} [opts]
103
+ * @returns {{ error: string, code: string } | null}
104
+ */
105
+ export function llmProviderAuthGate(nextProvider, opts = {}) {
106
+ if (opts.stubAgent === true) return null;
107
+ const provider = normalizeLlmProvider(nextProvider);
108
+ if (provider === 'openrouter') {
109
+ const or = opts.openrouterAuth ?? checkOpenRouterAuth();
110
+ if (!or.ok) {
111
+ return {
112
+ error:
113
+ 'OpenRouter is not authenticated. Add an API key in Settings → Authentication before enabling OpenRouter.',
114
+ code: 'openrouter_auth_required',
115
+ };
116
+ }
117
+ return null;
118
+ }
119
+ const claude = opts.claudeAuth ?? checkClaudeAuth();
120
+ if (!claude.ok) {
121
+ return {
122
+ error:
123
+ 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, or run claude auth login, before enabling Claude.',
124
+ code: 'claude_auth_required',
125
+ };
126
+ }
127
+ return null;
128
+ }
129
+
94
130
  /**
95
131
  * @param {unknown} raw
96
132
  * @returns {{ claude?: string, openrouter?: string }}
package/src/github.js CHANGED
@@ -5,6 +5,63 @@ const execFileAsync = promisify(execFile);
5
5
 
6
6
  const ISSUE_URL_RE = /github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/;
7
7
 
8
+ const REPO_FROM_REMOTE_RE =
9
+ /(?:github\.com[:/]|github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/i;
10
+
11
+ const PROJECT_STATUS_FIELDS_QUERY = `query($owner: String!, $name: String!) {
12
+ repository(owner: $owner, name: $name) {
13
+ projectsV2(first: 20) {
14
+ nodes {
15
+ id
16
+ title
17
+ fields(first: 30) {
18
+ nodes {
19
+ __typename
20
+ ... on ProjectV2SingleSelectField {
21
+ id
22
+ name
23
+ options { id name }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }`;
31
+
32
+ const ISSUE_PROJECT_ITEMS_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
33
+ repository(owner: $owner, name: $name) {
34
+ issue(number: $number) {
35
+ id
36
+ projectItems(first: 20) {
37
+ nodes {
38
+ id
39
+ project { id title }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }`;
45
+
46
+ const ADD_PROJECT_ITEM_MUTATION = `mutation($projectId: ID!, $contentId: ID!) {
47
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
48
+ item { id }
49
+ }
50
+ }`;
51
+
52
+ const SET_PROJECT_STATUS_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
53
+ updateProjectV2ItemFieldValue(
54
+ input: {
55
+ projectId: $projectId
56
+ itemId: $itemId
57
+ fieldId: $fieldId
58
+ value: { singleSelectOptionId: $optionId }
59
+ }
60
+ ) {
61
+ projectV2Item { id }
62
+ }
63
+ }`;
64
+
8
65
  /**
9
66
  * @param {string} issueUrl
10
67
  * @returns {{ owner: string, repo: string, number: number }}
@@ -18,6 +75,272 @@ export function parseIssueUrl(issueUrl) {
18
75
  return { owner, repo, number: Number(number) };
19
76
  }
20
77
 
78
+ /**
79
+ * Owner/repo from an origin remote (HTTPS or SSH).
80
+ * @param {string | null | undefined} remoteUrl
81
+ * @returns {{ owner: string, repo: string } | null}
82
+ */
83
+ export function parseGithubRepoFromRemote(remoteUrl) {
84
+ const raw = String(remoteUrl || '').trim().replace(/\/+$/, '');
85
+ if (!raw) return null;
86
+ const match = raw.match(REPO_FROM_REMOTE_RE);
87
+ if (!match) return null;
88
+ const owner = match[1];
89
+ const repo = match[2];
90
+ if (!owner || !repo) return null;
91
+ return { owner, repo };
92
+ }
93
+
94
+ /**
95
+ * @param {{
96
+ * query: string,
97
+ * variables?: Record<string, string | number | boolean>,
98
+ * cwd: string,
99
+ * runGh?: typeof execFileAsync,
100
+ * }} params
101
+ */
102
+ async function ghGraphql({ query, variables = {}, cwd, runGh = execFileAsync }) {
103
+ const args = ['api', 'graphql', '-f', `query=${query}`];
104
+ for (const [key, value] of Object.entries(variables)) {
105
+ if (typeof value === 'number' || typeof value === 'boolean') {
106
+ args.push('-F', `${key}=${value}`);
107
+ } else {
108
+ args.push('-f', `${key}=${value}`);
109
+ }
110
+ }
111
+ const { stdout } = await runGh('gh', args, { cwd });
112
+ const payload = JSON.parse(stdout);
113
+ if (Array.isArray(payload.errors) && payload.errors.length > 0) {
114
+ throw new Error(payload.errors.map((e) => e.message || String(e)).join('; '));
115
+ }
116
+ return payload.data;
117
+ }
118
+
119
+ /**
120
+ * @param {unknown} data
121
+ * @returns {Array<{
122
+ * name: string,
123
+ * id?: string,
124
+ * projectId?: string,
125
+ * projectTitle?: string,
126
+ * fieldId?: string,
127
+ * }>}
128
+ */
129
+ export function extractProjectStatusOptions(data) {
130
+ const nodes = data?.repository?.projectsV2?.nodes;
131
+ const projects = Array.isArray(nodes) ? nodes : [];
132
+ /** @type {Array<{ name: string, id?: string, projectId?: string, projectTitle?: string, fieldId?: string }>} */
133
+ const out = [];
134
+ const seen = new Set();
135
+ for (const project of projects) {
136
+ if (!project || typeof project !== 'object') continue;
137
+ const projectId = project.id != null ? String(project.id) : '';
138
+ const projectTitle = String(project.title || '').trim();
139
+ const fields = Array.isArray(project.fields?.nodes) ? project.fields.nodes : [];
140
+ for (const field of fields) {
141
+ if (!field || typeof field !== 'object') continue;
142
+ const fieldName = String(field.name || '').trim();
143
+ if (!/status/i.test(fieldName)) continue;
144
+ const fieldId = field.id != null ? String(field.id) : '';
145
+ const options = Array.isArray(field.options) ? field.options : [];
146
+ for (const opt of options) {
147
+ const name = String(opt?.name || '').trim();
148
+ if (!name) continue;
149
+ const key = name.toLowerCase();
150
+ if (seen.has(key)) continue;
151
+ seen.add(key);
152
+ out.push({
153
+ name,
154
+ ...(opt?.id != null ? { id: String(opt.id) } : {}),
155
+ ...(projectId ? { projectId } : {}),
156
+ ...(projectTitle ? { projectTitle } : {}),
157
+ ...(fieldId ? { fieldId } : {}),
158
+ });
159
+ }
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /**
166
+ * @param {{
167
+ * cwd: string,
168
+ * originUrl?: string | null,
169
+ * runGh?: typeof execFileAsync,
170
+ * }} opts
171
+ * @returns {Promise<{
172
+ * ok: true,
173
+ * source: 'project' | 'labels',
174
+ * statuses: Array<{ name: string, id?: string, projectId?: string, projectTitle?: string, fieldId?: string }>,
175
+ * } | {
176
+ * ok: false,
177
+ * error: string,
178
+ * statuses: [],
179
+ * }>}
180
+ */
181
+ export async function listGithubIssueStatuses(opts) {
182
+ const runGh = opts.runGh || execFileAsync;
183
+ const cwd = opts.cwd;
184
+ const parsed = parseGithubRepoFromRemote(opts.originUrl);
185
+ try {
186
+ if (parsed) {
187
+ try {
188
+ const data = await ghGraphql({
189
+ query: PROJECT_STATUS_FIELDS_QUERY,
190
+ variables: { owner: parsed.owner, name: parsed.repo },
191
+ cwd,
192
+ runGh,
193
+ });
194
+ const statuses = extractProjectStatusOptions(data);
195
+ if (statuses.length > 0) {
196
+ return { ok: true, source: 'project', statuses };
197
+ }
198
+ } catch {
199
+ // Projects v2 may be disabled or unauthorized — try labels.
200
+ }
201
+ }
202
+
203
+ const { stdout } = await runGh(
204
+ 'gh',
205
+ ['label', 'list', '--limit', '100', '--json', 'name'],
206
+ { cwd }
207
+ );
208
+ const labels = JSON.parse(stdout);
209
+ const list = Array.isArray(labels) ? labels : [];
210
+ const statuses = [];
211
+ const seen = new Set();
212
+ for (const label of list) {
213
+ const name = String(label?.name || '').trim();
214
+ if (!name) continue;
215
+ const key = name.toLowerCase();
216
+ if (seen.has(key)) continue;
217
+ seen.add(key);
218
+ statuses.push({ name });
219
+ }
220
+ if (statuses.length > 0) {
221
+ return { ok: true, source: 'labels', statuses };
222
+ }
223
+ return {
224
+ ok: false,
225
+ error: parsed
226
+ ? 'No GitHub Project Status options or labels found for this repo.'
227
+ : 'Could not parse origin remote as GitHub owner/repo, and no labels were found.',
228
+ statuses: [],
229
+ };
230
+ } catch (err) {
231
+ return {
232
+ ok: false,
233
+ error: err instanceof Error ? err.message : String(err),
234
+ statuses: [],
235
+ };
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Set a GitHub Projects v2 Status field on the issue (add to the first matching
241
+ * project if needed). Returns ok:false when no Status field matches.
242
+ * @param {{
243
+ * issueNumber: number | string,
244
+ * statusName: string,
245
+ * cwd: string,
246
+ * originUrl?: string | null,
247
+ * runGh?: typeof execFileAsync,
248
+ * }} opts
249
+ * @returns {Promise<
250
+ * | { ok: true, statusName: string, projectTitle?: string }
251
+ * | { ok: false, reason: 'no_repo' | 'no_match' | 'error', error: string }
252
+ * >}
253
+ */
254
+ export async function setGithubIssueProjectStatus(opts) {
255
+ const runGh = opts.runGh || execFileAsync;
256
+ const cwd = opts.cwd;
257
+ const want = String(opts.statusName || '').trim();
258
+ const n = Number(opts.issueNumber);
259
+ if (!want) {
260
+ return { ok: false, reason: 'no_match', error: 'Status name is required' };
261
+ }
262
+ if (!Number.isInteger(n) || n <= 0) {
263
+ return { ok: false, reason: 'error', error: `Invalid GitHub issue number: ${opts.issueNumber}` };
264
+ }
265
+ const parsed = parseGithubRepoFromRemote(opts.originUrl);
266
+ if (!parsed) {
267
+ return { ok: false, reason: 'no_repo', error: 'Could not parse GitHub owner/repo from origin remote' };
268
+ }
269
+
270
+ try {
271
+ const fieldsData = await ghGraphql({
272
+ query: PROJECT_STATUS_FIELDS_QUERY,
273
+ variables: { owner: parsed.owner, name: parsed.repo },
274
+ cwd,
275
+ runGh,
276
+ });
277
+ const options = extractProjectStatusOptions(fieldsData);
278
+ const match = options.find((s) => s.name.toLowerCase() === want.toLowerCase());
279
+ if (!match?.projectId || !match.fieldId || !match.id) {
280
+ return {
281
+ ok: false,
282
+ reason: 'no_match',
283
+ error: `No GitHub Project Status option named "${want}"`,
284
+ };
285
+ }
286
+
287
+ const issueData = await ghGraphql({
288
+ query: ISSUE_PROJECT_ITEMS_QUERY,
289
+ variables: { owner: parsed.owner, name: parsed.repo, number: n },
290
+ cwd,
291
+ runGh,
292
+ });
293
+ const issue = issueData?.repository?.issue;
294
+ if (!issue?.id) {
295
+ return { ok: false, reason: 'error', error: `GitHub issue #${n} not found` };
296
+ }
297
+
298
+ const items = Array.isArray(issue.projectItems?.nodes) ? issue.projectItems.nodes : [];
299
+ let itemId = '';
300
+ for (const item of items) {
301
+ if (item?.project?.id === match.projectId && item.id) {
302
+ itemId = String(item.id);
303
+ break;
304
+ }
305
+ }
306
+ if (!itemId) {
307
+ const added = await ghGraphql({
308
+ query: ADD_PROJECT_ITEM_MUTATION,
309
+ variables: { projectId: match.projectId, contentId: String(issue.id) },
310
+ cwd,
311
+ runGh,
312
+ });
313
+ itemId = String(added?.addProjectV2ItemById?.item?.id || '');
314
+ }
315
+ if (!itemId) {
316
+ return { ok: false, reason: 'error', error: `Could not add issue #${n} to GitHub Project` };
317
+ }
318
+
319
+ await ghGraphql({
320
+ query: SET_PROJECT_STATUS_MUTATION,
321
+ variables: {
322
+ projectId: match.projectId,
323
+ itemId,
324
+ fieldId: match.fieldId,
325
+ optionId: match.id,
326
+ },
327
+ cwd,
328
+ runGh,
329
+ });
330
+ return {
331
+ ok: true,
332
+ statusName: match.name,
333
+ projectTitle: match.projectTitle,
334
+ };
335
+ } catch (err) {
336
+ return {
337
+ ok: false,
338
+ reason: 'error',
339
+ error: err instanceof Error ? err.message : String(err),
340
+ };
341
+ }
342
+ }
343
+
21
344
  /**
22
345
  * Fetch issue title/body/labels via `gh` for branch naming and type detection.
23
346
  * @param {string} issueUrl
package/src/jira.js CHANGED
@@ -316,6 +316,184 @@ export async function fetchJiraIssue(key, creds) {
316
316
  };
317
317
  }
318
318
 
319
+ /**
320
+ * @param {{
321
+ * baseUrl: string,
322
+ * email: string,
323
+ * apiToken: string,
324
+ * fetchFn?: typeof fetch,
325
+ * }} creds
326
+ * @param {string} path
327
+ */
328
+ async function jiraGetJson(creds, path) {
329
+ const fetchFn = creds.fetchFn || fetch;
330
+ const base = normalizeJiraBaseUrl(creds.baseUrl);
331
+ const url = `${base}${path.startsWith('/') ? path : `/${path}`}`;
332
+ const res = await fetchFn(url, {
333
+ method: 'GET',
334
+ headers: {
335
+ Authorization: jiraAuthHeader({
336
+ email: creds.email,
337
+ apiToken: creds.apiToken,
338
+ }),
339
+ Accept: 'application/json',
340
+ },
341
+ });
342
+ const text = await res.text().catch(() => '');
343
+ if (!res.ok) {
344
+ const detail = text ? `: ${text.slice(0, 200)}` : '';
345
+ const err = new Error(`Jira GET ${path} failed (${res.status} ${res.statusText})${detail}`);
346
+ err.status = res.status;
347
+ throw err;
348
+ }
349
+ if (!text) return {};
350
+ try {
351
+ return JSON.parse(text);
352
+ } catch {
353
+ throw new Error(`Jira GET ${path} returned invalid JSON`);
354
+ }
355
+ }
356
+
357
+ /**
358
+ * @param {unknown} raw
359
+ * @returns {Map<string, string>}
360
+ */
361
+ export function jiraStatusIdNameMap(raw) {
362
+ const list = Array.isArray(raw)
363
+ ? raw
364
+ : raw && typeof raw === 'object' && Array.isArray(/** @type {{ values?: unknown }} */ (raw).values)
365
+ ? /** @type {{ values: unknown[] }} */ (raw).values
366
+ : [];
367
+ /** @type {Map<string, string>} */
368
+ const map = new Map();
369
+ for (const item of list) {
370
+ if (!item || typeof item !== 'object') continue;
371
+ const obj = /** @type {{ id?: unknown, name?: unknown }} */ (item);
372
+ const id = obj.id != null ? String(obj.id) : '';
373
+ const name = String(obj.name || '').trim();
374
+ if (id && name) map.set(id, name);
375
+ }
376
+ return map;
377
+ }
378
+
379
+ /**
380
+ * @param {unknown} columns
381
+ * @param {Map<string, string>} statusNames
382
+ * @param {string} [boardName]
383
+ * @returns {Array<{ name: string, id?: string, column?: string, board?: string }>}
384
+ */
385
+ export function statusesFromBoardColumns(columns, statusNames, boardName) {
386
+ const cols = Array.isArray(columns) ? columns : [];
387
+ /** @type {Array<{ name: string, id?: string, column?: string, board?: string }>} */
388
+ const out = [];
389
+ const seen = new Set();
390
+ for (const col of cols) {
391
+ if (!col || typeof col !== 'object') continue;
392
+ const colObj = /** @type {{ name?: unknown, statuses?: unknown }} */ (col);
393
+ const column = String(colObj.name || '').trim();
394
+ const sts = Array.isArray(colObj.statuses) ? colObj.statuses : [];
395
+ for (const st of sts) {
396
+ if (!st || typeof st !== 'object') continue;
397
+ const obj = /** @type {{ id?: unknown, name?: unknown }} */ (st);
398
+ const id = obj.id != null ? String(obj.id) : '';
399
+ const name = String(obj.name || '').trim() || (id ? statusNames.get(id) || '' : '');
400
+ if (!name) continue;
401
+ const key = name.toLowerCase();
402
+ if (seen.has(key)) continue;
403
+ seen.add(key);
404
+ out.push({
405
+ name,
406
+ ...(id ? { id } : {}),
407
+ ...(column ? { column } : {}),
408
+ ...(boardName ? { board: boardName } : {}),
409
+ });
410
+ }
411
+ }
412
+ return out;
413
+ }
414
+
415
+ /**
416
+ * Live Jira statuses for the Rules dropdown.
417
+ * Prefers Agile board column statuses (kanban/scrum); falls back to /rest/api/3/status.
418
+ * @param {{
419
+ * baseUrl: string,
420
+ * email: string,
421
+ * apiToken: string,
422
+ * fetchFn?: typeof fetch,
423
+ * }} creds
424
+ * @returns {Promise<{
425
+ * ok: true,
426
+ * source: 'board' | 'status',
427
+ * statuses: Array<{ name: string, id?: string, column?: string, board?: string }>,
428
+ * } | {
429
+ * ok: false,
430
+ * error: string,
431
+ * statuses: [],
432
+ * }>}
433
+ */
434
+ export async function listJiraBoardStatuses(creds) {
435
+ try {
436
+ let statusMap = new Map();
437
+ try {
438
+ statusMap = jiraStatusIdNameMap(await jiraGetJson(creds, '/rest/api/3/status'));
439
+ } catch {
440
+ // Board config sometimes includes status names without this lookup.
441
+ }
442
+
443
+ try {
444
+ const boards = await jiraGetJson(creds, '/rest/agile/1.0/board?maxResults=50');
445
+ const values = Array.isArray(boards?.values) ? boards.values : [];
446
+ /** @type {Array<{ name: string, id?: string, column?: string, board?: string }>} */
447
+ const statuses = [];
448
+ const seen = new Set();
449
+ for (const board of values) {
450
+ if (!board || board.id == null) continue;
451
+ const boardName = String(board.name || '').trim();
452
+ try {
453
+ const cfg = await jiraGetJson(
454
+ creds,
455
+ `/rest/agile/1.0/board/${encodeURIComponent(String(board.id))}/configuration`
456
+ );
457
+ const fromBoard = statusesFromBoardColumns(
458
+ cfg?.columnConfig?.columns,
459
+ statusMap,
460
+ boardName
461
+ );
462
+ for (const st of fromBoard) {
463
+ const key = st.name.toLowerCase();
464
+ if (seen.has(key)) continue;
465
+ seen.add(key);
466
+ statuses.push(st);
467
+ }
468
+ } catch {
469
+ // Skip boards the token cannot read.
470
+ }
471
+ }
472
+ if (statuses.length > 0) {
473
+ return { ok: true, source: 'board', statuses };
474
+ }
475
+ } catch {
476
+ // Agile API missing / 403 — use global status catalog.
477
+ }
478
+
479
+ const statuses = [...statusMap.entries()].map(([id, name]) => ({ name, id }));
480
+ if (statuses.length > 0) {
481
+ return { ok: true, source: 'status', statuses };
482
+ }
483
+ return {
484
+ ok: false,
485
+ error: 'No Jira statuses found. Check board access or Jira Software permissions.',
486
+ statuses: [],
487
+ };
488
+ } catch (err) {
489
+ return {
490
+ ok: false,
491
+ error: err instanceof Error ? err.message : String(err),
492
+ statuses: [],
493
+ };
494
+ }
495
+ }
496
+
319
497
  /**
320
498
  * Find a transition whose target status name matches (case-insensitive).
321
499
  * @param {Array<{ id?: string, name?: string, to?: { name?: string } }>} transitions
package/src/server.js CHANGED
@@ -3,13 +3,14 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { Store } from './store.js';
6
- import { parseIssueUrl, createPr, fetchIssueDetails } from './github.js';
6
+ import { parseIssueUrl, createPr, fetchIssueDetails, listGithubIssueStatuses } from './github.js';
7
7
  import {
8
8
  parseJiraIssueRef,
9
9
  resolveJiraCredentials,
10
10
  testJiraConnection,
11
11
  fetchJiraIssue,
12
12
  mapJiraIssueType,
13
+ listJiraBoardStatuses,
13
14
  } from './jira.js';
14
15
  import { applyAfterPrOpenedRules } from './afterPrRules.js';
15
16
  import {
@@ -33,12 +34,17 @@ import {
33
34
  runAgentOnReviewFeedback,
34
35
  stripAiAttribution,
35
36
  } from './agent.js';
36
- import { publicConfig, updateConfig, normalizeLlmProvider } from './config.js';
37
+ import {
38
+ publicConfig,
39
+ updateConfig,
40
+ normalizeLlmProvider,
41
+ llmProviderAuthGate,
42
+ } from './config.js';
37
43
  import { upsertEnvVars } from './env.js';
38
44
  import { listModels } from './models.js';
39
45
  import { splitIssueUrls } from './urls.js';
40
46
  import { usageFromLogs, withJobUsage, snapshotJobLlm, tagUsageProvider } from './usage.js';
41
- import { checkGhAuth } from './gh-auth.js';
47
+ import { checkGhAuth, originRemoteInfo } from './gh-auth.js';
42
48
  import { checkClaudeAuth } from './claude-auth.js';
43
49
  import { checkOpenRouterAuth } from './openrouter-auth.js';
44
50
  import { isValidModelId, isNoModel, NO_MODEL, isModelIdForProvider } from './models.js';
@@ -177,6 +183,8 @@ export function normalizeReviewComments(body) {
177
183
  * resolveJiraCredentials?: Function,
178
184
  * checkGhAuth?: typeof checkGhAuth,
179
185
  * checkClaudeAuth?: typeof checkClaudeAuth,
186
+ * listJiraBoardStatuses?: typeof listJiraBoardStatuses,
187
+ * listGithubIssueStatuses?: typeof listGithubIssueStatuses,
180
188
  * },
181
189
  * }} options
182
190
  */
@@ -192,6 +200,8 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
192
200
  const doApplyFileExclusions = deps.applyFileExclusions || applyFileExclusions;
193
201
  const doCheckGhAuth = deps.checkGhAuth || checkGhAuth;
194
202
  const doCheckClaudeAuth = deps.checkClaudeAuth || checkClaudeAuth;
203
+ const doListJiraBoardStatuses = deps.listJiraBoardStatuses || listJiraBoardStatuses;
204
+ const doListGithubIssueStatuses = deps.listGithubIssueStatuses || listGithubIssueStatuses;
195
205
 
196
206
  function formatAgentJobError(err) {
197
207
  return err instanceof Error ? err.message : String(err);
@@ -682,6 +692,21 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
682
692
  openrouterApiKey: _or,
683
693
  ...configPatch
684
694
  } = patch;
695
+
696
+ if (configPatch.llmProvider === 'claude' || configPatch.llmProvider === 'openrouter') {
697
+ const next = configPatch.llmProvider;
698
+ if (next !== normalizeLlmProvider(config.llmProvider)) {
699
+ const gate = llmProviderAuthGate(next, {
700
+ stubAgent: useStubAgent,
701
+ claudeAuth: doCheckClaudeAuth(),
702
+ openrouterAuth: checkOpenRouterAuth(),
703
+ });
704
+ if (gate) {
705
+ return res.status(400).json(gate);
706
+ }
707
+ }
708
+ }
709
+
685
710
  updateConfig(repoRoot, config, configPatch);
686
711
  res.json(publicConfigPayload());
687
712
  } catch (err) {
@@ -711,6 +736,54 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
711
736
  }
712
737
  });
713
738
 
739
+ app.get('/api/jira/statuses', async (_req, res) => {
740
+ try {
741
+ const creds = resolveJiraCredentials({ configBaseUrl: config.jiraBaseUrl });
742
+ if ('error' in creds) {
743
+ return res.status(400).json({ ok: false, error: creds.error, statuses: [] });
744
+ }
745
+ const result = await doListJiraBoardStatuses(creds);
746
+ if (!result.ok) {
747
+ return res.status(400).json(result);
748
+ }
749
+ res.json(result);
750
+ } catch (err) {
751
+ res.status(500).json({
752
+ ok: false,
753
+ error: err instanceof Error ? err.message : String(err),
754
+ statuses: [],
755
+ });
756
+ }
757
+ });
758
+
759
+ app.get('/api/github/statuses', async (_req, res) => {
760
+ try {
761
+ const gh = doCheckGhAuth();
762
+ if (!gh.ok) {
763
+ const error =
764
+ gh.reason === 'not-found'
765
+ ? 'GitHub CLI (gh) is not installed.'
766
+ : 'GitHub is not authenticated. Configure a PAT in Settings → Authentication, or run gh auth login.';
767
+ return res.status(400).json({ ok: false, error, statuses: [] });
768
+ }
769
+ const origin = originRemoteInfo(repoRoot);
770
+ const result = await doListGithubIssueStatuses({
771
+ cwd: repoRoot,
772
+ originUrl: origin.url,
773
+ });
774
+ if (!result.ok) {
775
+ return res.status(400).json(result);
776
+ }
777
+ res.json(result);
778
+ } catch (err) {
779
+ res.status(500).json({
780
+ ok: false,
781
+ error: err instanceof Error ? err.message : String(err),
782
+ statuses: [],
783
+ });
784
+ }
785
+ });
786
+
714
787
  app.post('/api/issues', (req, res) => {
715
788
  try {
716
789
  const gate = authGate({ needGh: true, needLlm: true });