acdev 1.0.14 → 1.0.16

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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
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 = '-';
@@ -301,12 +301,12 @@ const els = {
301
301
  settingsModelValue: document.getElementById('settings-model-value'),
302
302
  settingsModelPanel: document.getElementById('settings-model-panel'),
303
303
  settingsModelList: document.getElementById('settings-model-list'),
304
- settingsModelSearch: document.getElementById('settings-model-search'),
305
- settingsModelHint: document.getElementById('settings-model-hint'),
306
- settingsMaxTurns: document.getElementById('settings-max-turns'),
307
- settingsTimeout: document.getElementById('settings-timeout'),
308
- settingsTimeoutMs: document.getElementById('settings-timeout-ms'),
309
- 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'),
310
310
  settingsTools: document.getElementById('settings-tools'),
311
311
  settingsFeedback: document.getElementById('settings-feedback'),
312
312
  settingsSaveBtn: document.getElementById('settings-save-btn'),
@@ -941,14 +941,64 @@ function jobRepoLine(job) {
941
941
  const branch = name ? ` · ${name}` : '';
942
942
  return { repo, number: num, branch, text: `${repo} #${num}${branch}`, isJira: false };
943
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
+ }
944
993
 
945
994
  function jobSubLine(job) {
946
995
  const meta = jobRepoLine(job);
947
996
  const agent = jobLlmLine(job);
997
+ const pr = jobPrStatusSummary(job);
948
998
  const base = meta.isJira
949
999
  ? `${meta.number}${meta.branch}`
950
1000
  : `${meta.repo} #${meta.number}${meta.branch}`;
951
- return agent ? `${base} · ${agent}` : base;
1001
+ return [base, agent, pr].filter(Boolean).join(' · ');
952
1002
  }
953
1003
 
954
1004
  /**
@@ -1001,6 +1051,208 @@ function sortedJobs() {
1001
1051
  );
1002
1052
  }
1003
1053
 
1054
+ /**
1055
+ * @param {string} raw
1056
+ * @returns {string}
1057
+ */
1058
+ function escapeHtml(raw) {
1059
+ return String(raw)
1060
+ .replaceAll('&', '&amp;')
1061
+ .replaceAll('<', '&lt;')
1062
+ .replaceAll('>', '&gt;')
1063
+ .replaceAll('"', '&quot;')
1064
+ .replaceAll("'", '&#39;');
1065
+ }
1066
+
1067
+ /**
1068
+ * @param {string} raw
1069
+ * @returns {string | null}
1070
+ */
1071
+ function safeMarkdownHref(raw) {
1072
+ const href = String(raw || '').trim();
1073
+ if (!href) return null;
1074
+ if (href.startsWith('#') || href.startsWith('/')) return href;
1075
+ try {
1076
+ const url = new URL(href, window.location.href);
1077
+ return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : null;
1078
+ } catch {
1079
+ return null;
1080
+ }
1081
+ }
1082
+
1083
+ /**
1084
+ * @param {string} raw
1085
+ * @returns {string}
1086
+ */
1087
+ function renderInlineMarkdown(raw) {
1088
+ const stash = [];
1089
+ const hold = (html) => `\u0000${stash.push(html) - 1}\u0000`;
1090
+ let out = escapeHtml(raw);
1091
+ out = out.replace(/`([^`]+)`/g, (_, code) => hold(`<code>${code}</code>`));
1092
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_, body) => hold(`<strong>${body}</strong>`));
1093
+ out = out.replace(/__([^_]+)__/g, (_, body) => hold(`<strong>${body}</strong>`));
1094
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
1095
+ const safe = safeMarkdownHref(href);
1096
+ if (!safe) return label;
1097
+ return hold(
1098
+ `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer">${label}</a>`
1099
+ );
1100
+ });
1101
+ return out.replace(/\u0000(\d+)\u0000/g, (_, idx) => stash[Number(idx)] || '');
1102
+ }
1103
+
1104
+ /**
1105
+ * @param {HTMLElement | null} container
1106
+ * @param {string} raw
1107
+ */
1108
+ function renderMarkdownBlock(container, raw) {
1109
+ if (!container) return;
1110
+ container.innerHTML = '';
1111
+ const text = String(raw || '').replace(/\r\n/g, '\n').trim();
1112
+ if (!text) return;
1113
+
1114
+ const lines = text.split('\n');
1115
+ let paragraph = [];
1116
+ let listEl = null;
1117
+ let listType = '';
1118
+
1119
+ const flushParagraph = () => {
1120
+ if (!paragraph.length) return;
1121
+ const p = document.createElement('p');
1122
+ p.innerHTML = renderInlineMarkdown(paragraph.join(' '));
1123
+ container.appendChild(p);
1124
+ paragraph = [];
1125
+ };
1126
+
1127
+ const flushList = () => {
1128
+ if (!listEl) return;
1129
+ container.appendChild(listEl);
1130
+ listEl = null;
1131
+ listType = '';
1132
+ };
1133
+
1134
+ for (let i = 0; i < lines.length; i += 1) {
1135
+ const line = lines[i];
1136
+ if (!line.trim()) {
1137
+ flushParagraph();
1138
+ flushList();
1139
+ continue;
1140
+ }
1141
+
1142
+ const fence = line.match(/^```([\w-]+)?\s*$/);
1143
+ if (fence) {
1144
+ flushParagraph();
1145
+ flushList();
1146
+ const codeLines = [];
1147
+ for (i += 1; i < lines.length; i += 1) {
1148
+ if (/^```\s*$/.test(lines[i])) break;
1149
+ codeLines.push(lines[i]);
1150
+ }
1151
+ const pre = document.createElement('pre');
1152
+ pre.className = 'markdown-code';
1153
+ const code = document.createElement('code');
1154
+ if (fence[1]) code.dataset.language = fence[1];
1155
+ code.textContent = codeLines.join('\n');
1156
+ pre.appendChild(code);
1157
+ container.appendChild(pre);
1158
+ continue;
1159
+ }
1160
+
1161
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
1162
+ if (heading) {
1163
+ flushParagraph();
1164
+ flushList();
1165
+ const tag = `h${heading[1].length}`;
1166
+ const el = document.createElement(tag);
1167
+ el.className = `markdown-heading markdown-heading-${heading[1].length}`;
1168
+ el.innerHTML = renderInlineMarkdown(heading[2]);
1169
+ container.appendChild(el);
1170
+ continue;
1171
+ }
1172
+
1173
+ const bullet = line.match(/^\s*([-*+])\s+(.*)$/);
1174
+ const ordered = line.match(/^\s*\d+\.\s+(.*)$/);
1175
+ if (bullet || ordered) {
1176
+ flushParagraph();
1177
+ const type = bullet ? 'ul' : 'ol';
1178
+ if (!listEl || listType !== type) {
1179
+ flushList();
1180
+ listEl = document.createElement(type);
1181
+ listType = type;
1182
+ }
1183
+ const li = document.createElement('li');
1184
+ li.innerHTML = renderInlineMarkdown((bullet || ordered)[2] || (bullet || ordered)[1] || '');
1185
+ listEl.appendChild(li);
1186
+ continue;
1187
+ }
1188
+
1189
+ paragraph.push(line.trim());
1190
+ }
1191
+
1192
+ flushParagraph();
1193
+ flushList();
1194
+ }
1195
+
1196
+ /**
1197
+ * @param {HTMLElement | null} container
1198
+ * @param {object | null | undefined} pr
1199
+ */
1200
+ function renderPrStatusNotes(container, pr) {
1201
+ if (!container) return;
1202
+ container.innerHTML = '';
1203
+ if (!pr || !pr.ok) return;
1204
+
1205
+ const reviews = Array.isArray(pr.latestReviews) ? [...pr.latestReviews] : [];
1206
+ reviews.sort((a, b) => {
1207
+ const ta = Date.parse(a.submittedAt || '') || 0;
1208
+ const tb = Date.parse(b.submittedAt || '') || 0;
1209
+ return tb - ta;
1210
+ });
1211
+
1212
+ if (reviews.length > 0) {
1213
+ const heading = document.createElement('div');
1214
+ heading.className = 'pr-status-notes-title';
1215
+ heading.textContent = 'Latest reviews';
1216
+ container.appendChild(heading);
1217
+
1218
+ const list = document.createElement('div');
1219
+ list.className = 'pr-status-note-list';
1220
+
1221
+ for (const review of reviews.slice(0, 4)) {
1222
+ const item = document.createElement('div');
1223
+ item.className = 'pr-status-note';
1224
+ const meta = document.createElement('div');
1225
+ meta.className = 'pr-status-note-meta';
1226
+ const parts = [review.state.replace(/_/g, ' ').toLowerCase()];
1227
+ if (review.author) parts.push(review.author);
1228
+ if (review.submittedAt) parts.push(formatTime(review.submittedAt));
1229
+ meta.textContent = parts.join(' · ');
1230
+ item.appendChild(meta);
1231
+
1232
+ if (review.body) {
1233
+ const body = document.createElement('div');
1234
+ body.className = 'pr-status-note-body markdown-view';
1235
+ renderMarkdownBlock(body, review.body);
1236
+ item.appendChild(body);
1237
+ }
1238
+
1239
+ list.appendChild(item);
1240
+ }
1241
+
1242
+ container.appendChild(list);
1243
+ }
1244
+
1245
+ if (Array.isArray(pr.requestedReviewers) && pr.requestedReviewers.length > 0) {
1246
+ const requested = document.createElement('div');
1247
+ requested.className = 'pr-status-note-meta';
1248
+ requested.textContent = `Requested reviewers: ${pr.requestedReviewers.join(', ')}`;
1249
+ container.appendChild(requested);
1250
+ }
1251
+ }
1252
+ if (typeof window !== 'undefined') {
1253
+ window.__acdevMarkdownRender = renderMarkdownBlock;
1254
+ window.__acdevMarkdownInline = renderInlineMarkdown;
1255
+ }
1004
1256
  function formatTime(ts) {
1005
1257
  try {
1006
1258
  return new Date(ts).toLocaleTimeString([], {
@@ -1665,8 +1917,8 @@ function renderDiff(diff, opts = {}) {
1665
1917
  const chip = document.createElement('div');
1666
1918
  chip.className = 'diff-pending-chip';
1667
1919
  const body = document.createElement('div');
1668
- body.className = 'diff-pending-chip-body';
1669
- body.textContent = c.body;
1920
+ body.className = 'diff-pending-chip-body markdown-view';
1921
+ renderMarkdownBlock(body, c.body);
1670
1922
  const actions = document.createElement('div');
1671
1923
  actions.className = 'diff-pending-chip-actions';
1672
1924
  const edit = document.createElement('button');
@@ -1816,8 +2068,7 @@ function renderDiff(diff, opts = {}) {
1816
2068
  }
1817
2069
  continue;
1818
2070
  }
1819
-
1820
- if (line.startsWith('@@')) {
2071
+ if (line.startsWith('@@ ')) {
1821
2072
  const m = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
1822
2073
  if (m) {
1823
2074
  leftNo = Number(m[1]);
@@ -1959,8 +2210,8 @@ function renderPendingCommentList(jobId) {
1959
2210
  }
1960
2211
 
1961
2212
  const body = document.createElement('div');
1962
- body.className = 'review-pending-item-body';
1963
- body.textContent = c.body;
2213
+ body.className = 'review-pending-item-body markdown-view';
2214
+ renderMarkdownBlock(body, c.body);
1964
2215
  main.appendChild(body);
1965
2216
  const actions = document.createElement('div');
1966
2217
  actions.className = 'review-pending-item-actions';
@@ -2579,9 +2830,18 @@ function renderReview(jobs) {
2579
2830
  els.reviewGeneralComment.disabled = !editable;
2580
2831
  }
2581
2832
  els.reviewPrSection.classList.toggle('hidden', !isOpened);
2833
+ if (isOpened) {
2834
+ if (els.reviewPrStatusLine) {
2835
+ els.reviewPrStatusLine.textContent = job.prStatus
2836
+ ? jobPrStatusSummary(job)
2837
+ : 'GitHub status loading…';
2838
+ }
2839
+ renderPrStatusNotes(els.reviewPrStatusNotes, job.prStatus);
2840
+ } else {
2841
+ if (els.reviewPrStatusLine) els.reviewPrStatusLine.textContent = '';
2842
+ if (els.reviewPrStatusNotes) els.reviewPrStatusNotes.innerHTML = '';
2843
+ }
2582
2844
  els.reviewTerminalSection.classList.toggle('hidden', !isTerminal);
2583
-
2584
- const prMeta = resolvePrMeta(job);
2585
2845
  if (document.activeElement !== els.prTitle && document.activeElement !== els.prBody) {
2586
2846
  els.prTitle.value = prMeta.prTitle;
2587
2847
  els.prBody.value = prMeta.prBody;
@@ -3799,12 +4059,11 @@ function knownToolsList(cfg = appConfig) {
3799
4059
  }
3800
4060
 
3801
4061
  /**
3802
- * Switch Settings top-level tab (Ticket source / Authentication / Rules / Configuration).
3803
- * @param {'ticket' | 'auth' | 'rules' | 'config'} tab
4062
+ * Switch Settings top-level tab (Ticket source / Authentication / Configuration).
4063
+ * @param {'ticket' | 'auth' | 'config'} tab
3804
4064
  */
3805
4065
  function setSettingsTab(tab) {
3806
- const next =
3807
- tab === 'auth' || tab === 'rules' || tab === 'config' ? tab : 'ticket';
4066
+ const next = tab === 'auth' || tab === 'config' ? tab : 'ticket';
3808
4067
  settingsTab = next;
3809
4068
 
3810
4069
  if (els.settingsTabs) {
@@ -3823,9 +4082,6 @@ function setSettingsTab(tab) {
3823
4082
  if (next === 'config') {
3824
4083
  void fetchModels();
3825
4084
  }
3826
- if (next === 'rules') {
3827
- void fetchRuleStatuses();
3828
- }
3829
4085
  }
3830
4086
 
3831
4087
  /**
@@ -4714,7 +4970,7 @@ els.settingsTabs?.addEventListener('click', (e) => {
4714
4970
  const btn = e.target.closest('[data-settings-tab]');
4715
4971
  if (!btn?.dataset.settingsTab) return;
4716
4972
  setSettingsTab(
4717
- /** @type {'ticket' | 'auth' | 'rules' | 'config'} */ (btn.dataset.settingsTab)
4973
+ /** @type {'ticket' | 'auth' | 'config'} */ (btn.dataset.settingsTab)
4718
4974
  );
4719
4975
  });
4720
4976
 
@@ -4738,7 +4994,7 @@ els.settingsTabs?.addEventListener('keydown', (e) => {
4738
4994
  e.preventDefault();
4739
4995
  const nextTab = tabs[nextIdx];
4740
4996
  setSettingsTab(
4741
- /** @type {'ticket' | 'auth' | 'rules' | 'config'} */ (nextTab.dataset.settingsTab)
4997
+ /** @type {'ticket' | 'auth' | 'config'} */ (nextTab.dataset.settingsTab)
4742
4998
  );
4743
4999
  nextTab.focus();
4744
5000
  });
@@ -4990,7 +5246,6 @@ els.jiraTestBtn?.addEventListener('click', async () => {
4990
5246
  }
4991
5247
  els.jiraStatus.textContent = `Connected as ${data.displayName || 'OK'}`;
4992
5248
  els.jiraStatus.className = 'jira-status ok';
4993
- if (settingsTab === 'rules') void fetchJiraRuleStatuses();
4994
5249
  } catch (err) {
4995
5250
  els.jiraStatus.textContent = err.message || 'Test failed';
4996
5251
  els.jiraStatus.className = 'jira-status err';
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"
@@ -640,7 +645,7 @@
640
645
  <div class="settings-field" id="settings-github-rule-status-field">
641
646
  <label class="field-label" for="settings-github-rule-status">Target status</label>
642
647
  <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>
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,95 @@ 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
+ .markdown-view {
1624
+ line-height: 1.45;
1625
+ word-break: break-word;
1626
+ }
1627
+
1628
+ .markdown-view > :first-child {
1629
+ margin-top: 0;
1630
+ }
1631
+
1632
+ .markdown-view > :last-child {
1633
+ margin-bottom: 0;
1634
+ }
1635
+
1636
+ .markdown-view p,
1637
+ .markdown-view ul,
1638
+ .markdown-view ol,
1639
+ .markdown-view pre,
1640
+ .markdown-view blockquote,
1641
+ .markdown-view h1,
1642
+ .markdown-view h2,
1643
+ .markdown-view h3,
1644
+ .markdown-view h4,
1645
+ .markdown-view h5,
1646
+ .markdown-view h6 {
1647
+ margin: 0 0 8px;
1648
+ }
1649
+
1650
+ .markdown-view ul,
1651
+ .markdown-view ol {
1652
+ padding-left: 20px;
1653
+ }
1654
+
1655
+ .markdown-view code {
1656
+ font-family: "JetBrains Mono", ui-monospace, monospace;
1657
+ font-size: 12px;
1658
+ padding: 1px 4px;
1659
+ background: var(--bg);
1660
+ border-radius: 4px;
1661
+ }
1662
+
1663
+ .markdown-view pre {
1664
+ margin: 0 0 8px;
1665
+ padding: 8px 10px;
1666
+ background: var(--bg);
1667
+ border: 1px solid var(--border-soft);
1668
+ border-radius: 8px;
1669
+ overflow-x: auto;
1670
+ }
1671
+
1672
+ .markdown-view pre code {
1673
+ padding: 0;
1674
+ background: transparent;
1675
+ }
1676
+
1677
+ .markdown-view a {
1678
+ color: var(--primary);
1679
+ text-decoration: underline;
1680
+ }
1681
+
1682
+ .pr-status-note-body {
1683
+ font-size: 13px;
1684
+ }
1685
+
1597
1686
  /* —— Review file selection —— */
1598
1687
  .review-files-header {
1599
1688
  display: flex;
@@ -1906,7 +1995,7 @@ body.diff-fs-open {
1906
1995
 
1907
1996
  .diff-pending-chip-body {
1908
1997
  color: var(--text);
1909
- white-space: pre-wrap;
1998
+ font-size: 12px;
1910
1999
  }
1911
2000
 
1912
2001
  .diff-pending-chip-actions {
@@ -1916,6 +2005,10 @@ body.diff-fs-open {
1916
2005
  flex-wrap: wrap;
1917
2006
  }
1918
2007
 
2008
+ .review-pending-item-body {
2009
+ font-size: 13px;
2010
+ }
2011
+
1919
2012
  .review-pending-list {
1920
2013
  display: flex;
1921
2014
  flex-direction: column;
package/src/github.js CHANGED
@@ -393,6 +393,122 @@ export function buildPrCreateArgs({ title, body, baseBranch, branchName, draft =
393
393
  return args;
394
394
  }
395
395
 
396
+ /**
397
+ * @typedef {{
398
+ * ok: true,
399
+ * url: string,
400
+ * title: string,
401
+ * number: number,
402
+ * state: string,
403
+ * isDraft: boolean,
404
+ * reviewDecision: string | null,
405
+ * mergeStateStatus: string | null,
406
+ * closedAt: string | null,
407
+ * mergedAt: string | null,
408
+ * author: string | null,
409
+ * requestedReviewers: string[],
410
+ * latestReviews: Array<{
411
+ * author: string | null,
412
+ * state: string,
413
+ * body: string,
414
+ * submittedAt: string | null,
415
+ * }>,
416
+ * }} GithubPrStatusOk
417
+ *
418
+ * @typedef {{
419
+ * ok: false,
420
+ * error: string,
421
+ * }} GithubPrStatusError
422
+ *
423
+ * @typedef {GithubPrStatusOk | GithubPrStatusError} GithubPrStatusResult
424
+ */
425
+
426
+ /**
427
+ * @param {unknown} user
428
+ * @returns {string | null}
429
+ */
430
+ function userName(user) {
431
+ if (!user || typeof user !== 'object') return null;
432
+ const name = String(user.login || user.name || user.displayName || '').trim();
433
+ return name || null;
434
+ }
435
+
436
+ /**
437
+ * @param {unknown} review
438
+ * @returns {{ author: string | null, state: string, body: string, submittedAt: string | null } | null}
439
+ */
440
+ function normalizeReview(review) {
441
+ if (!review || typeof review !== 'object') return null;
442
+ const state = String(review.state || '').trim().toUpperCase();
443
+ const body = String(review.body || '').trim();
444
+ const submittedAt = String(review.submittedAt || review.createdAt || review.updatedAt || '').trim();
445
+ return {
446
+ author: userName(review.author),
447
+ state: state || 'UNKNOWN',
448
+ body,
449
+ submittedAt: submittedAt || null,
450
+ };
451
+ }
452
+
453
+ /**
454
+ * Current GitHub PR review state + summary via `gh pr view`.
455
+ * @param {{
456
+ * prUrl: string,
457
+ * cwd: string,
458
+ * runGh?: typeof execFileAsync,
459
+ * }} opts
460
+ * @returns {Promise<GithubPrStatusResult>}
461
+ */
462
+ export async function fetchGithubPrStatus({ prUrl, cwd, runGh = execFileAsync }) {
463
+ const url = String(prUrl || '').trim();
464
+ if (!url) {
465
+ return { ok: false, error: 'PR URL is required' };
466
+ }
467
+ try {
468
+ const { stdout } = await runGh(
469
+ 'gh',
470
+ [
471
+ 'pr',
472
+ 'view',
473
+ url,
474
+ '--json',
475
+ 'url,title,number,state,isDraft,reviewDecision,mergeStateStatus,closedAt,mergedAt,author,reviewRequests,latestReviews',
476
+ ],
477
+ { cwd }
478
+ );
479
+ const raw = JSON.parse(stdout);
480
+ const latestReviews = Array.isArray(raw.latestReviews)
481
+ ? raw.latestReviews.map(normalizeReview).filter(Boolean)
482
+ : [];
483
+ const requestedReviewers = Array.isArray(raw.reviewRequests || raw.requestedReviewers)
484
+ ? (raw.reviewRequests || raw.requestedReviewers).map((item) => {
485
+ if (item && typeof item === 'object' && 'requestedReviewer' in item) {
486
+ return userName(item.requestedReviewer);
487
+ }
488
+ return userName(item);
489
+ }).filter(Boolean)
490
+ : [];
491
+ return {
492
+ ok: true,
493
+ url: String(raw.url || url),
494
+ title: String(raw.title || '').trim(),
495
+ number: Number(raw.number || 0),
496
+ state: String(raw.state || '').trim().toUpperCase() || 'UNKNOWN',
497
+ isDraft: Boolean(raw.isDraft),
498
+ reviewDecision: String(raw.reviewDecision || '').trim().toUpperCase() || null,
499
+ mergeStateStatus: String(raw.mergeStateStatus || '').trim().toUpperCase() || null,
500
+ closedAt: String(raw.closedAt || '').trim() || null,
501
+ mergedAt: String(raw.mergedAt || '').trim() || null,
502
+ author: userName(raw.author),
503
+ requestedReviewers,
504
+ latestReviews,
505
+ };
506
+ } catch (err) {
507
+ const message = err.stderr?.toString() || err.message || String(err);
508
+ return { ok: false, error: `Failed to fetch PR status for ${url}: ${message}` };
509
+ }
510
+ }
511
+
396
512
  /**
397
513
  * Create a GitHub PR (draft or ready for review).
398
514
  * @param {{
@@ -442,7 +558,6 @@ export async function createPr({
442
558
  throw err;
443
559
  }
444
560
  }
445
-
446
561
  /**
447
562
  * Add a label to a GitHub issue via `gh`.
448
563
  * @param {number | string} issueNumber
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ 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, listGithubIssueStatuses } from './github.js';
6
+ import { parseIssueUrl, createPr, fetchGithubPrStatus, fetchIssueDetails, listGithubIssueStatuses } from './github.js';
7
7
  import {
8
8
  parseJiraIssueRef,
9
9
  resolveJiraCredentials,
@@ -172,6 +172,8 @@ export function normalizeReviewComments(body) {
172
172
  * deps?: {
173
173
  * pushBranch?: typeof pushBranch,
174
174
  * createPr?: typeof createPr,
175
+ * fetchGithubPrStatus?: typeof fetchGithubPrStatus,
176
+ * removeWorktree?: typeof removeWorktree,
175
177
  * getDiff?: typeof getDiff,
176
178
  * listChangedFiles?: typeof listChangedFiles,
177
179
  * applyFileExclusions?: typeof applyFileExclusions,
@@ -192,17 +194,37 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
192
194
  const app = express();
193
195
  const publicDir = path.join(__dirname, '..', 'public');
194
196
  app.use(express.json());
195
-
196
197
  const doPushBranch = deps.pushBranch || pushBranch;
197
198
  const doCreatePr = deps.createPr || createPr;
199
+ const doRemoveWorktree = deps.removeWorktree || removeWorktree;
198
200
  const doGetDiff = deps.getDiff || getDiff;
199
201
  const doListChangedFiles = deps.listChangedFiles || listChangedFiles;
200
202
  const doApplyFileExclusions = deps.applyFileExclusions || applyFileExclusions;
203
+ const doFetchGithubPrStatus = deps.fetchGithubPrStatus || fetchGithubPrStatus;
201
204
  const doCheckGhAuth = deps.checkGhAuth || checkGhAuth;
202
205
  const doCheckClaudeAuth = deps.checkClaudeAuth || checkClaudeAuth;
203
206
  const doListJiraBoardStatuses = deps.listJiraBoardStatuses || listJiraBoardStatuses;
204
207
  const doListGithubIssueStatuses = deps.listGithubIssueStatuses || listGithubIssueStatuses;
205
208
 
209
+ /** @type {Map<string, { at: number, value: object }>} */
210
+ const prStatusCache = new Map();
211
+
212
+ async function getPrStatusForJob(job) {
213
+ const prUrl = String(job?.prUrl || '').trim();
214
+ if (!prUrl) return null;
215
+
216
+ const cached = prStatusCache.get(prUrl);
217
+ const now = Date.now();
218
+ if (cached && now - cached.at < 15_000) {
219
+ return cached.value;
220
+ }
221
+
222
+ const result = await doFetchGithubPrStatus({ prUrl, cwd: repoRoot });
223
+ const value = result.ok ? { ok: true, ...result } : result;
224
+ prStatusCache.set(prUrl, { at: now, value });
225
+ return value;
226
+ }
227
+
206
228
  function formatAgentJobError(err) {
207
229
  return err instanceof Error ? err.message : String(err);
208
230
  }
@@ -613,10 +635,16 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
613
635
  }
614
636
  }
615
637
 
616
- app.get('/api/jobs', (_req, res) => {
638
+ app.get('/api/jobs', async (_req, res) => {
617
639
  try {
618
- // Backfill usage from logs for older jobs that never got a first-class field.
619
- res.json(store.getJobs().map(withJobUsage));
640
+ const jobs = await Promise.all(
641
+ store.getJobs().map(async (job) => {
642
+ const withUsage = withJobUsage(job);
643
+ const prStatus = await getPrStatusForJob(withUsage);
644
+ return prStatus ? { ...withUsage, prStatus } : withUsage;
645
+ })
646
+ );
647
+ res.json(jobs);
620
648
  } catch (err) {
621
649
  res.status(500).json({ error: err.message });
622
650
  }
@@ -1129,7 +1157,23 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
1129
1157
  );
1130
1158
  }
1131
1159
 
1132
- res.json(store.getJob(updated.id) || updated);
1160
+ const refreshed = store.getJob(updated.id) || updated;
1161
+ const wtId = worktreeIdForJob(refreshed);
1162
+ if (wtId != null && refreshed.worktreePath) {
1163
+ try {
1164
+ await doRemoveWorktree(repoRoot, wtId, refreshed.branchName);
1165
+ } catch (cleanupErr) {
1166
+ console.warn(
1167
+ `[acdev] removeWorktree after PR open failed for job ${refreshed.id}:`,
1168
+ cleanupErr instanceof Error ? cleanupErr.message : cleanupErr
1169
+ );
1170
+ }
1171
+ store.updateJob(refreshed.id, { worktreePath: undefined });
1172
+ }
1173
+
1174
+ const finalJob = withJobUsage(store.getJob(updated.id) || updated);
1175
+ const finalPrStatus = await getPrStatusForJob(finalJob);
1176
+ res.json(finalPrStatus ? { ...finalJob, prStatus: finalPrStatus } : finalJob);
1133
1177
  } catch (err) {
1134
1178
  const message = err instanceof Error ? err.message : String(err);
1135
1179
  store.updateJob(job.id, { status: 'failed', error: message });