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 CHANGED
@@ -206,7 +206,7 @@ On first run, creates `.acdev/config.json`:
206
206
 
207
207
  | Action | Jira | GitHub |
208
208
  |--------|------|--------|
209
- | `set_status` | Workflow transition to a status whose name matches `targetStatus` (case-insensitive) | Applies a label named after `targetStatus` via `gh issue edit --add-label` (Projects board API is not used) |
209
+ | `set_status` | Workflow transition to a status whose name matches `targetStatus` (case-insensitive). Settings loads live board statuses. | Sets GitHub Projects v2 **Status** (adds the issue to the project if needed); falls back to a label with that name |
210
210
  | `add_label` | Adds the Jira label via REST `update.labels` `{ add }` | Adds the issue label via `gh` |
211
211
  | `close_issue` | Transitions to a Done-category status, or else a Done/Closed/Resolved-like name | Closes the GitHub issue |
212
212
 
@@ -264,6 +264,8 @@ npm test
264
264
  | `GET` | `/api/models` | `{ models, selected, source }` — live Anthropic list or curated fallback |
265
265
  | `PATCH` | `/api/config` | Partial update including `ticketSource`, `jiraBaseUrl`; secrets (`jiraEmail`, etc.) → `.env` |
266
266
  | `POST` | `/api/jira/test` | Test Jira credentials (`GET /rest/api/3/myself`) |
267
+ | `GET` | `/api/jira/statuses` | Live Jira board/workflow statuses for Rules |
268
+ | `GET` | `/api/github/statuses` | Live GitHub Project Status options (or labels) |
267
269
 
268
270
  ### Branch naming
269
271
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -162,6 +162,10 @@ let availableModels = [];
162
162
  let ticketSource = 'github';
163
163
  /** @type {'claude' | 'openrouter'} */
164
164
  let llmProvider = 'claude';
165
+ /** @type {Array<{ name: string, column?: string, board?: string }>} */
166
+ let jiraStatusOptions = [];
167
+ /** @type {Array<{ name: string, projectTitle?: string }>} */
168
+ let githubStatusOptions = [];
165
169
  /** @type {{
166
170
  * repoName?: string,
167
171
  * baseBranch?: string,
@@ -319,12 +323,14 @@ const els = {
319
323
  settingsJiraRuleAction: document.getElementById('settings-jira-rule-action'),
320
324
  settingsJiraRuleStatus: document.getElementById('settings-jira-rule-status'),
321
325
  settingsJiraRuleStatusField: document.getElementById('settings-jira-rule-status-field'),
326
+ settingsJiraRuleStatusHint: document.getElementById('settings-jira-rule-status-hint'),
322
327
  settingsJiraRuleLabel: document.getElementById('settings-jira-rule-label'),
323
328
  settingsJiraRuleLabelField: document.getElementById('settings-jira-rule-label-field'),
324
329
  settingsGithubRuleEnabled: document.getElementById('settings-github-rule-enabled'),
325
330
  settingsGithubRuleAction: document.getElementById('settings-github-rule-action'),
326
331
  settingsGithubRuleStatus: document.getElementById('settings-github-rule-status'),
327
332
  settingsGithubRuleStatusField: document.getElementById('settings-github-rule-status-field'),
333
+ settingsGithubRuleStatusHint: document.getElementById('settings-github-rule-status-hint'),
328
334
  settingsGithubRuleLabel: document.getElementById('settings-github-rule-label'),
329
335
  settingsGithubRuleLabelField: document.getElementById('settings-github-rule-label-field'),
330
336
  settingsGhStatus: document.getElementById('settings-gh-status'),
@@ -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
- els.settingsJiraRuleStatus.value = jiraRule.targetStatus || 'In Review';
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
- els.settingsGithubRuleStatus.value = ghRule.targetStatus || 'In Review';
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 name</label>
608
- <input id="settings-jira-rule-status" class="input" type="text" name="jiraRuleTargetStatus" autocomplete="off" placeholder="In Review">
609
- <p class="field-hint">Must match a status reachable from the issue’s current workflow (exact name, any casing).</p>
607
+ <label class="field-label" for="settings-jira-rule-status">Target status</label>
608
+ <select id="settings-jira-rule-status" class="input" name="jiraRuleTargetStatus"></select>
609
+ <p class="field-hint" id="settings-jira-rule-status-hint">Live statuses from your Jira kanban/scrum boards. Must match a reachable workflow status.</p>
610
610
  </div>
611
611
  <div class="settings-field" id="settings-jira-rule-label-field">
612
612
  <label class="field-label" for="settings-jira-rule-label">Label</label>
@@ -619,7 +619,7 @@
619
619
  <div id="github-rules-panel" class="rules-block">
620
620
  <div class="field-label">GitHub Issues — after PR opened</div>
621
621
  <p class="field-hint">
622
- GitHub issues don’t have Jira-like workflow statuses. Move status applies a label named after the target status (e.g. <code>In Review</code>). You can also add a different label or close the issue. Projects board status is not supported.
622
+ Move status uses a live board Status when the repo has a GitHub Project; otherwise a matching issue label. Add label uses the labels API. Close closes the issue.
623
623
  </p>
624
624
  <div class="settings-grid">
625
625
  <div class="settings-field settings-field-full">
@@ -638,9 +638,9 @@
638
638
  </select>
639
639
  </div>
640
640
  <div class="settings-field" id="settings-github-rule-status-field">
641
- <label class="field-label" for="settings-github-rule-status">Target status name</label>
642
- <input id="settings-github-rule-status" class="input" type="text" name="githubRuleTargetStatus" autocomplete="off" placeholder="In Review">
643
- <p class="field-hint">Applied as an issue label (exact name). Create the label in the repo first if it doesn’t exist.</p>
641
+ <label class="field-label" for="settings-github-rule-status">Target status</label>
642
+ <select id="settings-github-rule-status" class="input" name="githubRuleTargetStatus"></select>
643
+ <p class="field-hint" id="settings-github-rule-status-hint">Live GitHub Project Status options, or repo labels if no Status field exists.</p>
644
644
  </div>
645
645
  <div class="settings-field" id="settings-github-rule-label-field">
646
646
  <label class="field-label" for="settings-github-rule-label">Label</label>
@@ -4,7 +4,8 @@
4
4
  */
