acdev 1.0.13 → 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 +147 -2
- package/public/index.html +7 -7
- package/src/afterPrRules.js +19 -3
- package/src/config.js +1 -1
- package/src/github.js +323 -0
- package/src/jira.js +178 -0
- package/src/server.js +55 -2
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'),
|
|
@@ -3817,6 +3823,9 @@ function setSettingsTab(tab) {
|
|
|
3817
3823
|
if (next === 'config') {
|
|
3818
3824
|
void fetchModels();
|
|
3819
3825
|
}
|
|
3826
|
+
if (next === 'rules') {
|
|
3827
|
+
void fetchRuleStatuses();
|
|
3828
|
+
}
|
|
3820
3829
|
}
|
|
3821
3830
|
|
|
3822
3831
|
/**
|
|
@@ -4008,7 +4017,12 @@ function fillSettingsForm(cfg) {
|
|
|
4008
4017
|
els.settingsJiraRuleAction.value = action;
|
|
4009
4018
|
}
|
|
4010
4019
|
if (els.settingsJiraRuleStatus) {
|
|
4011
|
-
|
|
4020
|
+
fillStatusSelect(
|
|
4021
|
+
els.settingsJiraRuleStatus,
|
|
4022
|
+
jiraStatusOptions,
|
|
4023
|
+
jiraRule.targetStatus || '',
|
|
4024
|
+
{ hintEl: els.settingsJiraRuleStatusHint }
|
|
4025
|
+
);
|
|
4012
4026
|
}
|
|
4013
4027
|
if (els.settingsJiraRuleLabel) {
|
|
4014
4028
|
els.settingsJiraRuleLabel.value = jiraRule.label || '';
|
|
@@ -4025,7 +4039,12 @@ function fillSettingsForm(cfg) {
|
|
|
4025
4039
|
els.settingsGithubRuleAction.value = action;
|
|
4026
4040
|
}
|
|
4027
4041
|
if (els.settingsGithubRuleStatus) {
|
|
4028
|
-
|
|
4042
|
+
fillStatusSelect(
|
|
4043
|
+
els.settingsGithubRuleStatus,
|
|
4044
|
+
githubStatusOptions,
|
|
4045
|
+
ghRule.targetStatus || '',
|
|
4046
|
+
{ hintEl: els.settingsGithubRuleStatusHint }
|
|
4047
|
+
);
|
|
4029
4048
|
}
|
|
4030
4049
|
if (els.settingsGithubRuleLabel) {
|
|
4031
4050
|
els.settingsGithubRuleLabel.value = ghRule.label || '';
|
|
@@ -4097,6 +4116,131 @@ function fillSettingsForm(cfg) {
|
|
|
4097
4116
|
}
|
|
4098
4117
|
}
|
|
4099
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
|
+
|
|
4100
4244
|
/**
|
|
4101
4245
|
* Show status / label fields based on the selected post-PR action.
|
|
4102
4246
|
* @param {'jira' | 'github'} source
|
|
@@ -4846,6 +4990,7 @@ els.jiraTestBtn?.addEventListener('click', async () => {
|
|
|
4846
4990
|
}
|
|
4847
4991
|
els.jiraStatus.textContent = `Connected as ${data.displayName || 'OK'}`;
|
|
4848
4992
|
els.jiraStatus.className = 'jira-status ok';
|
|
4993
|
+
if (settingsTab === 'rules') void fetchJiraRuleStatuses();
|
|
4849
4994
|
} catch (err) {
|
|
4850
4995
|
els.jiraStatus.textContent = err.message || 'Test failed';
|
|
4851
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>
|
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} */ ([
|
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 {
|
|
@@ -43,7 +44,7 @@ import { upsertEnvVars } from './env.js';
|
|
|
43
44
|
import { listModels } from './models.js';
|
|
44
45
|
import { splitIssueUrls } from './urls.js';
|
|
45
46
|
import { usageFromLogs, withJobUsage, snapshotJobLlm, tagUsageProvider } from './usage.js';
|
|
46
|
-
import { checkGhAuth } from './gh-auth.js';
|
|
47
|
+
import { checkGhAuth, originRemoteInfo } from './gh-auth.js';
|
|
47
48
|
import { checkClaudeAuth } from './claude-auth.js';
|
|
48
49
|
import { checkOpenRouterAuth } from './openrouter-auth.js';
|
|
49
50
|
import { isValidModelId, isNoModel, NO_MODEL, isModelIdForProvider } from './models.js';
|
|
@@ -182,6 +183,8 @@ export function normalizeReviewComments(body) {
|
|
|
182
183
|
* resolveJiraCredentials?: Function,
|
|
183
184
|
* checkGhAuth?: typeof checkGhAuth,
|
|
184
185
|
* checkClaudeAuth?: typeof checkClaudeAuth,
|
|
186
|
+
* listJiraBoardStatuses?: typeof listJiraBoardStatuses,
|
|
187
|
+
* listGithubIssueStatuses?: typeof listGithubIssueStatuses,
|
|
185
188
|
* },
|
|
186
189
|
* }} options
|
|
187
190
|
*/
|
|
@@ -197,6 +200,8 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
197
200
|
const doApplyFileExclusions = deps.applyFileExclusions || applyFileExclusions;
|
|
198
201
|
const doCheckGhAuth = deps.checkGhAuth || checkGhAuth;
|
|
199
202
|
const doCheckClaudeAuth = deps.checkClaudeAuth || checkClaudeAuth;
|
|
203
|
+
const doListJiraBoardStatuses = deps.listJiraBoardStatuses || listJiraBoardStatuses;
|
|
204
|
+
const doListGithubIssueStatuses = deps.listGithubIssueStatuses || listGithubIssueStatuses;
|
|
200
205
|
|
|
201
206
|
function formatAgentJobError(err) {
|
|
202
207
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -731,6 +736,54 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
731
736
|
}
|
|
732
737
|
});
|
|
733
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
|
+
|
|
734
787
|
app.post('/api/issues', (req, res) => {
|
|
735
788
|
try {
|
|
736
789
|
const gate = authGate({ needGh: true, needLlm: true });
|