acdev 1.0.13 → 1.0.15

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
@@ -148,7 +148,7 @@ The tool starts a local server (default port `4848`), opens the browser, and sho
148
148
  1. Paste GitHub issue URLs or Jira keys/URLs on **Overview** and click **Add to queue**
149
149
  2. Jobs process sequentially: sync base branch → create worktree → run agent → await review
150
150
  3. Monitor live logs on **Runs**; review diffs on **Review**
151
- 4. **Approve as draft** (draft PR) or **Approve as ready for review** (non-draft PR), or **Reject** (cleanup worktree, keep history as discarded)
151
+ 4. **Approve as draft** (draft PR) or **Approve as ready for review** (non-draft PR); approval now opens PR and removes local worktree. **Reject** still cleanup worktree, keep history as discarded
152
152
  5. **Clear** removes a job from history entirely (so you can re-queue the same issue). Allowed for queued / awaiting review / PR opened / failed / discarded; refused while a job is in-flight
153
153
 
154
154
  ### Web UI views
@@ -160,7 +160,7 @@ The tool starts a local server (default port `4848`), opens the browser, and sho
160
160
  | **Review** | Edit PR title/body, colored diff, approve draft or ready, reject, or Clear |
161
161
  | **Logs** | Cross-job activity log with job filter |
162
162
  | **Alerts** | Failures / warnings with Retry, Clear, and dismiss |
163
- | **Settings** | Ticket source (GitHub / Jira), GitHub + Claude authentication, Jira connection, post-PR rules, and `.acdev/config.json` fields |
163
+ | **Settings** | Ticket source (GitHub / Jira), GitHub + Claude authentication, Jira connection, and `.acdev/config.json` fields |
164
164
 
165
165
  Model selection lives in **Settings → Configuration**. The sidebar shows the current model as a read-only label. Default is **Sonnet 5** (`claude-sonnet-5`).
166
166
 
@@ -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.15",
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
@@ -125,7 +125,7 @@ let reviewFileLists = {};
125
125
  /** @type {Record<string, Set<string>>} excluded paths per job while reviewing */
126
126
  let reviewExcludedByJob = {};
127
127
  let settingsSaving = false;
128
- /** @type {'ticket' | 'auth' | 'rules' | 'config'} */
128
+ /** @type {'ticket' | 'auth' | 'config'} */
129
129
  let settingsTab = 'ticket';
130
130
  /** Sentinel: no model selected (jobs blocked until user picks one). */
131
131
  const NO_MODEL = '-';