5
5
 
6
6
  import { normalizeGithubRules, normalizeJiraRules } from './config.js';
7
- import { addIssueLabel, closeIssue } from './github.js';
7
+ import { addIssueLabel, closeIssue, setGithubIssueProjectStatus } from './github.js';
8
+ import { originRemoteInfo } from './gh-auth.js';
8
9
  import {
9
10
  addJiraIssueLabel,
10
11
  closeJiraIssue,
@@ -30,6 +31,7 @@ import {
30
31
  * closeJiraIssue?: typeof closeJiraIssue,
31
32
  * addIssueLabel?: typeof addIssueLabel,
32
33
  * closeIssue?: typeof closeIssue,
34
+ * setGithubIssueProjectStatus?: typeof setGithubIssueProjectStatus,
33
35
  * resolveJiraCredentials?: typeof resolveJiraCredentials,
34
36
  * },
35
37
  * }} params
@@ -47,6 +49,7 @@ export async function applyAfterPrOpenedRules({
47
49
  const doCloseJira = deps.closeJiraIssue || closeJiraIssue;
48
50
  const doAddLabel = deps.addIssueLabel || addIssueLabel;
49
51
  const doClose = deps.closeIssue || closeIssue;
52
+ const doSetProjectStatus = deps.setGithubIssueProjectStatus || setGithubIssueProjectStatus;
50
53
  const doResolveCreds = deps.resolveJiraCredentials || resolveJiraCredentials;
51
54
 
52
55
  const source = job.ticketSource === 'jira' || job.jiraKey ? 'jira' : 'github';
@@ -133,9 +136,22 @@ export async function applyAfterPrOpenedRules({
133
136
  appendLog(job, 'warn', msg);
134
137
  return { applied: false, message: msg };
135
138
  }
136
- // GitHub issues have no workflow statuses; model status as an issue label.
139
+ const originUrl = originRemoteInfo(repoRoot).url;
140
+ const project = await doSetProjectStatus({
141
+ issueNumber: job.issueNumber,
142
+ statusName: status,
143
+ cwd,
144
+ originUrl,
145
+ });
146
+ if (project?.ok) {
147
+ const board = project.projectTitle ? ` on "${project.projectTitle}"` : '';
148
+ const msg = `Moved GitHub issue #${job.issueNumber} status to "${project.statusName}"${board} after PR opened`;
149
+ appendLog(job, 'info', msg);
150
+ return { applied: true, message: msg };
151
+ }
137
152
  await doAddLabel(job.issueNumber, status, cwd);
138
- const msg = `Moved GitHub issue #${job.issueNumber} status to "${status}" (label) after PR opened`;
153
+ const fallback = project?.error ? ` (${project.error})` : '';
154
+ const msg = `Moved GitHub issue #${job.issueNumber} status to "${status}" (label)${fallback} after PR opened`;
139
155
  appendLog(job, 'info', msg);
140
156
  return { applied: true, message: msg };
141
157
  }
package/src/config.js CHANGED
@@ -31,7 +31,7 @@ export const LLM_PROVIDERS = /** @type {const} */ (['claude', 'openrouter']);
31
31
  /**
32
32
  * Shared post-PR actions for Jira and GitHub Issues rules.
33
33
  * - Jira `set_status` / `close_issue`: workflow transitions (close → Done-like status).
34
- * - GitHub `set_status`: applies a label named after `targetStatus` (no board API).
34
+ * - GitHub `set_status`: GitHub Projects v2 Status field, else a label named after `targetStatus`.
35
35
  * - Both `add_label`: add the configured label name.
36
36
  */
37
37
  export const AFTER_PR_ACTIONS = /** @type {const} */ ([
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 });