acdev 1.0.14 → 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.0.14",
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 = '-';
@@ -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,62 @@ function sortedJobs() {
1001
1051
  );
1002
1052
  }
1003
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
+ }
1004
1110
  function formatTime(ts) {
1005
1111
  try {
1006
1112
  return new Date(ts).toLocaleTimeString([], {
@@ -2579,9 +2685,18 @@ function renderReview(jobs) {
2579
2685
  els.reviewGeneralComment.disabled = !editable;
2580
2686
  }
2581
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
+ }
2582
2699
  els.reviewTerminalSection.classList.toggle('hidden', !isTerminal);
2583
-
2584
- const prMeta = resolvePrMeta(job);
2585
2700
  if (document.activeElement !== els.prTitle && document.activeElement !== els.prBody) {
2586
2701
  els.prTitle.value = prMeta.prTitle;
2587
2702
  els.prBody.value = prMeta.prBody;
@@ -3799,12 +3914,11 @@ function knownToolsList(cfg = appConfig) {
3799
3914
  }
3800
3915
 
3801
3916
  /**
3802
- * Switch Settings top-level tab (Ticket source / Authentication / Rules / Configuration).
3803
- * @param {'ticket' | 'auth' | 'rules' | 'config'} tab
3917
+ * Switch Settings top-level tab (Ticket source / Authentication / Configuration).
3918
+ * @param {'ticket' | 'auth' | 'config'} tab
3804
3919
  */
3805
3920
  function setSettingsTab(tab) {
3806
- const next =
3807
- tab === 'auth' || tab === 'rules' || tab === 'config' ? tab : 'ticket';
3921
+ const next = tab === 'auth' || tab === 'config' ? tab : 'ticket';
3808
3922
  settingsTab = next;
3809
3923
 
3810
3924
  if (els.settingsTabs) {
@@ -3823,9 +3937,6 @@ function setSettingsTab(tab) {
3823
3937
  if (next === 'config') {
3824
3938
  void fetchModels();
3825
3939
  }
3826
- if (next === 'rules') {
3827
- void fetchRuleStatuses();
3828
- }
3829
3940
  }
3830
3941
 
3831
3942
  /**
@@ -4714,7 +4825,7 @@ els.settingsTabs?.addEventListener('click', (e) => {
4714
4825
  const btn = e.target.closest('[data-settings-tab]');
4715
4826
  if (!btn?.dataset.settingsTab) return;
4716
4827
  setSettingsTab(
4717
- /** @type {'ticket' | 'auth' | 'rules' | 'config'} */ (btn.dataset.settingsTab)
4828
+ /** @type {'ticket' | 'auth' | 'config'} */ (btn.dataset.settingsTab)
4718
4829
  );
4719
4830
  });
4720
4831
 
@@ -4738,7 +4849,7 @@ els.settingsTabs?.addEventListener('keydown', (e) => {
4738
4849
  e.preventDefault();
4739
4850
  const nextTab = tabs[nextIdx];
4740
4851
  setSettingsTab(
4741
- /** @type {'ticket' | 'auth' | 'rules' | 'config'} */ (nextTab.dataset.settingsTab)
4852
+ /** @type {'ticket' | 'auth' | 'config'} */ (nextTab.dataset.settingsTab)
4742
4853
  );
4743
4854
  nextTab.focus();
4744
4855
  });
@@ -4990,7 +5101,6 @@ els.jiraTestBtn?.addEventListener('click', async () => {
4990
5101
  }
4991
5102
  els.jiraStatus.textContent = `Connected as ${data.displayName || 'OK'}`;
4992
5103
  els.jiraStatus.className = 'jira-status ok';
4993
- if (settingsTab === 'rules') void fetchJiraRuleStatuses();
4994
5104
  } catch (err) {
4995
5105
  els.jiraStatus.textContent = err.message || 'Test failed';
4996
5106
  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,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;
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 });