@@ -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,
@@ -297,12 +301,12 @@ const els = {
297
301
  settingsModelValue: document.getElementById('settings-model-value'),
298
302
  settingsModelPanel: document.getElementById('settings-model-panel'),
299
303
  settingsModelList: document.getElementById('settings-model-list'),
300
- settingsModelSearch: document.getElementById('settings-model-search'),
301
- settingsModelHint: document.getElementById('settings-model-hint'),
302
- settingsMaxTurns: document.getElementById('settings-max-turns'),
303
- settingsTimeout: document.getElementById('settings-timeout'),
304
- settingsTimeoutMs: document.getElementById('settings-timeout-ms'),
305
- settingsTestCommand: document.getElementById('settings-test-command'),
304
+ reviewPrLink: document.getElementById('review-pr-link'),
305
+ reviewPrStatusLine: document.getElementById('review-pr-status-line'),
306
+ reviewPrStatusNotes: document.getElementById('review-pr-status-notes'),
307
+ reviewTerminalSection: document.getElementById('review-terminal-section'),
308
+ reviewTerminalMessage: document.getElementById('review-terminal-message'),
309
+ reviewRetryBtn: document.getElementById('review-retry-btn'),
306
310
  settingsTools: document.getElementById('settings-tools'),
307
311
  settingsFeedback: document.getElementById('settings-feedback'),
308
312
  settingsSaveBtn: document.getElementById('settings-save-btn'),
@@ -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'),
@@ -935,14 +941,64 @@ function jobRepoLine(job) {
935
941
  const branch = name ? ` · ${name}` : '';
936
942
  return { repo, number: num, branch, text: `${repo} #${num}${branch}`, isJira: false };
937
943
  }
944
+ function prDecisionLabel(decision) {
945
+ switch (String(decision || '').trim().toUpperCase()) {
946
+ case 'APPROVED':
947
+ return 'approved';
948
+ case 'CHANGES_REQUESTED':
949
+ return 'changes requested';
950
+ case 'REVIEW_REQUIRED':
951
+ return 'review required';
952
+ default:
953
+ return '';
954
+ }
955
+ }
956
+
957
+ function jobPrStatusSummary(job) {
958
+ const pr = job?.prStatus;
959
+ if (!pr) return '';
960
+ if (!pr.ok) {
961
+ return `GitHub status unavailable${pr.error ? ` · ${truncate(pr.error, 72)}` : ''}`;
962
+ }
963
+
964
+ const parts = [];
965
+ if (pr.state === 'MERGED') {
966
+ parts.push('Merged');
967
+ } else if (pr.state === 'CLOSED') {
968
+ parts.push('Closed');
969
+ } else if (pr.isDraft) {
970
+ parts.push('Draft');
971
+ } else {
972
+ parts.push('Open');
973
+ }
974
+
975
+ const decision = prDecisionLabel(pr.reviewDecision);
976
+ if (decision) parts.push(decision);
977
+ if (pr.mergeStateStatus) {
978
+ parts.push(`merge ${pr.mergeStateStatus.toLowerCase().replace(/_/g, ' ')}`);
979
+ }
980
+ if (Array.isArray(pr.requestedReviewers) && pr.requestedReviewers.length) {
981
+ parts.push(`requested ${pr.requestedReviewers.join(', ')}`);
982
+ }
983
+ const changeRequest = Array.isArray(pr.latestReviews)
984
+ ? pr.latestReviews.find((review) => review.state === 'CHANGES_REQUESTED' && review.body)
985
+ : null;
986
+ if (changeRequest?.body) {
987
+ const who = changeRequest.author ? `${changeRequest.author}: ` : '';
988
+ parts.push(`CR ${who}${truncate(changeRequest.body, 72)}`);
989
+ }
990
+
991
+ return `GitHub · ${parts.join(' · ')}`;
992
+ }
938
993
 
939
994
  function jobSubLine(job) {
940
995
  const meta = jobRepoLine(job);
941
996
  const agent = jobLlmLine(job);
997
+ const pr = jobPrStatusSummary(job);
942
998
  const base = meta.isJira
943
999
  ? `${meta.number}${meta.branch}`
944
1000
  : `${meta.repo} #${meta.number}${meta.branch}`;
945
- return agent ? `${base} · ${agent}` : base;
1001
+ return [base, agent, pr].filter(Boolean).join(' · ');
946
1002
  }
947
1003
 
948
1004
  /**
@@ -995,6 +1051,62 @@ function sortedJobs() {
995
1051
  );
996
1052
  }
997
1053
 
1054
+ /**
1055
+ * @param {HTMLElement | null} container
1056
+ * @param {object | null | undefined} pr
1057
+ */
1058
+ function renderPrStatusNotes(container, pr) {
1059
+ if (!container) return;
1060
+ container.innerHTML = '';
1061
+ if (!pr || !pr.ok) return;
1062
+
1063
+ const reviews = Array.isArray(pr.latestReviews) ? [...pr.latestReviews] : [];
1064
+ reviews.sort((a, b) => {
1065
+ const ta = Date.parse(a.submittedAt || '') || 0;
1066
+ const tb = Date.parse(b.submittedAt || '') || 0;
1067
+ return tb - ta;
1068
+ });
1069
+
1070
+ if (reviews.length > 0) {
1071
+ const heading = document.createElement('div');
1072
+ heading.className = 'pr-status-notes-title';
1073
+ heading.textContent = 'Latest reviews';
1074
+ container.appendChild(heading);
1075
+
1076
+ const list = document.createElement('div');
1077
+ list.className = 'pr-status-note-list';
1078
+
1079
+ for (const review of reviews.slice(0, 4)) {
1080
+ const item = document.createElement('div');
1081
+ item.className = 'pr-status-note';
1082
+ const meta = document.createElement('div');
1083
+ meta.className = 'pr-status-note-meta';
1084
+ const parts = [review.state.replace(/_/g, ' ').toLowerCase()];
1085
+ if (review.author) parts.push(review.author);
1086
+ if (review.submittedAt) parts.push(formatTime(review.submittedAt));
1087
+ meta.textContent = parts.join(' · ');
1088
+ item.appendChild(meta);
1089
+
1090
+ if (review.body) {
1091
+ const body = document.createElement('div');
1092
+ body.className = 'pr-status-note-body';
1093
+ body.textContent = review.body;
1094
+ item.appendChild(body);
1095
+ }
1096
+
1097
+ list.appendChild(item);
1098
+ }
1099
+
1100
+ container.appendChild(list);
1101
+ }
1102
+
1103
+ if (Array.isArray(pr.requestedReviewers) && pr.requestedReviewers.length > 0) {
1104
+ const requested = document.createElement('div');
1105
+ requested.className = 'pr-status-note-meta';
1106
+ requested.textContent = `Requested reviewers: ${pr.requestedReviewers.join(', ')}`;
1107
+ container.appendChild(requested);
1108
+ }
1109
+ }
998
1110
  function formatTime(ts) {
999
1111
  try {
1000
1112
  return new Date(ts).toLocaleTimeString([], {
@@ -2573,9 +2685,18 @@ function renderReview(jobs) {
2573
2685
  els.reviewGeneralComment.disabled = !editable;
2574
2686
  }
2575
2687
  els.reviewPrSection.classList.toggle('hidden', !isOpened);
2688
+ if (isOpened) {
2689
+ if (els.reviewPrStatusLine) {
2690
+ els.reviewPrStatusLine.textContent = job.prStatus
2691
+ ? jobPrStatusSummary(job)
2692
+ : 'GitHub status loading…';
2693
+ }
2694
+ renderPrStatusNotes(els.reviewPrStatusNotes, job.prStatus);
2695
+ } else {
2696
+ if (els.reviewPrStatusLine) els.reviewPrStatusLine.textContent = '';
2697
+ if (els.reviewPrStatusNotes) els.reviewPrStatusNotes.innerHTML = '';
2698
+ }
2576
2699
  els.reviewTerminalSection.classList.toggle('hidden', !isTerminal);
2577
-
2578
- const prMeta = resolvePrMeta(job);
2579
2700
  if (document.activeElement !== els.prTitle && document.activeElement !== els.prBody) {
2580
2701
  els.prTitle.value = prMeta.prTitle;
2581
2702
  els.prBody.value = prMeta.prBody;
@@ -3793,12 +3914,11 @@ function knownToolsList(cfg = appConfig) {
3793
3914
  }
3794
3915
 
3795
3916
  /**
3796
- * Switch Settings top-level tab (Ticket source / Authentication / Rules / Configuration).
3797
- * @param {'ticket' | 'auth' | 'rules' | 'config'} tab
3917
+ * Switch Settings top-level tab (Ticket source / Authentication / Configuration).
3918
+ * @param {'ticket' | 'auth' | 'config'} tab
3798
3919
  */
3799
3920
  function setSettingsTab(tab) {
3800
- const next =
3801
- tab === 'auth' || tab === 'rules' || tab === 'config' ? tab : 'ticket';
3921
+ const next = tab === 'auth' || tab === 'config' ? tab : 'ticket';
3802
3922
  settingsTab = next;
3803
3923
 
3804
3924
  if (els.settingsTabs) {
@@ -4008,7 +4128,12 @@ function fillSettingsForm(cfg) {
4008
4128
  els.settingsJiraRuleAction.value = action;
4009
4129
  }
4010
4130
  if (els.settingsJiraRuleStatus) {
4011
- els.settingsJiraRuleStatus.value = jiraRule.targetStatus || 'In Review';
4131
+ fillStatusSelect(
4132
+ els.settingsJiraRuleStatus,
4133
+ jiraStatusOptions,
4134
+ jiraRule.targetStatus || '',
4135
+ { hintEl: els.settingsJiraRuleStatusHint }
4136
+ );
4012
4137
  }
4013
4138
  if (els.settingsJiraRuleLabel) {
4014
4139
  els.settingsJiraRuleLabel.value = jiraRule.label || '';
@@ -4025,7 +4150,12 @@ function fillSettingsForm(cfg) {
4025
4150
  els.settingsGithubRuleAction.value = action;
4026
4151
  }
4027
4152
  if (els.settingsGithubRuleStatus) {
4028
- els.settingsGithubRuleStatus.value = ghRule.targetStatus || 'In Review';
4153
+ fillStatusSelect(
4154
+ els.settingsGithubRuleStatus,
4155
+ githubStatusOptions,
4156
+ ghRule.targetStatus || '',
4157
+ { hintEl: els.settingsGithubRuleStatusHint }
4158
+ );
4029
4159
  }
4030
4160
  if (els.settingsGithubRuleLabel) {
4031
4161
  els.settingsGithubRuleLabel.value = ghRule.label || '';
@@ -4097,6 +4227,131 @@ function fillSettingsForm(cfg) {
4097
4227
  }
4098
4228
  }
4099
4229
 
4230
+ /**
4231
+ * @param {HTMLSelectElement | null} selectEl
4232
+ * @param {Array<{ name: string, column?: string, board?: string, projectTitle?: string }>} statuses
4233
+ * @param {string} selected
4234
+ * @param {{ loading?: boolean, error?: string, empty?: string, hintEl?: HTMLElement | null, hintOk?: string }} [meta]
4235
+ */
4236
+ function fillStatusSelect(selectEl, statuses, selected, meta = {}) {
4237
+ if (!selectEl) return;
4238
+ const current = String(selected || '').trim();
4239
+ selectEl.innerHTML = '';
4240
+ if (meta.loading) {
4241
+ selectEl.disabled = true;
4242
+ const opt = document.createElement('option');
4243
+ opt.value = current;
4244
+ opt.textContent = current ? `${current} (loading…)` : 'Loading statuses…';
4245
+ selectEl.appendChild(opt);
4246
+ selectEl.value = current;
4247
+ if (meta.hintEl) meta.hintEl.textContent = 'Fetching live statuses…';
4248
+ return;
4249
+ }
4250
+ selectEl.disabled = false;
4251
+ const list = Array.isArray(statuses) ? statuses : [];
4252
+ if (current && !list.some((s) => s.name === current)) {
4253
+ const saved = document.createElement('option');
4254
+ saved.value = current;
4255
+ saved.textContent = `${current} (saved)`;
4256
+ selectEl.appendChild(saved);
4257
+ }
4258
+ for (const st of list) {
4259
+ const name = String(st.name || '').trim();
4260
+ if (!name) continue;
4261
+ const opt = document.createElement('option');
4262
+ opt.value = name;
4263
+ const extra = st.column && st.column !== name
4264
+ ? st.column
4265
+ : st.board || st.projectTitle || '';
4266
+ opt.textContent = extra ? `${name} — ${extra}` : name;
4267
+ selectEl.appendChild(opt);
4268
+ }
4269
+ if (!selectEl.options.length) {
4270
+ const opt = document.createElement('option');
4271
+ opt.value = current;
4272
+ opt.textContent = meta.error || meta.empty || 'No statuses found';
4273
+ selectEl.appendChild(opt);
4274
+ }
4275
+ if (current) selectEl.value = current;
4276
+ if (meta.hintEl) {
4277
+ if (meta.error) meta.hintEl.textContent = meta.error;
4278
+ else if (meta.hintOk) meta.hintEl.textContent = meta.hintOk;
4279
+ }
4280
+ }
4281
+
4282
+ async function fetchJiraRuleStatuses() {
4283
+ const selected =
4284
+ els.settingsJiraRuleStatus?.value?.trim() ||
4285
+ appConfig.jiraRules?.afterPrOpened?.targetStatus ||
4286
+ '';
4287
+ fillStatusSelect(els.settingsJiraRuleStatus, jiraStatusOptions, selected, {
4288
+ loading: true,
4289
+ hintEl: els.settingsJiraRuleStatusHint,
4290
+ });
4291
+ try {
4292
+ const res = await fetch('/api/jira/statuses');
4293
+ const data = await readJson(res);
4294
+ if (!res.ok || !data.ok) {
4295
+ fillStatusSelect(els.settingsJiraRuleStatus, jiraStatusOptions, selected, {
4296
+ error: data.error || `Failed to load Jira statuses (HTTP ${res.status})`,
4297
+ hintEl: els.settingsJiraRuleStatusHint,
4298
+ });
4299
+ return;
4300
+ }
4301
+ jiraStatusOptions = Array.isArray(data.statuses) ? data.statuses : [];
4302
+ const source = data.source === 'board' ? 'Jira board' : 'Jira workflow catalog';
4303
+ fillStatusSelect(els.settingsJiraRuleStatus, jiraStatusOptions, selected, {
4304
+ hintEl: els.settingsJiraRuleStatusHint,
4305
+ hintOk: `Live statuses from ${source}. Must match a reachable workflow status.`,
4306
+ });
4307
+ } catch (err) {
4308
+ fillStatusSelect(els.settingsJiraRuleStatus, jiraStatusOptions, selected, {
4309
+ error: err.message || 'Failed to load Jira statuses',
4310
+ hintEl: els.settingsJiraRuleStatusHint,
4311
+ });
4312
+ }
4313
+ }
4314
+
4315
+ async function fetchGithubRuleStatuses() {
4316
+ const selected =
4317
+ els.settingsGithubRuleStatus?.value?.trim() ||
4318
+ appConfig.githubRules?.afterPrOpened?.targetStatus ||
4319
+ '';
4320
+ fillStatusSelect(els.settingsGithubRuleStatus, githubStatusOptions, selected, {
4321
+ loading: true,
4322
+ hintEl: els.settingsGithubRuleStatusHint,
4323
+ });
4324
+ try {
4325
+ const res = await fetch('/api/github/statuses');
4326
+ const data = await readJson(res);
4327
+ if (!res.ok || !data.ok) {
4328
+ fillStatusSelect(els.settingsGithubRuleStatus, githubStatusOptions, selected, {
4329
+ error: data.error || `Failed to load GitHub statuses (HTTP ${res.status})`,
4330
+ hintEl: els.settingsGithubRuleStatusHint,
4331
+ });
4332
+ return;
4333
+ }
4334
+ githubStatusOptions = Array.isArray(data.statuses) ? data.statuses : [];
4335
+ const hintOk =
4336
+ data.source === 'project'
4337
+ ? 'Live GitHub Project Status options for this repo.'
4338
+ : 'No Project Status field — listing repo labels instead.';
4339
+ fillStatusSelect(els.settingsGithubRuleStatus, githubStatusOptions, selected, {
4340
+ hintEl: els.settingsGithubRuleStatusHint,
4341
+ hintOk,
4342
+ });
4343
+ } catch (err) {
4344
+ fillStatusSelect(els.settingsGithubRuleStatus, githubStatusOptions, selected, {
4345
+ error: err.message || 'Failed to load GitHub statuses',
4346
+ hintEl: els.settingsGithubRuleStatusHint,
4347
+ });
4348
+ }
4349
+ }
4350
+
4351
+ function fetchRuleStatuses() {
4352
+ return Promise.all([fetchJiraRuleStatuses(), fetchGithubRuleStatuses()]);
4353
+ }
4354
+
4100
4355
  /**
4101
4356
  * Show status / label fields based on the selected post-PR action.
4102
4357
  * @param {'jira' | 'github'} source
@@ -4570,7 +4825,7 @@ els.settingsTabs?.addEventListener('click', (e) => {
4570
4825
  const btn = e.target.closest('[data-settings-tab]');
4571
4826
  if (!btn?.dataset.settingsTab) return;
4572
4827
  setSettingsTab(
4573
- /** @type {'ticket' | 'auth' | 'rules' | 'config'} */ (btn.dataset.settingsTab)
4828
+ /** @type {'ticket' | 'auth' | 'config'} */ (btn.dataset.settingsTab)
4574
4829
  );
4575
4830
  });
4576
4831
 
@@ -4594,7 +4849,7 @@ els.settingsTabs?.addEventListener('keydown', (e) => {
4594
4849
  e.preventDefault();
4595
4850
  const nextTab = tabs[nextIdx];
4596
4851
  setSettingsTab(
4597
- /** @type {'ticket' | 'auth' | 'rules' | 'config'} */ (nextTab.dataset.settingsTab)
4852
+ /** @type {'ticket' | 'auth' | 'config'} */ (nextTab.dataset.settingsTab)
4598
4853
  );
4599
4854
  nextTab.focus();
4600
4855
  });
package/public/index.html CHANGED
@@ -352,6 +352,8 @@
352
352
 
353
353
  <div id="review-pr-section" class="card card-success hidden">
354
354
  <p class="pr-opened-line">PR opened: <a id="review-pr-link" href="#" target="_blank" rel="noopener"></a></p>
355
+ <p id="review-pr-status-line" class="field-hint"></p>
356
+ <div id="review-pr-status-notes" class="pr-status-notes"></div>
355
357
  <div class="actions">
356
358
  <button id="review-pr-clear-btn" type="button" class="btn btn-secondary btn-sm">Clear from history</button>
357
359
  </div>
@@ -431,6 +433,7 @@
431
433
  aria-selected="false"
432
434
  tabindex="-1"
433
435
  >Authentication</button>
436
+ <!--
434
437
  <button
435
438
  type="button"
436
439
  class="settings-tab"
@@ -441,6 +444,7 @@
441
444
  aria-selected="false"
442
445
  tabindex="-1"
443
446
  >Rules</button>
447
+ -->
444
448
  <button
445
449
  type="button"
446
450
  class="settings-tab"
@@ -571,6 +575,7 @@
571
575
  </div>
572
576
  </div>
573
577
 
578
+ <!--
574
579
  <div
575
580
  class="settings-panel hidden"
576
581
  role="tabpanel"
@@ -604,9 +609,9 @@
604
609
  </select>
605
610
  </div>
606
611
  <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>
612
+ <label class="field-label" for="settings-jira-rule-status">Target status</label>
613
+ <select id="settings-jira-rule-status" class="input" name="jiraRuleTargetStatus"></select>
614
+ <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
615
  </div>
611
616
  <div class="settings-field" id="settings-jira-rule-label-field">
612
617
  <label class="field-label" for="settings-jira-rule-label">Label</label>
@@ -619,7 +624,7 @@
619
624
  <div id="github-rules-panel" class="rules-block">
620
625
  <div class="field-label">GitHub Issues — after PR opened</div>
621
626
  <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.
627
+ 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
628
  </p>
624
629
  <div class="settings-grid">
625
630
  <div class="settings-field settings-field-full">
@@ -638,9 +643,9 @@
638
643
  </select>
639
644
  </div>
640
645
  <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>
646
+ <label class="field-label" for="settings-github-rule-status">Target status</label>
647
+ <select id="settings-github-rule-status" class="input" name="githubRuleTargetStatus"></select>
648
+ <p class="field-hint" id="settings-github-rule-status-hint">Live statuses from GitHub Project v2 or labels. Must match a reachable board status / label.</p>
644
649
  </div>
645
650
  <div class="settings-field" id="settings-github-rule-label-field">
646
651
  <label class="field-label" for="settings-github-rule-label">Label</label>
@@ -651,6 +656,8 @@
651
656
  </div>
652
657
  </div>
653
658
  </div>
659
+ -->
660
+ </div>
654
661
 
655
662
  <div
656
663
  class="settings-panel hidden"
package/public/styles.css CHANGED
@@ -1594,6 +1594,43 @@ a { color: var(--primary); text-underline-offset: 3px; }
1594
1594
  margin-bottom: 12px;
1595
1595
  }
1596
1596
 
1597
+ .pr-status-notes {
1598
+ display: grid;
1599
+ gap: 10px;
1600
+ margin: 0 0 12px;
1601
+ }
1602
+
1603
+ .pr-status-notes-title {
1604
+ font-size: 12px;
1605
+ font-weight: 700;
1606
+ letter-spacing: 0.04em;
1607
+ text-transform: uppercase;
1608
+ color: var(--text-muted);
1609
+ }
1610
+
1611
+ .pr-status-note-list {
1612
+ display: grid;
1613
+ gap: 8px;
1614
+ }
1615
+
1616
+ .pr-status-note {
1617
+ padding: 10px 12px;
1618
+ border: 1px solid var(--border-soft);
1619
+ border-radius: 10px;
1620
+ background: var(--surface-2);
1621
+ }
1622
+
1623
+ .pr-status-note-meta {
1624
+ font-size: 12px;
1625
+ color: var(--text-muted);
1626
+ margin-bottom: 4px;
1627
+ }
1628
+
1629
+ .pr-status-note-body {
1630
+ white-space: pre-wrap;
1631
+ line-height: 1.45;
1632
+ }
1633
+
1597
1634
  /* —— Review file selection —— */
1598
1635
  .review-files-header {
1599
1636
  display: flex;
@@ -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} */ ([