acdev 1.0.0
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/.acdev/.env.example +13 -0
- package/README.md +231 -0
- package/bin/acdev.js +138 -0
- package/package.json +56 -0
- package/public/acdev_wordmark_logo.svg +10 -0
- package/public/app.js +3291 -0
- package/public/index.html +449 -0
- package/public/styles.css +1870 -0
- package/src/afterPrRules.js +116 -0
- package/src/agent.js +669 -0
- package/src/claude-auth.js +81 -0
- package/src/config.js +426 -0
- package/src/env.js +98 -0
- package/src/gh-auth.js +41 -0
- package/src/git.js +867 -0
- package/src/github.js +179 -0
- package/src/jira.js +418 -0
- package/src/paths.js +128 -0
- package/src/server.js +988 -0
- package/src/store.js +135 -0
- package/src/urls.js +16 -0
- package/src/usage.js +122 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { Store } from './store.js';
|
|
5
|
+
import { parseIssueUrl, createPr, fetchIssueDetails } from './github.js';
|
|
6
|
+
import {
|
|
7
|
+
parseJiraIssueRef,
|
|
8
|
+
resolveJiraCredentials,
|
|
9
|
+
testJiraConnection,
|
|
10
|
+
fetchJiraIssue,
|
|
11
|
+
mapJiraIssueType,
|
|
12
|
+
} from './jira.js';
|
|
13
|
+
import { applyAfterPrOpenedRules } from './afterPrRules.js';
|
|
14
|
+
import {
|
|
15
|
+
syncBaseBranch,
|
|
16
|
+
createWorktree,
|
|
17
|
+
removeWorktree,
|
|
18
|
+
getDiff,
|
|
19
|
+
pushBranch,
|
|
20
|
+
detectIssueType,
|
|
21
|
+
buildBranchName,
|
|
22
|
+
sanitizeBranchCommits,
|
|
23
|
+
listChangedFiles,
|
|
24
|
+
applyFileExclusions,
|
|
25
|
+
normalizeExcludedPaths,
|
|
26
|
+
validateFileSelection,
|
|
27
|
+
parseChangedFilesFromDiff,
|
|
28
|
+
} from './git.js';
|
|
29
|
+
import {
|
|
30
|
+
runAgentOnIssue,
|
|
31
|
+
runAgentOnReviewFeedback,
|
|
32
|
+
stripAiAttribution,
|
|
33
|
+
} from './agent.js';
|
|
34
|
+
import { publicConfig, updateConfig } from './config.js';
|
|
35
|
+
import { upsertEnvVars } from './env.js';
|
|
36
|
+
import { splitIssueUrls } from './urls.js';
|
|
37
|
+
import { usageFromLogs, withJobUsage } from './usage.js';
|
|
38
|
+
|
|
39
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
|
|
41
|
+
/** Statuses where clearing a job is refused (safer than force-fail). */
|
|
42
|
+
const IN_FLIGHT_STATUSES = new Set([
|
|
43
|
+
'running',
|
|
44
|
+
'syncing',
|
|
45
|
+
'preparing_worktree',
|
|
46
|
+
'applying_feedback',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** Statuses allowed for DELETE /api/jobs/:id */
|
|
50
|
+
const CLEARABLE_STATUSES = new Set([
|
|
51
|
+
'queued',
|
|
52
|
+
'awaiting_review',
|
|
53
|
+
'pr_opened',
|
|
54
|
+
'discarded',
|
|
55
|
+
'failed',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Worktree directory id: Jira key or GitHub issue number.
|
|
60
|
+
* @param {{ jiraKey?: string, issueNumber?: number }} job
|
|
61
|
+
* @returns {string | number | null}
|
|
62
|
+
*/
|
|
63
|
+
function worktreeIdForJob(job) {
|
|
64
|
+
if (job.jiraKey) return job.jiraKey;
|
|
65
|
+
if (job.issueNumber != null) return job.issueNumber;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Dedup key for an active job (GitHub URL or Jira key).
|
|
71
|
+
* @param {{ issueUrl?: string, jiraKey?: string, ticketSource?: string }} job
|
|
72
|
+
*/
|
|
73
|
+
function jobDedupeKey(job) {
|
|
74
|
+
if (job.ticketSource === 'jira' || job.jiraKey) {
|
|
75
|
+
return `jira:${String(job.jiraKey || '').toUpperCase()}`;
|
|
76
|
+
}
|
|
77
|
+
return `github:${job.issueUrl}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Normalize + validate POST /api/jobs/:id/review body.
|
|
81
|
+
* @param {unknown} body
|
|
82
|
+
* @returns {{
|
|
83
|
+
* ok: true,
|
|
84
|
+
* generalComment: string,
|
|
85
|
+
* lineComments: Array<{ path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>,
|
|
86
|
+
* } | { ok: false, error: string }}
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeReviewComments(body) {
|
|
89
|
+
const generalComment =
|
|
90
|
+
typeof body?.generalComment === 'string' ? body.generalComment.trim() : '';
|
|
91
|
+
|
|
92
|
+
if (body?.generalComment != null && typeof body.generalComment !== 'string') {
|
|
93
|
+
return { ok: false, error: 'generalComment must be a string' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rawLines = body?.lineComments;
|
|
97
|
+
if (rawLines != null && !Array.isArray(rawLines)) {
|
|
98
|
+
return { ok: false, error: 'lineComments must be an array' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @type {Array<{ path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>} */
|
|
102
|
+
const lineComments = [];
|
|
103
|
+
for (const item of rawLines || []) {
|
|
104
|
+
if (!item || typeof item !== 'object') {
|
|
105
|
+
return { ok: false, error: 'Each line comment must be an object' };
|
|
106
|
+
}
|
|
107
|
+
const pathStr = typeof item.path === 'string' ? item.path.trim() : '';
|
|
108
|
+
const commentBody = typeof item.body === 'string' ? item.body.trim() : '';
|
|
109
|
+
const side = item.side === 'LEFT' || item.side === 'RIGHT' ? item.side : null;
|
|
110
|
+
const line = Number(item.line);
|
|
111
|
+
if (!pathStr) {
|
|
112
|
+
return { ok: false, error: 'Each line comment requires a non-empty path' };
|
|
113
|
+
}
|
|
114
|
+
if (!commentBody) {
|
|
115
|
+
return { ok: false, error: 'Each line comment requires a non-empty body' };
|
|
116
|
+
}
|
|
117
|
+
if (!side) {
|
|
118
|
+
return { ok: false, error: 'Each line comment side must be LEFT or RIGHT' };
|
|
119
|
+
}
|
|
120
|
+
if (!Number.isInteger(line) || line < 1) {
|
|
121
|
+
return { ok: false, error: 'Each line comment requires a positive integer line' };
|
|
122
|
+
}
|
|
123
|
+
lineComments.push({ path: pathStr, line, side, body: commentBody });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!generalComment && lineComments.length === 0) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: 'At least one non-empty generalComment or lineComment is required',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { ok: true, generalComment, lineComments };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {{
|
|
138
|
+
* repoRoot: string,
|
|
139
|
+
* config: object,
|
|
140
|
+
* store: Store,
|
|
141
|
+
* useStubAgent?: boolean,
|
|
142
|
+
* deps?: {
|
|
143
|
+
* pushBranch?: typeof pushBranch,
|
|
144
|
+
* createPr?: typeof createPr,
|
|
145
|
+
* getDiff?: typeof getDiff,
|
|
146
|
+
* listChangedFiles?: typeof listChangedFiles,
|
|
147
|
+
* applyFileExclusions?: typeof applyFileExclusions,
|
|
148
|
+
* transitionJiraIssue?: Function,
|
|
149
|
+
* addIssueLabel?: Function,
|
|
150
|
+
* closeIssue?: Function,
|
|
151
|
+
* resolveJiraCredentials?: Function,
|
|
152
|
+
* },
|
|
153
|
+
* }} options
|
|
154
|
+
*/
|
|
155
|
+
export function createServer({ repoRoot, config, store, useStubAgent = false, deps = {} }) {
|
|
156
|
+
const app = express();
|
|
157
|
+
const publicDir = path.join(__dirname, '..', 'public');
|
|
158
|
+
app.use(express.json());
|
|
159
|
+
|
|
160
|
+
const doPushBranch = deps.pushBranch || pushBranch;
|
|
161
|
+
const doCreatePr = deps.createPr || createPr;
|
|
162
|
+
const doGetDiff = deps.getDiff || getDiff;
|
|
163
|
+
const doListChangedFiles = deps.listChangedFiles || listChangedFiles;
|
|
164
|
+
const doApplyFileExclusions = deps.applyFileExclusions || applyFileExclusions;
|
|
165
|
+
|
|
166
|
+
/** @type {Map<string, Set<import('http').ServerResponse>>} */
|
|
167
|
+
const subscribers = new Map();
|
|
168
|
+
let queueRunning = false;
|
|
169
|
+
|
|
170
|
+
function emitEvent(jobId, event) {
|
|
171
|
+
const subs = subscribers.get(jobId);
|
|
172
|
+
if (!subs) return;
|
|
173
|
+
const data = JSON.stringify(event);
|
|
174
|
+
for (const res of subs) {
|
|
175
|
+
res.write(`data: ${data}\n\n`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function appendLog(job, type, payload) {
|
|
180
|
+
const event = {
|
|
181
|
+
ts: new Date().toISOString(),
|
|
182
|
+
type,
|
|
183
|
+
payload,
|
|
184
|
+
};
|
|
185
|
+
const logs = [...(job.logs || []), event];
|
|
186
|
+
return store.updateJob(job.id, { logs });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function setStatus(jobId, status, extra = {}) {
|
|
190
|
+
if (!store.getJob(jobId)) return undefined;
|
|
191
|
+
const job = store.updateJob(jobId, { status, ...extra });
|
|
192
|
+
appendLog(job, 'status', status);
|
|
193
|
+
const updated = store.getJob(jobId);
|
|
194
|
+
if (updated?.logs?.length) {
|
|
195
|
+
emitEvent(jobId, updated.logs[updated.logs.length - 1]);
|
|
196
|
+
}
|
|
197
|
+
return updated;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function runJob(jobId) {
|
|
201
|
+
let job = store.getJob(jobId);
|
|
202
|
+
if (!job) return;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
job = setStatus(jobId, 'syncing');
|
|
206
|
+
if (!job) return;
|
|
207
|
+
await syncBaseBranch(repoRoot, config.baseBranch);
|
|
208
|
+
|
|
209
|
+
job = setStatus(jobId, 'preparing_worktree');
|
|
210
|
+
if (!job) return;
|
|
211
|
+
|
|
212
|
+
const isJira = job.ticketSource === 'jira' || Boolean(job.jiraKey);
|
|
213
|
+
let issueTitle;
|
|
214
|
+
let issueType;
|
|
215
|
+
/** @type {object | undefined} */
|
|
216
|
+
let jiraIssue;
|
|
217
|
+
|
|
218
|
+
if (isJira) {
|
|
219
|
+
const creds = resolveJiraCredentials({ configBaseUrl: config.jiraBaseUrl });
|
|
220
|
+
if ('error' in creds) {
|
|
221
|
+
throw new Error(creds.error);
|
|
222
|
+
}
|
|
223
|
+
const key = job.jiraKey || parseJiraIssueRef(job.issueUrl, { baseUrl: creds.baseUrl }).key;
|
|
224
|
+
jiraIssue = await fetchJiraIssue(key, creds);
|
|
225
|
+
issueTitle = jiraIssue.summary;
|
|
226
|
+
issueType = mapJiraIssueType(jiraIssue.issueType);
|
|
227
|
+
if (!store.getJob(jobId)) return;
|
|
228
|
+
// Prefer canonical browse URL from API
|
|
229
|
+
store.updateJob(jobId, {
|
|
230
|
+
issueUrl: jiraIssue.browseUrl,
|
|
231
|
+
jiraKey: jiraIssue.key,
|
|
232
|
+
ticketSource: 'jira',
|
|
233
|
+
});
|
|
234
|
+
job = store.getJob(jobId);
|
|
235
|
+
if (!job) return;
|
|
236
|
+
} else {
|
|
237
|
+
const issueDetails = await fetchIssueDetails(job.issueUrl, repoRoot);
|
|
238
|
+
issueTitle = issueDetails.title;
|
|
239
|
+
issueType = detectIssueType(issueDetails);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const desiredBranchName = buildBranchName(issueType, issueTitle);
|
|
243
|
+
const worktreeId = worktreeIdForJob(job);
|
|
244
|
+
if (worktreeId == null) {
|
|
245
|
+
throw new Error('Job is missing issueNumber / jiraKey for worktree path');
|
|
246
|
+
}
|
|
247
|
+
const { branchName, worktreePath } = await createWorktree(
|
|
248
|
+
repoRoot,
|
|
249
|
+
worktreeId,
|
|
250
|
+
config.baseBranch,
|
|
251
|
+
desiredBranchName
|
|
252
|
+
);
|
|
253
|
+
if (!store.getJob(jobId)) return;
|
|
254
|
+
store.updateJob(jobId, {
|
|
255
|
+
branchName,
|
|
256
|
+
worktreePath,
|
|
257
|
+
issueTitle,
|
|
258
|
+
issueType,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
job = setStatus(jobId, 'running');
|
|
262
|
+
if (!job) return;
|
|
263
|
+
const onEvent = (message) => {
|
|
264
|
+
const current = store.getJob(jobId);
|
|
265
|
+
if (!current) return;
|
|
266
|
+
const event = {
|
|
267
|
+
ts: new Date().toISOString(),
|
|
268
|
+
type: 'agent_event',
|
|
269
|
+
payload: message,
|
|
270
|
+
};
|
|
271
|
+
const logs = [...(current.logs || []), event];
|
|
272
|
+
store.updateJob(jobId, { logs });
|
|
273
|
+
emitEvent(jobId, event);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const { prTitle, prBody, usage } = await runAgentOnIssue({
|
|
277
|
+
issueUrl: job.issueUrl,
|
|
278
|
+
worktreePath,
|
|
279
|
+
config,
|
|
280
|
+
onEvent,
|
|
281
|
+
stub: useStubAgent,
|
|
282
|
+
branchName,
|
|
283
|
+
issueNumber: job.issueNumber,
|
|
284
|
+
ticketSource: isJira ? 'jira' : 'github',
|
|
285
|
+
jiraKey: job.jiraKey,
|
|
286
|
+
jiraIssue,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (!store.getJob(jobId)) return;
|
|
290
|
+
|
|
291
|
+
// Safety net: strip Co-Authored-By / Claude trailers before review & push.
|
|
292
|
+
await sanitizeBranchCommits(worktreePath, config.baseBranch, repoRoot);
|
|
293
|
+
|
|
294
|
+
if (!store.getJob(jobId)) return;
|
|
295
|
+
|
|
296
|
+
const diff = await doGetDiff(worktreePath, config.baseBranch);
|
|
297
|
+
const patch = {
|
|
298
|
+
diff,
|
|
299
|
+
prTitle: stripAiAttribution(prTitle),
|
|
300
|
+
prBody: stripAiAttribution(prBody),
|
|
301
|
+
};
|
|
302
|
+
if (usage) patch.usage = usage;
|
|
303
|
+
setStatus(jobId, 'awaiting_review', patch);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
306
|
+
const current = store.getJob(jobId);
|
|
307
|
+
if (!current) return;
|
|
308
|
+
appendLog(current, 'error', message);
|
|
309
|
+
const afterLog = store.getJob(jobId);
|
|
310
|
+
const usage = usageFromLogs(afterLog?.logs);
|
|
311
|
+
const failPatch = {
|
|
312
|
+
status: 'failed',
|
|
313
|
+
error: message,
|
|
314
|
+
};
|
|
315
|
+
if (usage) failPatch.usage = usage;
|
|
316
|
+
store.updateJob(jobId, failPatch);
|
|
317
|
+
const updated = store.getJob(jobId);
|
|
318
|
+
if (updated?.logs?.length) {
|
|
319
|
+
emitEvent(jobId, updated.logs[updated.logs.length - 1]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Apply human review feedback on the existing worktree/branch (no sync/recreate).
|
|
326
|
+
* @param {string} jobId
|
|
327
|
+
*/
|
|
328
|
+
async function runFeedbackJob(jobId) {
|
|
329
|
+
let job = store.getJob(jobId);
|
|
330
|
+
if (!job || job.status !== 'applying_feedback') return;
|
|
331
|
+
|
|
332
|
+
const feedback = job.pendingReviewFeedback;
|
|
333
|
+
if (!feedback || (!feedback.generalComment && !(feedback.lineComments || []).length)) {
|
|
334
|
+
const message = 'Missing review feedback payload';
|
|
335
|
+
appendLog(job, 'error', message);
|
|
336
|
+
store.updateJob(jobId, { status: 'failed', error: message, pendingReviewFeedback: undefined });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (!job.worktreePath) {
|
|
341
|
+
const message = 'Job has no worktree; cannot apply review feedback';
|
|
342
|
+
appendLog(job, 'error', message);
|
|
343
|
+
store.updateJob(jobId, { status: 'failed', error: message, pendingReviewFeedback: undefined });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const onEvent = (message) => {
|
|
349
|
+
const current = store.getJob(jobId);
|
|
350
|
+
if (!current) return;
|
|
351
|
+
const event = {
|
|
352
|
+
ts: new Date().toISOString(),
|
|
353
|
+
type: 'agent_event',
|
|
354
|
+
payload: message,
|
|
355
|
+
};
|
|
356
|
+
const logs = [...(current.logs || []), event];
|
|
357
|
+
store.updateJob(jobId, { logs });
|
|
358
|
+
emitEvent(jobId, event);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const { prTitle, prBody, usage } = await runAgentOnReviewFeedback({
|
|
362
|
+
issueUrl: job.issueUrl,
|
|
363
|
+
worktreePath: job.worktreePath,
|
|
364
|
+
config,
|
|
365
|
+
generalComment: feedback.generalComment || '',
|
|
366
|
+
lineComments: feedback.lineComments || [],
|
|
367
|
+
onEvent,
|
|
368
|
+
stub: useStubAgent,
|
|
369
|
+
branchName: job.branchName,
|
|
370
|
+
issueNumber: job.issueNumber,
|
|
371
|
+
ticketSource: job.ticketSource === 'jira' || job.jiraKey ? 'jira' : 'github',
|
|
372
|
+
jiraKey: job.jiraKey,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (!store.getJob(jobId)) return;
|
|
376
|
+
|
|
377
|
+
await sanitizeBranchCommits(job.worktreePath, config.baseBranch, repoRoot);
|
|
378
|
+
|
|
379
|
+
if (!store.getJob(jobId)) return;
|
|
380
|
+
|
|
381
|
+
const diff = await doGetDiff(job.worktreePath, config.baseBranch);
|
|
382
|
+
const patch = {
|
|
383
|
+
diff,
|
|
384
|
+
prTitle: stripAiAttribution(prTitle),
|
|
385
|
+
prBody: stripAiAttribution(prBody),
|
|
386
|
+
pendingReviewFeedback: undefined,
|
|
387
|
+
};
|
|
388
|
+
if (usage) patch.usage = usage;
|
|
389
|
+
setStatus(jobId, 'awaiting_review', patch);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
392
|
+
const current = store.getJob(jobId);
|
|
393
|
+
if (!current) return;
|
|
394
|
+
appendLog(current, 'error', message);
|
|
395
|
+
const afterLog = store.getJob(jobId);
|
|
396
|
+
const usage = usageFromLogs(afterLog?.logs);
|
|
397
|
+
const failPatch = {
|
|
398
|
+
status: 'failed',
|
|
399
|
+
error: message,
|
|
400
|
+
pendingReviewFeedback: undefined,
|
|
401
|
+
};
|
|
402
|
+
if (usage) failPatch.usage = usage;
|
|
403
|
+
store.updateJob(jobId, failPatch);
|
|
404
|
+
const updated = store.getJob(jobId);
|
|
405
|
+
if (updated?.logs?.length) {
|
|
406
|
+
emitEvent(jobId, updated.logs[updated.logs.length - 1]);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function processQueue() {
|
|
412
|
+
if (queueRunning) return;
|
|
413
|
+
queueRunning = true;
|
|
414
|
+
try {
|
|
415
|
+
while (true) {
|
|
416
|
+
// Prefer review-feedback rework over new queued issues (same mutex).
|
|
417
|
+
const feedback = store.getJobs().find((j) => j.status === 'applying_feedback');
|
|
418
|
+
if (feedback) {
|
|
419
|
+
await runFeedbackJob(feedback.id);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const queued = store.getJobs().find((j) => j.status === 'queued');
|
|
423
|
+
if (!queued) break;
|
|
424
|
+
await runJob(queued.id);
|
|
425
|
+
}
|
|
426
|
+
} catch (err) {
|
|
427
|
+
console.error('[acdev] Queue runner error:', err);
|
|
428
|
+
} finally {
|
|
429
|
+
queueRunning = false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
app.get('/api/jobs', (_req, res) => {
|
|
434
|
+
try {
|
|
435
|
+
// Backfill usage from logs for older jobs that never got a first-class field.
|
|
436
|
+
res.json(store.getJobs().map(withJobUsage));
|
|
437
|
+
} catch (err) {
|
|
438
|
+
res.status(500).json({ error: err.message });
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
app.get('/api/config', (_req, res) => {
|
|
443
|
+
try {
|
|
444
|
+
res.json(publicConfig(config, { repoRoot }));
|
|
445
|
+
} catch (err) {
|
|
446
|
+
res.status(500).json({ error: err.message });
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
app.patch('/api/config', (req, res) => {
|
|
451
|
+
try {
|
|
452
|
+
const patch = req.body || {};
|
|
453
|
+
|
|
454
|
+
// Jira secrets → .env (never persisted in config.json)
|
|
455
|
+
/** @type {Record<string, string | null>} */
|
|
456
|
+
const envPatch = {};
|
|
457
|
+
if (patch.jiraEmail !== undefined) {
|
|
458
|
+
const email =
|
|
459
|
+
typeof patch.jiraEmail === 'string' ? patch.jiraEmail.trim() : '';
|
|
460
|
+
envPatch.JIRA_EMAIL = email || null;
|
|
461
|
+
}
|
|
462
|
+
if (patch.jiraApiToken !== undefined) {
|
|
463
|
+
const token =
|
|
464
|
+
typeof patch.jiraApiToken === 'string' ? patch.jiraApiToken.trim() : '';
|
|
465
|
+
// Empty string means "leave unchanged" when token already set — only clear if null
|
|
466
|
+
if (patch.jiraApiToken === null) {
|
|
467
|
+
envPatch.JIRA_API_TOKEN = null;
|
|
468
|
+
} else if (token) {
|
|
469
|
+
envPatch.JIRA_API_TOKEN = token;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (patch.jiraBaseUrl !== undefined && typeof patch.jiraBaseUrl === 'string') {
|
|
473
|
+
// Also mirror base URL into env for convenience when set via Settings
|
|
474
|
+
const trimmed = patch.jiraBaseUrl.trim();
|
|
475
|
+
if (trimmed) {
|
|
476
|
+
envPatch.JIRA_BASE_URL = trimmed;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (Object.keys(envPatch).length > 0) {
|
|
480
|
+
upsertEnvVars(repoRoot, envPatch);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const {
|
|
484
|
+
jiraEmail: _e,
|
|
485
|
+
jiraApiToken: _t,
|
|
486
|
+
...configPatch
|
|
487
|
+
} = patch;
|
|
488
|
+
updateConfig(repoRoot, config, configPatch);
|
|
489
|
+
res.json(publicConfig(config, { repoRoot }));
|
|
490
|
+
} catch (err) {
|
|
491
|
+
res.status(400).json({ error: err.message });
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
app.post('/api/jira/test', async (req, res) => {
|
|
496
|
+
try {
|
|
497
|
+
const body = req.body || {};
|
|
498
|
+
const creds = resolveJiraCredentials({
|
|
499
|
+
baseUrl: body.baseUrl || body.jiraBaseUrl,
|
|
500
|
+
email: body.email || body.jiraEmail,
|
|
501
|
+
apiToken: body.apiToken || body.jiraApiToken,
|
|
502
|
+
configBaseUrl: config.jiraBaseUrl,
|
|
503
|
+
});
|
|
504
|
+
if ('error' in creds) {
|
|
505
|
+
return res.status(400).json({ ok: false, error: creds.error });
|
|
506
|
+
}
|
|
507
|
+
const result = await testJiraConnection(creds);
|
|
508
|
+
if (!result.ok) {
|
|
509
|
+
return res.status(400).json(result);
|
|
510
|
+
}
|
|
511
|
+
res.json(result);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
app.post('/api/issues', (req, res) => {
|
|
518
|
+
try {
|
|
519
|
+
const urls = splitIssueUrls(req.body?.urls);
|
|
520
|
+
if (urls.length === 0) {
|
|
521
|
+
return res.status(400).json({
|
|
522
|
+
error:
|
|
523
|
+
'Request body must include a non-empty "urls" array (or a comma/newline-separated string).',
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const ticketSource =
|
|
528
|
+
req.body?.ticketSource === 'jira' || req.body?.ticketSource === 'github'
|
|
529
|
+
? req.body.ticketSource
|
|
530
|
+
: config.ticketSource === 'jira'
|
|
531
|
+
? 'jira'
|
|
532
|
+
: 'github';
|
|
533
|
+
|
|
534
|
+
if (ticketSource === 'jira') {
|
|
535
|
+
const creds = resolveJiraCredentials({ configBaseUrl: config.jiraBaseUrl });
|
|
536
|
+
if ('error' in creds) {
|
|
537
|
+
return res.status(400).json({
|
|
538
|
+
error: creds.error,
|
|
539
|
+
code: 'jira_not_configured',
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const invalidUrls = [];
|
|
545
|
+
/** @type {Array<{ url: string, ticketSource: 'github' | 'jira', number?: number, jiraKey?: string }>} */
|
|
546
|
+
const parsed = [];
|
|
547
|
+
|
|
548
|
+
for (const raw of urls) {
|
|
549
|
+
try {
|
|
550
|
+
if (ticketSource === 'jira') {
|
|
551
|
+
const ref = parseJiraIssueRef(raw, {
|
|
552
|
+
baseUrl: config.jiraBaseUrl || process.env.JIRA_BASE_URL,
|
|
553
|
+
});
|
|
554
|
+
parsed.push({
|
|
555
|
+
url: ref.browseUrl.startsWith('http') ? ref.browseUrl : ref.key,
|
|
556
|
+
ticketSource: 'jira',
|
|
557
|
+
jiraKey: ref.key,
|
|
558
|
+
});
|
|
559
|
+
} else {
|
|
560
|
+
// Auto-detect Jira-looking refs even when source is github? Prefer strict
|
|
561
|
+
// per ticketSource, but allow obvious browse URLs if they look like Jira.
|
|
562
|
+
if (/atlassian\.net\/browse\//i.test(raw) || /^[A-Z][A-Z0-9]+-\d+$/i.test(raw.trim())) {
|
|
563
|
+
invalidUrls.push(raw);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const gh = parseIssueUrl(raw);
|
|
567
|
+
parsed.push({
|
|
568
|
+
url: raw,
|
|
569
|
+
ticketSource: 'github',
|
|
570
|
+
number: gh.number,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
} catch {
|
|
574
|
+
invalidUrls.push(raw);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (invalidUrls.length > 0) {
|
|
579
|
+
return res.status(400).json({
|
|
580
|
+
error:
|
|
581
|
+
ticketSource === 'jira'
|
|
582
|
+
? 'One or more values are not valid Jira issue keys or browse URLs.'
|
|
583
|
+
: 'One or more URLs are not valid GitHub issue URLs. Switch ticket source to Jira in Settings to enqueue Jira issues.',
|
|
584
|
+
invalidUrls,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const existing = store.getJobs();
|
|
589
|
+
const activeStatuses = new Set([
|
|
590
|
+
'queued',
|
|
591
|
+
'syncing',
|
|
592
|
+
'preparing_worktree',
|
|
593
|
+
'running',
|
|
594
|
+
'applying_feedback',
|
|
595
|
+
'awaiting_review',
|
|
596
|
+
'pr_opened',
|
|
597
|
+
]);
|
|
598
|
+
const skipped = [];
|
|
599
|
+
const created = [];
|
|
600
|
+
|
|
601
|
+
for (const item of parsed) {
|
|
602
|
+
const key = jobDedupeKey({
|
|
603
|
+
issueUrl: item.url,
|
|
604
|
+
jiraKey: item.jiraKey,
|
|
605
|
+
ticketSource: item.ticketSource,
|
|
606
|
+
});
|
|
607
|
+
const duplicate = existing.find(
|
|
608
|
+
(j) =>
|
|
609
|
+
activeStatuses.has(j.status) &&
|
|
610
|
+
jobDedupeKey(j) === key
|
|
611
|
+
);
|
|
612
|
+
if (duplicate) {
|
|
613
|
+
skipped.push(item.jiraKey || item.url);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const job = store.addJob({
|
|
617
|
+
issueUrl: item.url,
|
|
618
|
+
issueNumber: item.number,
|
|
619
|
+
ticketSource: item.ticketSource,
|
|
620
|
+
jiraKey: item.jiraKey,
|
|
621
|
+
});
|
|
622
|
+
created.push(job);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
res.json({ jobs: created, skipped });
|
|
626
|
+
processQueue().catch((err) => console.error('[acdev] processQueue failed:', err));
|
|
627
|
+
} catch (err) {
|
|
628
|
+
res.status(500).json({ error: err.message });
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
app.get('/api/jobs/:id/events', (req, res) => {
|
|
633
|
+
try {
|
|
634
|
+
const job = store.getJob(req.params.id);
|
|
635
|
+
if (!job) {
|
|
636
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
640
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
641
|
+
res.setHeader('Connection', 'keep-alive');
|
|
642
|
+
res.flushHeaders?.();
|
|
643
|
+
|
|
644
|
+
for (const event of job.logs || []) {
|
|
645
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (!subscribers.has(job.id)) {
|
|
649
|
+
subscribers.set(job.id, new Set());
|
|
650
|
+
}
|
|
651
|
+
subscribers.get(job.id).add(res);
|
|
652
|
+
|
|
653
|
+
req.on('close', () => {
|
|
654
|
+
subscribers.get(job.id)?.delete(res);
|
|
655
|
+
});
|
|
656
|
+
} catch (err) {
|
|
657
|
+
if (!res.headersSent) {
|
|
658
|
+
res.status(500).json({ error: err.message });
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
app.get('/api/jobs/:id/files', async (req, res) => {
|
|
664
|
+
try {
|
|
665
|
+
const job = store.getJob(req.params.id);
|
|
666
|
+
if (!job) {
|
|
667
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
let files = [];
|
|
671
|
+
if (job.worktreePath) {
|
|
672
|
+
try {
|
|
673
|
+
files = await doListChangedFiles(job.worktreePath, config.baseBranch);
|
|
674
|
+
} catch (err) {
|
|
675
|
+
// Fall back to parsing the stored unified diff.
|
|
676
|
+
files = parseChangedFilesFromDiff(job.diff || '');
|
|
677
|
+
if (files.length === 0) {
|
|
678
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
679
|
+
return res.status(500).json({ error: message });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
} else {
|
|
683
|
+
files = parseChangedFilesFromDiff(job.diff || '');
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
res.json({ files, baseBranch: config.baseBranch });
|
|
687
|
+
} catch (err) {
|
|
688
|
+
res.status(500).json({ error: err.message });
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
app.post('/api/jobs/:id/review', (req, res) => {
|
|
693
|
+
try {
|
|
694
|
+
const job = store.getJob(req.params.id);
|
|
695
|
+
if (!job) {
|
|
696
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
697
|
+
}
|
|
698
|
+
if (job.status !== 'awaiting_review') {
|
|
699
|
+
return res.status(400).json({
|
|
700
|
+
error: `Job is not awaiting review (status: ${job.status})`,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
if (!job.worktreePath) {
|
|
704
|
+
return res.status(400).json({
|
|
705
|
+
error: 'Job has no worktree; cannot apply review feedback',
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const normalized = normalizeReviewComments(req.body);
|
|
710
|
+
if (!normalized.ok) {
|
|
711
|
+
return res.status(400).json({ error: normalized.error });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const batch = {
|
|
715
|
+
generalComment: normalized.generalComment,
|
|
716
|
+
lineComments: normalized.lineComments,
|
|
717
|
+
submittedAt: new Date().toISOString(),
|
|
718
|
+
};
|
|
719
|
+
const history = [...(job.reviewComments || []), batch];
|
|
720
|
+
|
|
721
|
+
const updated = setStatus(job.id, 'applying_feedback', {
|
|
722
|
+
pendingReviewFeedback: {
|
|
723
|
+
generalComment: normalized.generalComment,
|
|
724
|
+
lineComments: normalized.lineComments,
|
|
725
|
+
},
|
|
726
|
+
latestReviewComments: batch,
|
|
727
|
+
reviewComments: history,
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
res.json(withJobUsage(updated));
|
|
731
|
+
processQueue().catch((err) => console.error('[acdev] processQueue failed:', err));
|
|
732
|
+
} catch (err) {
|
|
733
|
+
res.status(500).json({ error: err.message });
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
app.post('/api/jobs/:id/approve', async (req, res) => {
|
|
738
|
+
try {
|
|
739
|
+
const job = store.getJob(req.params.id);
|
|
740
|
+
if (!job) {
|
|
741
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
742
|
+
}
|
|
743
|
+
if (job.status !== 'awaiting_review') {
|
|
744
|
+
return res.status(400).json({ error: `Job is not awaiting review (status: ${job.status})` });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const prTitle = stripAiAttribution(req.body?.prTitle ?? job.prTitle ?? '');
|
|
748
|
+
const prBody = stripAiAttribution(req.body?.prBody ?? job.prBody ?? '');
|
|
749
|
+
// Default true preserves prior draft-PR behavior when clients omit `draft`.
|
|
750
|
+
const draft = req.body?.draft !== false && req.body?.draft !== 'false';
|
|
751
|
+
|
|
752
|
+
const excludedNorm = normalizeExcludedPaths(req.body?.excludedPaths);
|
|
753
|
+
if (!excludedNorm.ok) {
|
|
754
|
+
return res.status(400).json({ error: excludedNorm.error });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
let working = job;
|
|
759
|
+
|
|
760
|
+
if (excludedNorm.paths.length > 0) {
|
|
761
|
+
if (!job.worktreePath) {
|
|
762
|
+
return res.status(400).json({
|
|
763
|
+
error: 'Job has no worktree; cannot apply file exclusions',
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
let changedFiles;
|
|
768
|
+
try {
|
|
769
|
+
changedFiles = await doListChangedFiles(job.worktreePath, config.baseBranch);
|
|
770
|
+
} catch {
|
|
771
|
+
changedFiles = parseChangedFilesFromDiff(job.diff || '');
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const selection = validateFileSelection(changedFiles, excludedNorm.paths);
|
|
775
|
+
if (!selection.ok) {
|
|
776
|
+
return res.status(400).json({ error: selection.error });
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (selection.excluded.length > 0) {
|
|
780
|
+
await doApplyFileExclusions(
|
|
781
|
+
job.worktreePath,
|
|
782
|
+
config.baseBranch,
|
|
783
|
+
selection.excluded
|
|
784
|
+
);
|
|
785
|
+
const diff = await doGetDiff(job.worktreePath, config.baseBranch);
|
|
786
|
+
working = store.updateJob(job.id, { diff });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
await doPushBranch(working.worktreePath, working.branchName);
|
|
791
|
+
const prUrl = await doCreatePr({
|
|
792
|
+
worktreePath: working.worktreePath,
|
|
793
|
+
branchName: working.branchName,
|
|
794
|
+
baseBranch: config.baseBranch,
|
|
795
|
+
title: prTitle,
|
|
796
|
+
body: prBody,
|
|
797
|
+
draft,
|
|
798
|
+
});
|
|
799
|
+
const updated = store.updateJob(working.id, {
|
|
800
|
+
status: 'pr_opened',
|
|
801
|
+
prUrl,
|
|
802
|
+
prTitle,
|
|
803
|
+
prBody,
|
|
804
|
+
prDraft: draft,
|
|
805
|
+
});
|
|
806
|
+
appendLog(updated, 'status', 'pr_opened');
|
|
807
|
+
|
|
808
|
+
// Best-effort ticket rules — never fail approve after PR success
|
|
809
|
+
try {
|
|
810
|
+
await applyAfterPrOpenedRules({
|
|
811
|
+
job: store.getJob(updated.id) || updated,
|
|
812
|
+
config,
|
|
813
|
+
repoRoot,
|
|
814
|
+
appendLog: (j, type, payload) => appendLog(j, type, payload),
|
|
815
|
+
deps: {
|
|
816
|
+
transitionJiraIssue: deps.transitionJiraIssue,
|
|
817
|
+
addIssueLabel: deps.addIssueLabel,
|
|
818
|
+
closeIssue: deps.closeIssue,
|
|
819
|
+
resolveJiraCredentials: deps.resolveJiraCredentials,
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
} catch (ruleErr) {
|
|
823
|
+
const message =
|
|
824
|
+
ruleErr instanceof Error ? ruleErr.message : String(ruleErr);
|
|
825
|
+
console.warn(
|
|
826
|
+
`[acdev] Post-PR rule unexpected error (PR still opened): ${message}`
|
|
827
|
+
);
|
|
828
|
+
appendLog(
|
|
829
|
+
store.getJob(updated.id) || updated,
|
|
830
|
+
'warn',
|
|
831
|
+
`Post-PR ticket rule failed (PR still opened): ${message}`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
res.json(store.getJob(updated.id) || updated);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
838
|
+
store.updateJob(job.id, { status: 'failed', error: message });
|
|
839
|
+
res.status(500).json({ error: message });
|
|
840
|
+
}
|
|
841
|
+
} catch (err) {
|
|
842
|
+
res.status(500).json({ error: err.message });
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
app.post('/api/jobs/:id/reject', async (req, res) => {
|
|
847
|
+
try {
|
|
848
|
+
const job = store.getJob(req.params.id);
|
|
849
|
+
if (!job) {
|
|
850
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (job.status === 'discarded') {
|
|
854
|
+
return res.json(job);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const wtId = worktreeIdForJob(job);
|
|
858
|
+
if (wtId != null) {
|
|
859
|
+
await removeWorktree(repoRoot, wtId, job.branchName);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const updated = store.updateJob(job.id, { status: 'discarded' });
|
|
863
|
+
appendLog(updated, 'status', 'discarded');
|
|
864
|
+
res.json(store.getJob(job.id));
|
|
865
|
+
} catch (err) {
|
|
866
|
+
res.status(500).json({ error: err.message });
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Wipe a job from history (unlike Reject, which keeps a discarded record).
|
|
872
|
+
* Refuses while the job is in-flight.
|
|
873
|
+
*/
|
|
874
|
+
app.delete('/api/jobs/:id', async (req, res) => {
|
|
875
|
+
try {
|
|
876
|
+
const job = store.getJob(req.params.id);
|
|
877
|
+
if (!job) {
|
|
878
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (IN_FLIGHT_STATUSES.has(job.status)) {
|
|
882
|
+
return res.status(409).json({
|
|
883
|
+
error: `Cannot clear job while it is in progress (status: ${job.status}). Wait for it to finish or fail first.`,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (!CLEARABLE_STATUSES.has(job.status)) {
|
|
888
|
+
return res.status(409).json({
|
|
889
|
+
error: `Cannot clear job with status: ${job.status}`,
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const wtId = worktreeIdForJob(job);
|
|
894
|
+
if (wtId != null) {
|
|
895
|
+
try {
|
|
896
|
+
await removeWorktree(repoRoot, wtId, job.branchName);
|
|
897
|
+
} catch (err) {
|
|
898
|
+
console.warn(
|
|
899
|
+
`[acdev] removeWorktree during clear failed for job ${job.id}:`,
|
|
900
|
+
err instanceof Error ? err.message : err
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
store.deleteJob(job.id);
|
|
906
|
+
subscribers.delete(job.id);
|
|
907
|
+
res.json({ ok: true, id: job.id });
|
|
908
|
+
} catch (err) {
|
|
909
|
+
res.status(500).json({ error: err.message });
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
app.post('/api/jobs/:id/retry', (req, res) => {
|
|
914
|
+
try {
|
|
915
|
+
const job = store.getJob(req.params.id);
|
|
916
|
+
if (!job) {
|
|
917
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
918
|
+
}
|
|
919
|
+
if (job.status !== 'failed' && job.status !== 'discarded') {
|
|
920
|
+
return res.status(400).json({
|
|
921
|
+
error: `Retry is only allowed for failed or discarded jobs (status: ${job.status})`,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
let updated = store.updateJob(job.id, {
|
|
926
|
+
status: 'queued',
|
|
927
|
+
error: undefined,
|
|
928
|
+
diff: undefined,
|
|
929
|
+
prUrl: undefined,
|
|
930
|
+
branchName: undefined,
|
|
931
|
+
worktreePath: undefined,
|
|
932
|
+
prTitle: undefined,
|
|
933
|
+
prBody: undefined,
|
|
934
|
+
usage: undefined,
|
|
935
|
+
pendingReviewFeedback: undefined,
|
|
936
|
+
latestReviewComments: undefined,
|
|
937
|
+
});
|
|
938
|
+
updated = appendLog(updated, 'status', 'retry queued');
|
|
939
|
+
res.json(updated);
|
|
940
|
+
processQueue().catch((err) => console.error('[acdev] processQueue failed:', err));
|
|
941
|
+
} catch (err) {
|
|
942
|
+
res.status(500).json({ error: err.message });
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// Unknown /api/* must return JSON — never Express HTML or the SPA shell.
|
|
947
|
+
app.use('/api', (req, res) => {
|
|
948
|
+
res.status(404).json({ error: `Cannot ${req.method} ${req.originalUrl}` });
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
// Static + SPA after API routes so /api never falls through to index.html.
|
|
952
|
+
app.use(express.static(publicDir));
|
|
953
|
+
app.get('*', (_req, res) => {
|
|
954
|
+
res.sendFile(path.join(publicDir, 'index.html'));
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
// JSON body / unexpected errors on API paths.
|
|
958
|
+
app.use((err, req, res, next) => {
|
|
959
|
+
if (!req.path?.startsWith('/api') && !req.originalUrl?.startsWith('/api')) {
|
|
960
|
+
return next(err);
|
|
961
|
+
}
|
|
962
|
+
const status = err.status || err.statusCode || 500;
|
|
963
|
+
res.status(status).json({ error: err.message || 'Internal Server Error' });
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
return { app, processQueue };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* @param {import('http').Server} server
|
|
971
|
+
* @param {number} port
|
|
972
|
+
*/
|
|
973
|
+
export function listen(server, port) {
|
|
974
|
+
return new Promise((resolve, reject) => {
|
|
975
|
+
server.on('error', (err) => {
|
|
976
|
+
if (err.code === 'EADDRINUSE') {
|
|
977
|
+
reject(
|
|
978
|
+
new Error(
|
|
979
|
+
`Port ${port} is already in use. Try: ACDEV_PORT=${port + 1} acdev`
|
|
980
|
+
)
|
|
981
|
+
);
|
|
982
|
+
} else {
|
|
983
|
+
reject(err);
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
server.listen(port, () => resolve(undefined));
|
|
987
|
+
});
|
|
988
|
+
}
|