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 +3 -1
- package/package.json +1 -1
- package/public/app.js +234 -9
- package/public/index.html +8 -8
- package/public/styles.css +11 -1
- package/src/afterPrRules.js +19 -3
- package/src/config.js +37 -1
- package/src/github.js +323 -0
- package/src/jira.js +178 -0
- package/src/server.js +76 -3
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)
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
608
|
-
<
|
|
609
|
-
<p class="field-hint">
|
|
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
|
-
|
|
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
|
|
642
|
-
<
|
|
643
|
-
<p class="field-hint">
|
|
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;
|
package/src/afterPrRules.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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`:
|
|
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 {
|
|
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 });
|