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 +2 -2
- package/package.json +1 -1
- package/public/app.js +281 -26
- package/public/index.html +8 -1
- package/public/styles.css +94 -1
- package/src/github.js +116 -1
- package/src/server.js +50 -6
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)
|
|
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,
|
|
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
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' | '
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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('&', '&')
|
|
1061
|
+
.replaceAll('<', '<')
|
|
1062
|
+
.replaceAll('>', '>')
|
|
1063
|
+
.replaceAll('"', '"')
|
|
1064
|
+
.replaceAll("'", ''');
|
|
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
|
|
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
|
|
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 /
|
|
3803
|
-
* @param {'ticket' | 'auth' | '
|
|
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' | '
|
|
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' | '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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 });
|