@magnusekdahl/parallix 1.2.1 → 1.3.1

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.
Files changed (81) hide show
  1. package/index.js +190 -185
  2. package/lib/agents/agents.js +34 -4
  3. package/lib/agents/agents.ts +40 -5
  4. package/lib/commands/active.js +502 -579
  5. package/lib/commands/active.ts +665 -0
  6. package/lib/commands/checkpoint.js +98 -74
  7. package/lib/commands/checkpoint.ts +79 -0
  8. package/lib/commands/config.js +61 -31
  9. package/lib/commands/config.ts +47 -0
  10. package/lib/commands/coverage-gate.js +271 -246
  11. package/lib/commands/coverage-gate.ts +362 -0
  12. package/lib/commands/diff.js +140 -119
  13. package/lib/commands/diff.ts +125 -0
  14. package/lib/commands/draft.js +770 -923
  15. package/lib/commands/draft.ts +1021 -0
  16. package/lib/commands/handoff.js +588 -608
  17. package/lib/commands/handoff.ts +694 -0
  18. package/lib/commands/integrate.js +1331 -1468
  19. package/lib/commands/integrate.ts +1688 -0
  20. package/lib/commands/mission-start.js +279 -237
  21. package/lib/commands/mission-start.ts +263 -0
  22. package/lib/commands/rebase.js +606 -582
  23. package/lib/commands/rebase.ts +635 -0
  24. package/lib/commands/repair-handoff.js +181 -179
  25. package/lib/commands/repair-handoff.ts +228 -0
  26. package/lib/commands/resolve-conflict.js +121 -102
  27. package/lib/commands/resolve-conflict.ts +112 -0
  28. package/lib/commands/review.js +3 -4
  29. package/lib/commands/review.ts +14 -0
  30. package/lib/commands/setup-review.js +4 -5
  31. package/lib/commands/setup-review.ts +14 -0
  32. package/lib/commands/setup.js +3 -3
  33. package/lib/commands/setup.ts +2 -0
  34. package/lib/commands/stats-backfill.js +394 -359
  35. package/lib/commands/stats-backfill.ts +418 -0
  36. package/lib/commands/stats.js +1424 -1665
  37. package/lib/commands/stats.ts +2186 -0
  38. package/lib/commands/status.js +219 -200
  39. package/lib/commands/status.ts +219 -0
  40. package/lib/commands/verify.js +3 -1
  41. package/lib/commands/verify.ts +2 -0
  42. package/lib/core/nels.js +112 -127
  43. package/lib/core/nels.ts +189 -0
  44. package/lib/core/subagent-limit.js +15 -16
  45. package/lib/core/subagent-limit.ts +25 -0
  46. package/lib/index.js +142 -59
  47. package/lib/index.ts +134 -0
  48. package/lib/review/rebase.js +142 -145
  49. package/lib/review/rebase.ts +188 -0
  50. package/lib/review/review-adapter.js +99 -87
  51. package/lib/review/review-adapter.ts +198 -0
  52. package/lib/review/review-artifacts.js +486 -629
  53. package/lib/review/review-artifacts.ts +622 -0
  54. package/lib/review/review-commands.js +1220 -1373
  55. package/lib/review/review-commands.ts +1579 -0
  56. package/lib/review/review-events.js +544 -886
  57. package/lib/review/review-events.ts +954 -0
  58. package/lib/review/review-loop.js +945 -1020
  59. package/lib/review/review-loop.ts +1174 -0
  60. package/lib/review/review-polling.js +86 -115
  61. package/lib/review/review-polling.ts +194 -0
  62. package/lib/review/review-prompts.js +154 -189
  63. package/lib/review/review-prompts.ts +182 -0
  64. package/lib/review/review-state.js +246 -225
  65. package/lib/review/review-state.ts +353 -0
  66. package/lib/review/review.js +63 -84
  67. package/lib/review/review.ts +138 -0
  68. package/lib/tools/backlog.js +694 -680
  69. package/lib/tools/backlog.ts +835 -0
  70. package/lib/tools/forgejo.js +1270 -1405
  71. package/lib/tools/forgejo.ts +1793 -0
  72. package/lib/tools/gatekeeper.js +106 -96
  73. package/lib/tools/gatekeeper.ts +124 -0
  74. package/lib/tools/redgreen.js +200 -181
  75. package/lib/tools/redgreen.ts +221 -0
  76. package/lib/tools/sessions.js +58 -52
  77. package/lib/tools/sessions.ts +81 -0
  78. package/lib/tools/setup-review.js +902 -975
  79. package/lib/tools/setup-review.ts +1152 -0
  80. package/package.json +4 -4
  81. package/px.js +226 -208
@@ -0,0 +1,1793 @@
1
+ import * as fs from 'fs';
2
+ import * as http from 'http';
3
+ import * as https from 'https';
4
+ import * as path from 'path';
5
+ import { spawnSync } from 'child_process';
6
+ import { git } from '../core/git.js'; // NOTE: Original JS used `const git = require('../core/git')` and called `git.git(...)`. TS migration extracts the callable `git` function directly; `git(args, opts)` is functionally equivalent to the old `git.git(args, opts)`.
7
+ import { getPrimaryBranch, getPrimaryWorktree, resolveMissionBaseBranch } from '../core/mission-utils.js';
8
+ import { resolveReviewAdapter } from '../core/product-config.js';
9
+ import { getTaskImplementer, findTaskFile } from './backlog.js';
10
+ import * as verification from '../core/verification.js';
11
+ import * as fmt from '../core/fmt.js';
12
+
13
+ const DISPOSITION_PATTERN = /Autonomous review disposition:\s*(CHANGES_MADE|PUSHBACK_ALL|PARKED|BLOCKED)/;
14
+
15
+ const HTTP_REQUEST_TIMEOUT = 5000;
16
+ const DEFAULT_FORGEJO_USER = 'human';
17
+ const noopLog = () => {};
18
+ const derivedRepoCache = new Map();
19
+ function codexSandboxHint() {
20
+ return 'Codex runtime cannot reach local Forgejo from Node subprocesses. Use the repo-local Codex config/profile that allows the workflow network path.';
21
+ }
22
+ /** @param {string} rootDir @param {string} remoteName @returns {string} */
23
+ function cacheKey(rootDir: string, remoteName: string): string {
24
+ return `${rootDir}::${remoteName}`;
25
+ }
26
+ /** @param {string} rootDir @param {string} remoteName @returns {string|null} */
27
+ function deriveRepoFromGitRemote(rootDir: string, remoteName: string): string | null {
28
+ if (derivedRepoCache.has(cacheKey(rootDir, remoteName))) {
29
+ return derivedRepoCache.get(cacheKey(rootDir, remoteName));
30
+ }
31
+ const remote = remoteName || 'origin';
32
+ try {
33
+ const result = spawnSync('git', ['-C', rootDir, 'remote', 'get-url', remote], {
34
+ encoding: 'utf8',
35
+ timeout: 2000,
36
+ });
37
+ if (result.status !== 0) {
38
+ derivedRepoCache.set(cacheKey(rootDir, remote), null);
39
+ return null;
40
+ }
41
+ const url = (result.stdout || '').trim();
42
+ const match = url.match(/[:/]([^/:]+)\/([^/]+?)(\.git)?$/);
43
+ const derived = match ? `${match[1]}/${match[2]}` : null;
44
+ derivedRepoCache.set(cacheKey(rootDir, remote), derived);
45
+ return derived;
46
+ } catch (_) {
47
+ derivedRepoCache.set(cacheKey(rootDir, remote), null);
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /** @param {string} [explicitUser] @returns {string} */
53
+ function resolveForgejoUser(explicitUser?: string): string {
54
+ return explicitUser || process.env.FORGEJO_USER || DEFAULT_FORGEJO_USER;
55
+ }
56
+
57
+ function resolveForgejoHome() {
58
+ if (process.env.FORGEJO_HOME) {return process.env.FORGEJO_HOME;}
59
+ const directLocal = path.join(process.cwd(), '.forgejo-local');
60
+
61
+ // In test environments, we MUST NOT fall back to the real Forgejo home.
62
+ // NODE_TEST_CONTEXT is set by node --test.
63
+ if (process.env.NODE_TEST_CONTEXT) {
64
+ // If the test forgot to set FORGEJO_HOME, we return a path that is
65
+ // clearly not the real home to avoid accidental clobbering.
66
+ return '/tmp/forgejo-test-home-missing';
67
+ }
68
+ if (fs.existsSync(directLocal)) {
69
+ return directLocal;
70
+ }
71
+ try {
72
+ const main = getPrimaryWorktree();
73
+ const candidates = [
74
+ path.join(main, '.forgejo-local'),
75
+ path.join(path.dirname(main), `${path.basename(main).toLowerCase()}-forgejo`),
76
+ path.join(process.cwd(), '..', 'forgejo'),
77
+ ];
78
+ for (const candidate of candidates) {
79
+ if (fs.existsSync(candidate)) {
80
+ return candidate;
81
+ }
82
+ }
83
+ return candidates[0];
84
+ } catch (_) {
85
+ return directLocal;
86
+ }
87
+ }
88
+
89
+ /** @param {string} targetPath @returns {string|null} */
90
+ function normalizePathForComparison(targetPath: string): string | null {
91
+ if (!targetPath) {return null;}
92
+ try {
93
+ return fs.realpathSync.native(targetPath);
94
+ } catch (_) {
95
+ return path.resolve(targetPath);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @param {string} targetPath
101
+ * @param {{forgejoHome?: string}} [options]
102
+ * @returns {boolean}
103
+ */
104
+ function isForgejoPath(targetPath: string, options: any = {}): boolean {
105
+ const forgejoHome = options.forgejoHome || resolveForgejoHome();
106
+ const normalizedTarget = normalizePathForComparison(targetPath);
107
+ const normalizedForgejoHome = normalizePathForComparison(forgejoHome);
108
+ if (!normalizedTarget || !normalizedForgejoHome) {return false;}
109
+ return normalizedTarget === normalizedForgejoHome || normalizedTarget.startsWith(normalizedForgejoHome + path.sep);
110
+ }
111
+
112
+ function resolveForgejoSettings(rootDir = process.cwd()) {
113
+ const review = resolveReviewAdapter(rootDir);
114
+ const reviewRemote = review.remote || 'review';
115
+ return {
116
+ url: process.env.FORGEJO_URL || review.baseUrl || 'http://localhost:3300',
117
+ repo: process.env.FORGEJO_REPO || review.repo || deriveRepoFromGitRemote(rootDir, reviewRemote) || deriveRepoFromGitRemote(rootDir, 'origin') || '',
118
+ };
119
+ }
120
+
121
+ /**
122
+ * @param {{forgejoUser?: string, token?: string}} options
123
+ * @returns {{forgejoUser: string, token: string|null}}
124
+ */
125
+ function resolveForgejoAuth(options: { forgejoUser?: string, token?: string } = {} as { forgejoUser?: string, token?: string }): { forgejoUser: string, token: string | null } {
126
+ const forgejoUser = resolveForgejoUser(options.forgejoUser);
127
+ const token = options.token || readToken(forgejoUser);
128
+ return { forgejoUser, token };
129
+ }
130
+
131
+ /**
132
+ * @param {string} branch
133
+ * @param {{status?: number, statusCode?: number}} [apiErr]
134
+ * @returns {string}
135
+ */
136
+ function formatPrLookupFailure(branch: string, apiErr: { status?: number, statusCode?: number }): string {
137
+ const sandboxNote = apiErr.status === 7 ? ` (${codexSandboxHint()})` : '';
138
+ if (apiErr.statusCode === 401 || apiErr.statusCode === 403) {
139
+ return `failed to resolve PR for ${branch}: Forgejo authentication failed (${apiErr.statusCode})${sandboxNote}`;
140
+ }
141
+ if (apiErr.statusCode) {
142
+ return `failed to resolve PR for ${branch}: Forgejo API returned HTTP ${apiErr.statusCode}${sandboxNote}`;
143
+ }
144
+ return `failed to resolve PR for ${branch}${sandboxNote}`;
145
+ }
146
+
147
+ /**
148
+ * @param {string} branch
149
+ * @param {string} [rootDir]
150
+ * @param {{forgejoUser?: string, token?: string, apiCall?: Function}} [options]
151
+ * @returns {{exists: boolean, error?: string, raw: string, number?: number, title?: string, state?: string, merged?: boolean, url?: string}}
152
+ */
153
+ function getPrStatus(branch: string, rootDir?: string, options: any = {}) {
154
+ /** @type {{forgejoUser?: string, token?: string, apiCall?: Function}} */
155
+ const {
156
+ forgejoUser,
157
+ token: providedToken,
158
+ apiCall = forgejoApi
159
+ } = options;
160
+
161
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
162
+ const slug = slugMatch ? slugMatch[1] : null;
163
+
164
+ const { token } = resolveForgejoAuth({ forgejoUser, token: providedToken });
165
+ // Don't short-circuit on missing primary token — resolvePrAccess has fallback logic
166
+ // to try implementer and other known tokens when the primary is unavailable.
167
+ const prAccess = resolvePrAccess(branch, token || null, { apiCall, slug, forgejoUser, rootDir });
168
+ if (prAccess && typeof prAccess === 'object' && prAccess._apiError) {
169
+ const apiErr = prAccess._apiError;
170
+ return {
171
+ exists: false,
172
+ error: 'api-failed',
173
+ raw: formatPrLookupFailure(branch, apiErr)
174
+ };
175
+ }
176
+ if (!prAccess) {
177
+ return {
178
+ exists: false,
179
+ raw: `no PR found for '${branch}'`
180
+ };
181
+ }
182
+ const existingPrNumber = prAccess.prNumber;
183
+
184
+ const prDetails = apiCall('GET', `/pulls/${existingPrNumber}`, prAccess.token, undefined, { rootDir });
185
+ if (!prDetails.ok) {
186
+ return {
187
+ exists: false,
188
+ error: 'api-failed',
189
+ raw: formatPrLookupFailure(branch, prDetails)
190
+ };
191
+ }
192
+
193
+ const pr = prDetails.data;
194
+ const merged = pr.merged === true;
195
+ const raw = `PR #${pr.number}: ${pr.title}\n State: ${pr.state}\n Merged: ${merged ? 'True' : 'False'}\n URL: ${pr.html_url}`;
196
+
197
+ return {
198
+ exists: true,
199
+ number: pr.number,
200
+ title: pr.title,
201
+ state: pr.state,
202
+ merged: merged,
203
+ url: pr.html_url,
204
+ raw
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Resolve the Forgejo PAT file path for a given user.
210
+ * Mirrors the token resolution logic in the deprecated bash implementation.
211
+ *
212
+ * @param {string} user - Forgejo login (e.g. 'claude', 'codex', 'human')
213
+ * @returns {string|null}
214
+ */
215
+ function resolveTokenFile(user: string): string | null {
216
+ const resolvedUser = resolveForgejoUser(user);
217
+ const isCurrentUser = resolvedUser === resolveForgejoUser();
218
+ const canUseDefaultTokenFile = isCurrentUser || resolvedUser === DEFAULT_FORGEJO_USER;
219
+ const candidates = [
220
+ isCurrentUser ? process.env.FORGEJO_TOKEN_FILE : null,
221
+ path.join(resolveForgejoHome(), 'tokens', resolvedUser),
222
+ canUseDefaultTokenFile ? path.join(resolveForgejoHome(), 'token') : null,
223
+ ];
224
+
225
+ for (const candidate of candidates) {
226
+ if (candidate && fs.existsSync(candidate)) {return candidate;}
227
+ }
228
+
229
+ return null;
230
+ }
231
+
232
+ /**
233
+ * Read and return the Forgejo PAT for a given user.
234
+ *
235
+ * @param {string} user
236
+ * @returns {string|null}
237
+ */
238
+ function readToken(user: string): string | null {
239
+ const resolvedUser = resolveForgejoUser(user);
240
+ if (resolvedUser === resolveForgejoUser() && process.env.FORGEJO_TOKEN) {
241
+ return process.env.FORGEJO_TOKEN;
242
+ }
243
+ const tokenFile = resolveTokenFile(resolvedUser);
244
+ if (!tokenFile) {return null;}
245
+ return fs.readFileSync(tokenFile, 'utf8').trim();
246
+ }
247
+
248
+ /**
249
+ * Make a JSON Forgejo API call via curl. Returns parsed JSON or null on failure.
250
+ *
251
+ * @param {string} method - HTTP method (GET, POST, PATCH, ...)
252
+ * @param {string} apiPath - Path relative to /api/v1/repos/<repo>
253
+ * @param {string} token - Forgejo PAT
254
+ * @param {object} [body] - Optional JSON body
255
+ * @returns {{ ok: boolean, data: any, status: number|null, statusCode: number|null, stderr: string|null, error: string|null }}
256
+ */
257
+ /**
258
+ * @param {string} method
259
+ * @param {string} apiPath
260
+ * @param {string} token
261
+ * @param {object} [body]
262
+ * @param {{rootDir?: string}} [options]
263
+ * @returns {{ok: boolean, data: any, status: number|null, statusCode: number|null, stderr: string|null, error: string|null}}
264
+ */
265
+ function forgejoApi(method: string, apiPath: string, token: string, body: any, options: any = {}): { ok: boolean, data: any, status: number | null, statusCode: number | null, stderr: string | null, error: string | null } {
266
+ const { rootDir = process.cwd() } = options;
267
+ const { url: forgejoUrl, repo: forgejoRepo } = resolveForgejoSettings(rootDir);
268
+ const url = `${forgejoUrl}/api/v1/repos/${forgejoRepo}${apiPath}`;
269
+ const args = ['-s', '-X', method,
270
+ '-H', `Authorization: token ${token}`,
271
+ '-H', 'Content-Type: application/json',
272
+ '-w', '\\n%{http_code}'
273
+ ];
274
+
275
+ if (body) {
276
+ args.push('--data-binary', '@-');
277
+ }
278
+ args.push(url);
279
+
280
+ const result = spawnSync('curl', args, {
281
+ encoding: 'utf8',
282
+ input: body ? JSON.stringify(body) : undefined
283
+ });
284
+
285
+ if (result.status !== 0 || !result.stdout) {
286
+ return {
287
+ ok: false,
288
+ data: null,
289
+ status: result.status,
290
+ statusCode: null,
291
+ stderr: result.stderr,
292
+ error: result.status === 7 ? codexSandboxHint() : null
293
+ };
294
+ }
295
+
296
+ const output = result.stdout.trim();
297
+ const lastLineIndex = output.lastIndexOf('\n');
298
+ const statusCodeStr = lastLineIndex === -1 ? output : output.substring(lastLineIndex + 1);
299
+ const responseBody = lastLineIndex === -1 ? '' : output.substring(0, lastLineIndex).trim();
300
+
301
+ const statusCode = parseInt(statusCodeStr, 10);
302
+
303
+ let data = null;
304
+ if (responseBody) {
305
+ try {
306
+ data = JSON.parse(responseBody);
307
+ } catch (_) {
308
+ // Not JSON
309
+ }
310
+ }
311
+
312
+ return {
313
+ ok: statusCode >= 200 && statusCode < 300,
314
+ data,
315
+ status: result.status,
316
+ statusCode,
317
+ stderr: result.stderr || null,
318
+ error: null
319
+ };
320
+ }
321
+
322
+ /**
323
+ * @param {string} method
324
+ * @param {string} apiPath
325
+ * @param {string} token
326
+ * @param {object} [body]
327
+ * @param {{rootDir?: string, timeout?: number}} [options]
328
+ * @returns {Promise<{ok: boolean, data: any, status: number|null, statusCode: number|null, stderr: string|null, error: string|null}>}
329
+ */
330
+ async function forgejoApiAsync(method: string, apiPath: string, token: string, body: any, options: any = {}): Promise< { ok: boolean, data: any, status: number | null, statusCode: number | null, stderr: string | null, error: string | null } > {
331
+ /** @type {{rootDir?: string, timeout?: number}} */
332
+ const {
333
+ rootDir = process.cwd(),
334
+ timeout = HTTP_REQUEST_TIMEOUT
335
+ } = options;
336
+ const { url: forgejoUrl, repo: forgejoRepo } = resolveForgejoSettings(rootDir);
337
+
338
+ const url = new URL(`${forgejoUrl}/api/v1/repos/${forgejoRepo}${apiPath}`);
339
+ const transport = url.protocol === 'https:' ? https : http;
340
+ const payload = body ? JSON.stringify(body) : null;
341
+
342
+ return new Promise((resolve) => {
343
+ let settled = false;
344
+ const finish = /** @param {{ok: boolean, data: any, status: number|null, statusCode: number|null, stderr: string|null, error: string|null}} result */ (result: any) => {
345
+ if (settled) {return;}
346
+ settled = true;
347
+ resolve(result);
348
+ };
349
+
350
+ const req = transport.request(url, {
351
+ method,
352
+ timeout,
353
+ headers: {
354
+ Authorization: `token ${token}`,
355
+ 'Content-Type': 'application/json',
356
+ ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {})
357
+ }
358
+ }, /** @param {import('http').IncomingMessage} res */ (res: any) => {
359
+ let responseBody = '';
360
+ res.setEncoding('utf8');
361
+ res.on('data', /** @param {string|Buffer} chunk */ (chunk: any) => {
362
+ responseBody += chunk;
363
+ });
364
+ res.on('end', () => {
365
+ let data = null;
366
+ if (responseBody) {
367
+ try {
368
+ data = JSON.parse(responseBody);
369
+ } catch (_) {
370
+ data = null;
371
+ }
372
+ }
373
+ finish({
374
+ ok: (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300,
375
+ data,
376
+ status: 0,
377
+ statusCode: res.statusCode ?? null,
378
+ stderr: null,
379
+ error: null
380
+ });
381
+ });
382
+ });
383
+
384
+ req.on('error', /** @param {{code?: string, message?: string}} error */ (error: any) => {
385
+ finish({
386
+ ok: false,
387
+ data: null,
388
+ status: null,
389
+ statusCode: null,
390
+ stderr: error.message || null,
391
+ error: ['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH'].includes((error as { code?: string }).code || '') ? codexSandboxHint() : null
392
+ });
393
+ });
394
+
395
+ req.on('timeout', () => {
396
+ req.destroy(new Error('request timeout'));
397
+ finish({
398
+ ok: false,
399
+ data: null,
400
+ status: null,
401
+ statusCode: null,
402
+ stderr: 'request timeout',
403
+ error: null
404
+ });
405
+ });
406
+
407
+ if (payload) {
408
+ req.write(payload);
409
+ }
410
+ req.end();
411
+ });
412
+ }
413
+
414
+ /**
415
+ * Create a Forgejo PR for a given branch.
416
+ * Mirrors cmd_create in the deprecated bash implementation.
417
+ *
418
+ * @param {string} branch - Mission branch (e.g. 'mission/task-089')
419
+ * @param {string} user - Forgejo login
420
+ * @param {string} token - Forgejo PAT
421
+ * @param {object} [options]
422
+ * @returns {{ ok: boolean, url: string|null, error: string|null }}
423
+ */
424
+ /**
425
+ * @param {string} branch
426
+ * @param {string} user
427
+ * @param {string} token
428
+ * @param {{rootDir?: string, apiCall?: Function, log?: Function, force?: boolean, forceWithLease?: boolean, verificationArea?: string, captureVerifiedTreeProofFn?: Function, assertVerifiedTreeProofFn?: Function}} [options]
429
+ * @returns {{ok: boolean, url?: string|null, error?: string|null, prNumber?: number}}
430
+ */
431
+ function createPr(branch: string, user: string, token: string, options: any = {}): { ok: boolean, url?: string | null, error?: string | null, prNumber?: number } {
432
+ const {
433
+ rootDir = process.cwd(),
434
+ apiCall = forgejoApi,
435
+ log = fmt.log.info,
436
+ force = false,
437
+ forceWithLease = false,
438
+ verificationArea = null,
439
+ captureVerifiedTreeProofFn = verification.captureVerifiedTreeProof,
440
+ assertVerifiedTreeProofFn = verification.assertVerifiedTreeProof
441
+ } = options;
442
+
443
+ let primaryBranch = 'main';
444
+ try {
445
+ primaryBranch = getPrimaryBranch(rootDir);
446
+ } catch (_) {
447
+ primaryBranch = 'main';
448
+ }
449
+ if (branch === primaryBranch) {return { ok: false, error: `cannot create a PR from ${primaryBranch}` };}
450
+
451
+ // Resolve the PR base: for feature-branch missions the PR targets the recorded
452
+ // base branch; for legacy missions it falls back to the primary branch so
453
+ // the byte-identical regression path is preserved.
454
+ let prBase = primaryBranch;
455
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
456
+ const slug = slugMatch ? slugMatch[1] : null;
457
+ if (slug) {
458
+ try {
459
+ const resolvedBase = resolveMissionBaseBranch(slug, rootDir);
460
+ if (resolvedBase !== primaryBranch) {
461
+ prBase = resolvedBase;
462
+ }
463
+ } catch (_) {
464
+ // resolveMissionBaseBranch may fail if MISSION.md is not yet on disk; fall through to primary.
465
+ }
466
+ }
467
+
468
+ const repoOwner = resolveForgejoSettings(rootDir).repo.split('/')[0] || null;
469
+ const ownerToken = repoOwner ? readToken(repoOwner) : null;
470
+ const gitUser = ownerToken && repoOwner ? repoOwner : user;
471
+ const gitToken = ownerToken || token;
472
+ const apiUser = user;
473
+ const apiToken = token;
474
+ const resolvedVerificationArea = verificationArea || verification.resolveVerificationAdapter(rootDir).defaultArea;
475
+ const authenticatedGitFetch = /** @param {string} branchName @param {string} dir @param {{user?: string, token?: string}} [options] */ (branchName: string, dir: string, options: any = {}) => fetchReviewBranch(branchName, dir, {
476
+ ...options,
477
+ user: gitUser,
478
+ token: gitToken,
479
+ });
480
+
481
+ const proofResult = captureVerifiedTreeProofFn(resolvedVerificationArea, rootDir);
482
+ if (!proofResult.ok) {
483
+ return { ok: false, error: proofResult.error || 'failed to verify publish tree' };
484
+ }
485
+
486
+ // 1. Sync primary branch baseline
487
+ const syncResult = syncPrimaryBaseline(gitUser, gitToken, rootDir, {
488
+ verificationProof: proofResult.proof,
489
+ assertVerifiedTreeProofFn,
490
+ gitRunner: git
491
+ });
492
+ if (!syncResult.ok) {
493
+ return { ok: false, error: `failed to sync primary baseline: ${syncResult.error || syncResult.stderr}` };
494
+ }
495
+
496
+ // 1b. Feature-branch missions target a non-primary PR base that must exist on
497
+ // the review remote before the PR is opened. syncPrimaryBaseline only mirrors
498
+ // the primary branch, so push the feature base branch too.
499
+ if (prBase !== primaryBranch) {
500
+ log(`Mirroring PR base branch ${prBase} to review remote...`);
501
+ const baseSync = ensureRemoteBaseBranch(prBase, gitUser, gitToken, rootDir);
502
+ if (!baseSync.ok) {
503
+ return { ok: false, error: `failed to sync PR base branch ${prBase}: ${baseSync.error || baseSync.stderr}` };
504
+ }
505
+ }
506
+
507
+ // 2. Push the branch using authenticated URL
508
+ const remoteUrl = authenticatedReviewUrl(gitUser, gitToken, rootDir);
509
+ log(`Pushing ${branch} as Forgejo user ${gitUser}${force || forceWithLease ? ' (force-with-lease)' : ''}...`);
510
+ let pushArgsResult = buildCreatePrPushArgs(branch, remoteUrl, rootDir, {
511
+ force,
512
+ forceWithLease,
513
+ gitFetch: authenticatedGitFetch
514
+ });
515
+ if (!pushArgsResult.ok) {
516
+ return { ok: false, error: pushArgsResult.error };
517
+ }
518
+ let pushArgs = (pushArgsResult as {ok: boolean, pushArgs: string[], error?: string}).pushArgs || [];
519
+ let pushResult = git(pushArgs, { stdio: ['ignore', 'pipe', 'pipe'], env: cLocaleEnv() });
520
+ if (pushResult.stdout) {process.stdout.write(pushResult.stdout);}
521
+ if (pushResult.stderr) {process.stderr.write(pushResult.stderr);}
522
+
523
+ if (pushResult.status !== 0) {
524
+ if (forceWithLease && isStaleInfoPushRejection(pushResult)) {
525
+ log(`Stale push rejection for ${branch}; fetching and retrying...`);
526
+ pushArgsResult = buildCreatePrPushArgs(branch, remoteUrl, rootDir, {
527
+ force,
528
+ forceWithLease,
529
+ gitFetch: authenticatedGitFetch,
530
+ refreshTrackingRef: true
531
+ });
532
+ if (!pushArgsResult.ok) {
533
+ return { ok: false, error: pushArgsResult.error };
534
+ }
535
+ pushArgs = (pushArgsResult as {ok: boolean, pushArgs: string[], error?: string}).pushArgs || [];
536
+ pushResult = git(pushArgs, { stdio: ['ignore', 'pipe', 'pipe'], env: cLocaleEnv() });
537
+ if (pushResult.stdout) {process.stdout.write(pushResult.stdout);}
538
+ if (pushResult.stderr) {process.stderr.write(pushResult.stderr);}
539
+ }
540
+ if (pushResult.status !== 0) {
541
+ const pushError = (pushResult.stderr || pushResult.stdout || '').trim();
542
+ return { ok: false, error: `git push failed with status ${pushResult.status}${pushError ? `: ${pushError}` : ''}` };
543
+ }
544
+ }
545
+
546
+ // 3. Check if an OPEN PR already exists for this branch
547
+ const existingPrLookup = resolvePrAccess(branch, apiToken, { apiCall, slug, onlyOpen: true, forgejoUser: apiUser, rootDir });
548
+
549
+ if (existingPrLookup && isApiErrorResult(existingPrLookup)) {
550
+ const apiErr = (existingPrLookup as any)._apiError || {};
551
+ return { ok: false, error: `failed to check existing PR: ${(apiErr.error || 'API error')}${apiErr.status === 7 ? ` (${codexSandboxHint()})` : ''}` };
552
+ }
553
+
554
+ // 4. Return existing PR if already present
555
+ if (existingPrLookup && existingPrLookup.prNumber) {
556
+ const prDetailsToken = existingPrLookup.token || apiToken;
557
+ const prDetails = apiCall('GET', `/pulls/${existingPrLookup.prNumber}`, prDetailsToken, undefined, { rootDir });
558
+ if (prDetails.ok) {
559
+ log(`PR already exists: ${prDetails.data.html_url}`);
560
+ return { ok: true, url: prDetails.data.html_url, prNumber: existingPrLookup.prNumber };
561
+ }
562
+ }
563
+
564
+ // 5. Create the PR
565
+ const title = branch.replace(/^mission\//, '').replace(/-/g, ' ');
566
+ const prPayload = {
567
+ title,
568
+ head: branch,
569
+ base: prBase
570
+ };
571
+
572
+ const createResult = apiCall('POST', '/pulls', apiToken, prPayload, { rootDir });
573
+ if (!createResult.ok || !createResult.data || !createResult.data.html_url) {
574
+ return { ok: false, error: `failed to create PR: ${JSON.stringify(createResult.data)}` };
575
+ }
576
+
577
+ log(`PR created: ${createResult.data.html_url}`);
578
+ return { ok: true, url: createResult.data.html_url, prNumber: createResult.data.number };
579
+ }
580
+
581
+ /**
582
+ * @param {string} branch
583
+ * @param {string|null} token
584
+ * @param {{apiCall?: Function, slug?: string|null, onlyOpen?: boolean, forgejoUser?: string, rootDir?: string}} [options]
585
+ * @returns {number|null|{_apiError?: object, _notFound?: boolean}}
586
+ */
587
+ function getPrNumber(branch: string, token: string | null, options: any = {}): number | null | { _apiError?: object, _notFound?: boolean } {
588
+ const resolved = resolvePrAccess(branch, token, options);
589
+ if (!resolved || isApiErrorResult(resolved)) {
590
+ return resolved;
591
+ }
592
+ return resolved.prNumber ?? null;
593
+ }
594
+
595
+ /**
596
+ * Resolve the login of the author (creator) of the PR for a given branch.
597
+ *
598
+ * Used to detect the self-approval case where the resolved Forgejo reviewer is
599
+ * the same user that opened the PR — Forgejo rejects such a review with HTTP 422
600
+ * "approve your own pull is not allowed".
601
+ *
602
+ * Degrades safely: returns null when the PR cannot be resolved, the API call
603
+ * fails, or the author field is absent. Callers treat null as "unknown author"
604
+ * (i.e. not a self-approval) and proceed with the normal Forgejo POST.
605
+ *
606
+ * @param {string} branch - Mission branch (e.g. 'mission/task-089')
607
+ * @param {string} token - Forgejo PAT
608
+ * @param {object} [options]
609
+ * @returns {string|null} The PR author's login, or null if undeterminable.
610
+ */
611
+ /**
612
+ * @param {string} branch
613
+ * @param {string} token
614
+ * @param {{apiCall?: Function, resolvePrNumber?: Function, forgejoUser?: string, rootDir?: string}} [options]
615
+ * @returns {string|null}
616
+ */
617
+ function getPrAuthor(branch: string, token: string, options: any = {}): string | null {
618
+ /** @type {{apiCall?: Function, resolvePrNumber?: Function, forgejoUser?: string, rootDir?: string}} */
619
+ const {
620
+ apiCall = forgejoApi,
621
+ resolvePrNumber = getPrNumber,
622
+ forgejoUser,
623
+ rootDir = process.cwd()
624
+ } = options;
625
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
626
+ const slug = slugMatch ? slugMatch[1] : null;
627
+ const prNumber = resolvePrNumber(branch, token, { apiCall, slug, forgejoUser, rootDir });
628
+ if (isApiErrorResult(prNumber) || !prNumber) {return null;}
629
+
630
+ const prRes = apiCall('GET', `/pulls/${prNumber}`, token, undefined, { rootDir });
631
+ if (!prRes.ok || !prRes.data || !prRes.data.user) {return null;}
632
+ return prRes.data.user.login || null;
633
+ }
634
+
635
+ /**
636
+ * @param {string} branch
637
+ * @param {string|null} token
638
+ * @param {{apiCall?: Function, pageSize?: number, maxPages?: number, slug?: string|null, onlyOpen?: boolean, forgejoUser?: string, rootDir?: string, reportNotFound?: boolean}} [options]
639
+ * @returns {{prNumber?: number, token?: string, _apiError?: object, _notFound?: boolean}|null}
640
+ */
641
+ function resolvePrAccess(branch: string, token: string | null, options: any = {}): { prNumber?: number, token?: string, _apiError?: object, _notFound?: boolean } | null {
642
+ const {
643
+ apiCall = forgejoApi,
644
+ pageSize = 50,
645
+ maxPages = 50,
646
+ slug = null,
647
+ onlyOpen = false,
648
+ forgejoUser,
649
+ rootDir = process.cwd()
650
+ } = options;
651
+
652
+ let lastApiError: { status?: number, statusCode?: number, error?: string, stderr?: string|null } | null = null;
653
+ let sawSuccessfulLookup = false;
654
+
655
+ /** @param {string} state @param {string} t */
656
+ const searchInState = (state: string, t: string) => {
657
+ let consecutiveErrors = 0;
658
+ for (let page = 1; page <= maxPages; page += 1) {
659
+ const result = apiCall('GET', `/pulls?state=${state}&page=${page}&limit=${pageSize}&sort=recentupdate&direction=desc`, t, undefined, { rootDir });
660
+ if (!result.ok) {
661
+ consecutiveErrors++;
662
+ lastApiError = {
663
+ error: result.error,
664
+ status: result.status,
665
+ statusCode: result.statusCode,
666
+ stderr: result.stderr,
667
+ };
668
+ if (consecutiveErrors >= 3) {
669
+ break;
670
+ }
671
+ continue;
672
+ }
673
+ consecutiveErrors = 0;
674
+ sawSuccessfulLookup = true;
675
+ if (!Array.isArray(result.data) || result.data.length === 0) {
676
+ break;
677
+ }
678
+
679
+ for (const pr of result.data) {
680
+ const head = pr.head || {};
681
+ if (head.ref === branch || head.label === branch || (head.label && head.label.endsWith(':' + branch))) {
682
+ return pr.number;
683
+ }
684
+ }
685
+
686
+ if (result.data.length < pageSize) {
687
+ break;
688
+ }
689
+ }
690
+ return null;
691
+ };
692
+
693
+ /** @param {string} t */
694
+ const doLookup = (t: string) => {
695
+ const openResult = searchInState('open', t);
696
+ if (openResult) {return openResult;}
697
+ if (!onlyOpen) {return searchInState('all', t);}
698
+ return null;
699
+ };
700
+
701
+ // 1. Try with the provided token
702
+ let prNumber = doLookup(token || '');
703
+ if (prNumber) {return { prNumber, token: token || undefined };}
704
+
705
+ const currentUser = resolveForgejoUser(forgejoUser);
706
+ const triedUsers = [currentUser];
707
+
708
+ // 2. Fallback for slugs
709
+ if (slug) {
710
+ const taskFile = findTaskFile(slug, rootDir);
711
+ const implementer = taskFile ? getTaskImplementer(taskFile) : null;
712
+ const repoOwner = resolveForgejoSettings(rootDir).repo.split('/')[0] || null;
713
+ const candidates = [implementer, repoOwner, DEFAULT_FORGEJO_USER].filter((u): u is string => Boolean(u) && u !== currentUser);
714
+
715
+ for (const user of candidates) {
716
+ triedUsers.push(user);
717
+ const fallbackToken = readToken(user);
718
+ if (fallbackToken) {
719
+ prNumber = doLookup(fallbackToken);
720
+ if (prNumber) {return { prNumber, token: fallbackToken };}
721
+ }
722
+ }
723
+ }
724
+
725
+ if (options.reportNotFound) {
726
+ const curlCheck = spawnSync('curl', ['--version'], { encoding: 'utf8' });
727
+ fmt.log.fail(`PR not found for branch '${branch}' after checking tokens for: ${triedUsers.join(', ')}`);
728
+ const settings = resolveForgejoSettings(rootDir);
729
+ fmt.log.info(`Current environment: FORGEJO_URL=${settings.url}, FORGEJO_REPO=${settings.repo}, FORGEJO_HOME=${resolveForgejoHome()}`);
730
+ if (lastApiError) {
731
+ const err = lastApiError as { status?: number, error?: string, stderr?: string };
732
+ fmt.log.warn(`API error encountered during lookup: status=${err.status || 0}, error=${err.error || 'unknown'}`);
733
+ if (err.stderr) {
734
+ fmt.log.info(`API stderr: ${err.stderr}`);
735
+ }
736
+ }
737
+ fmt.log.info(`curl --version: ${curlCheck.status === 0 ? curlCheck.stdout.split('\n')[0] : 'failed to run curl'}`);
738
+ }
739
+
740
+ if (sawSuccessfulLookup) {
741
+ return null;
742
+ }
743
+ if (lastApiError) {
744
+ return { _apiError: lastApiError, _notFound: true };
745
+ }
746
+ return null;
747
+ }
748
+
749
+ /**
750
+ * @param {string} baseSlug
751
+ * @param {string} token
752
+ * @param {{apiCall?: Function, pageSize?: number, maxPages?: number}} [options]
753
+ * @returns {Array<{number: number, title: string, html_url: string, head: string}>}
754
+ */
755
+ function listOpenPrsForSlug(baseSlug: string, token: string, options: any = {}): Array< { number: number, title: string, html_url: string, head: string } > {
756
+ /** @type {{apiCall?: Function, pageSize?: number, maxPages?: number}} */
757
+ const {
758
+ apiCall = forgejoApi,
759
+ pageSize = 50,
760
+ maxPages = 2
761
+ } = options;
762
+
763
+ const prs = [];
764
+ let consecutiveErrors = 0;
765
+
766
+ for (let page = 1; page <= maxPages; page += 1) {
767
+ const result = apiCall('GET', `/pulls?state=open&page=${page}&limit=${pageSize}&sort=recentupdate&direction=desc`, token);
768
+ if (!result.ok) {
769
+ consecutiveErrors++;
770
+ if (consecutiveErrors >= 3) {break;}
771
+ continue;
772
+ }
773
+ consecutiveErrors = 0;
774
+ if (!Array.isArray(result.data) || result.data.length === 0) {break;}
775
+
776
+ for (const pr of result.data) {
777
+ const head = pr.head || {};
778
+ // Some refs come back directly in head.ref, others might be in head.label
779
+ const ref = head.ref || (head.label && head.label.split(':').pop()) || '';
780
+
781
+ // Match exactly mission/<baseSlug> or mission/<baseSlug>-<suffix>
782
+ if (ref === `mission/${baseSlug}` || ref.startsWith(`mission/${baseSlug}-`)) {
783
+ prs.push({
784
+ number: pr.number,
785
+ title: pr.title,
786
+ html_url: pr.html_url,
787
+ head: ref
788
+ });
789
+ }
790
+ }
791
+
792
+ if (result.data.length < pageSize) {break;}
793
+ }
794
+
795
+ return prs;
796
+ }
797
+
798
+ /** @param {{_apiError?: object, _notFound?: boolean}} result @returns {boolean} */
799
+ function isApiErrorResult(result: { _apiError?: object, _notFound?: boolean }): boolean {
800
+ return Boolean(result && typeof result === 'object' && result._apiError && result._notFound === true);
801
+ }
802
+
803
+ /**
804
+ * @param {string} user
805
+ * @param {string} token
806
+ * @param {string} rootDir
807
+ * @returns {string}
808
+ */
809
+ function authenticatedReviewUrl(user: string, token: string, rootDir?: string) {
810
+ const { url: forgejoUrl, repo: forgejoRepo } = resolveForgejoSettings(rootDir);
811
+ const url = new URL(forgejoUrl);
812
+ const protocol = url.protocol;
813
+ const host = url.host; // includes port if present
814
+
815
+ return `${protocol}//${user}:${token}@${host}/${forgejoRepo}.git`;
816
+ }
817
+
818
+ /** @param {string} rootDir @returns {string|null} */
819
+ function reviewRemoteUrl(rootDir?: string) {
820
+ const { url: forgejoUrl, repo: forgejoRepo } = resolveForgejoSettings(rootDir);
821
+ if (!forgejoUrl || !forgejoRepo) {return null;}
822
+ const url = new URL(forgejoUrl);
823
+ return `${url.protocol}//${url.host}/${forgejoRepo}.git`;
824
+ }
825
+
826
+ /**
827
+ * @param {string} user
828
+ * @param {string} token
829
+ * @param {string} rootDir
830
+ * @param {{verificationProof?: object|null, assertVerifiedTreeProofFn?: Function, gitRunner?: Function}} [opts]
831
+ * @returns {{ok: boolean, skipped?: boolean, status?: number, stderr?: string, error?: string|null}}
832
+ */
833
+ function syncPrimaryBaseline(user: string, token: string, rootDir: string = process.cwd(), {
834
+ verificationProof = null,
835
+ assertVerifiedTreeProofFn = verification.assertVerifiedTreeProof,
836
+ gitRunner = git
837
+ } = {}) {
838
+ let primaryBranchName = 'main';
839
+ try {
840
+ primaryBranchName = getPrimaryBranch(rootDir);
841
+ } catch (_) {
842
+ primaryBranchName = 'main';
843
+ }
844
+
845
+ const proofCheck = assertVerifiedTreeProofFn(verificationProof as any, rootDir, { gitRunner });
846
+ if (!proofCheck.ok) {
847
+ return { ok: false, error: proofCheck.error || 'verification-proof-mismatch' };
848
+ }
849
+
850
+ const primaryExists = gitRunner(['-C', rootDir, 'show-ref', '--verify', '--quiet', `refs/heads/${primaryBranchName}`]);
851
+ if (primaryExists.status !== 0) {return { ok: true, skipped: true };}
852
+
853
+ const remoteUrl = authenticatedReviewUrl(user, token, rootDir);
854
+ // Force-push: the review remote's primary branch is a server-side mirror of our
855
+ // local primary that we intentionally overwrite to keep the review baseline in
856
+ // sync. Without --force a diverged remote primary (rebases, amended baseline
857
+ // commits) rejects this as a non-fast-forward, aborting the sync and breaking
858
+ // the review loop. See missions/task-1318 "Required Forgejo fix".
859
+ const result = gitRunner(['-C', rootDir, 'push', '--force', remoteUrl, `${primaryBranchName}:${primaryBranchName}`], {
860
+ stdio: 'pipe'
861
+ });
862
+
863
+ return {
864
+ ok: result.status === 0,
865
+ status: result.status,
866
+ stderr: result.stderr,
867
+ error: result.status === 0 ? null : `${result.stderr || 'git push failed'}${result.error ? ` (${codexSandboxHint()})` : ''}`
868
+ };
869
+ }
870
+
871
+ /**
872
+ * @param {string} baseBranch
873
+ * @param {string} user
874
+ * @param {string} token
875
+ * @param {string} rootDir
876
+ * @param {{gitRunner?: Function}} [opts]
877
+ * @returns {{ok: boolean, status?: number, stderr?: string, error?: string|null}}
878
+ */
879
+ function ensureRemoteBaseBranch(baseBranch: string, user: string, token: string, rootDir: string = process.cwd(), {
880
+ gitRunner = git
881
+ } = {}) {
882
+ // A feature-branch mission opens its PR against a non-primary base branch.
883
+ // syncPrimaryBaseline only mirrors the primary branch, so the PR base would
884
+ // not exist on the review remote and PR creation would fail server-side.
885
+ // Mirror the local feature base branch here before the PR is opened.
886
+ /** @type {{status: number}} */
887
+ const exists = gitRunner(['-C', rootDir, 'show-ref', '--verify', '--quiet', `refs/heads/${baseBranch}`]);
888
+ if (exists.status !== 0) {
889
+ return { ok: false, error: `PR base branch ${baseBranch} does not exist locally` };
890
+ }
891
+
892
+ const remoteUrl = authenticatedReviewUrl(user, token, rootDir);
893
+ // Force-push for the same reason syncPrimaryBaseline force-pushes the primary
894
+ // branch: the review remote's copy is an intentional server-side mirror of our
895
+ // local branch. A diverged remote (e.g. from a prior sub-mission integration)
896
+ // would otherwise reject a fast-forward push and break PR creation.
897
+ const result = gitRunner(['-C', rootDir, 'push', '--force', remoteUrl, `${baseBranch}:${baseBranch}`], {
898
+ stdio: 'pipe'
899
+ });
900
+
901
+ return {
902
+ ok: result.status === 0,
903
+ status: result.status,
904
+ stderr: result.stderr,
905
+ error: result.status === 0 ? null : `${result.stderr || 'git push failed'}${result.error ? ` (${codexSandboxHint()})` : ''}`
906
+ };
907
+ }
908
+
909
+ /**
910
+ * @param {string} sourceRef
911
+ * @param {string} destinationRef
912
+ * @param {string} rootDir
913
+ * @param {{force?: boolean, forceWithLease?: boolean, user?: string, token?: string}} [opts]
914
+ * @returns {*}
915
+ */
916
+ function pushReviewRef(sourceRef: string, destinationRef: string, rootDir: string = process.cwd(), options?: { force?: boolean, forceWithLease?: boolean, user?: string, token?: string }) {
917
+ const {
918
+ force = false,
919
+ forceWithLease = false,
920
+ user,
921
+ token
922
+ } = options || {};
923
+ const remote = token
924
+ ? authenticatedReviewUrl(user || resolveForgejoUser(), token, rootDir)
925
+ : 'review';
926
+ const pushArgs = /** @type {string[]} */ (['-C', rootDir, 'push', remote]);
927
+ if (forceWithLease) {
928
+ pushArgs.push('--force-with-lease');
929
+ } else if (force) {
930
+ pushArgs.push('--force');
931
+ }
932
+ pushArgs.push(`${sourceRef}:${destinationRef}`);
933
+ const result = git(pushArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
934
+ if (result.stdout) {process.stdout.write(result.stdout);}
935
+ if (result.stderr) {process.stderr.write(result.stderr);}
936
+ return result;
937
+ }
938
+
939
+ /**
940
+ * @param {string} branch
941
+ * @param {string} rootDir
942
+ * @param {{user?: string, token?: string}} options
943
+ * @returns {*}
944
+ */
945
+ function fetchReviewBranch(branch: string, rootDir: string = process.cwd(), options: any = {}) {
946
+ const { user, token } = options || {};
947
+ const source = token
948
+ ? authenticatedReviewUrl(user || resolveForgejoUser(), token, rootDir)
949
+ : 'review';
950
+ return git(['-C', rootDir, 'fetch', source, `+refs/heads/${branch}:refs/remotes/review/${branch}`], {
951
+ stdio: ['ignore', 'pipe', 'pipe'],
952
+ // Force a stable C locale so git emits its diagnostics in English. Without
953
+ // this, a non-English operator locale (e.g. Swedish "kunde inte hitta
954
+ // fjärr-referensen") makes isMissingRemoteRef miss the "could not find
955
+ // remote ref" condition and a routine first push aborts with a fatal error.
956
+ env: cLocaleEnv()
957
+ });
958
+ }
959
+
960
+ /** @param {string} branch @param {string} rootDir @returns {{ok: boolean, ref?: string, sha?: string, error?: string}} */
961
+ function resolveTrackingBranchSha(branch: string, rootDir: string = process.cwd()) {
962
+ const candidateRefs = [`refs/remotes/review/${branch}`, `refs/remotes/origin/${branch}`];
963
+ for (const ref of candidateRefs) {
964
+ const result = git(['-C', rootDir, 'rev-parse', '--verify', `${ref}^{commit}`], {
965
+ stdio: ['ignore', 'pipe', 'pipe']
966
+ });
967
+ const sha = (result.stdout || '').trim();
968
+ if (result.status === 0 && sha) {
969
+ return { ok: true, ref, sha };
970
+ }
971
+ }
972
+ return {
973
+ ok: false,
974
+ error: `could not resolve tracking ref for ${branch}; checked ${candidateRefs.join(' and ')}`
975
+ };
976
+ }
977
+
978
+ /**
979
+ * @param {string} branch
980
+ * @param {string} remoteUrl
981
+ * @param {string} rootDir
982
+ * @param {{force?: boolean, forceWithLease?: boolean, gitFetch?: Function, refreshTrackingRef?: boolean}} [options]
983
+ * @returns {{ok: boolean, pushArgs?: string[], error?: string}}
984
+ */
985
+ function buildCreatePrPushArgs(branch: string, remoteUrl: string, rootDir: string = process.cwd(), options: any = {}) {
986
+ /** @type {{force?: boolean, forceWithLease?: boolean, gitFetch?: Function, refreshTrackingRef?: boolean}} */
987
+ const {
988
+ force = false,
989
+ forceWithLease = false,
990
+ gitFetch = fetchReviewBranch,
991
+ refreshTrackingRef = false
992
+ } = options;
993
+
994
+ const pushArgs = ['-C', rootDir, 'push'];
995
+ if (forceWithLease) {
996
+ if (refreshTrackingRef) {
997
+ const refreshResult = gitFetch(branch, rootDir);
998
+ if (refreshResult.status !== 0) {
999
+ if (isMissingRemoteRef(refreshResult)) {
1000
+ // First push: the remote branch does not exist yet, so there is
1001
+ // nothing to clobber. Fall back to a plain push (no force-with-lease).
1002
+ pushArgs.push(remoteUrl, branch);
1003
+ return { ok: true, pushArgs };
1004
+ }
1005
+ return {
1006
+ ok: false,
1007
+ error: `failed to refresh tracking ref for ${branch}: ${pushOutput(refreshResult) || 'git fetch failed'}`
1008
+ };
1009
+ }
1010
+ }
1011
+
1012
+ let trackingRefResult = resolveTrackingBranchSha(branch, rootDir);
1013
+ if (!trackingRefResult.ok && !refreshTrackingRef) {
1014
+ const fetchResult = gitFetch(branch, rootDir);
1015
+ if (fetchResult.status !== 0) {
1016
+ if (isMissingRemoteRef(fetchResult)) {
1017
+ // First push: the remote branch does not exist yet, so there is
1018
+ // nothing to clobber. Fall back to a plain push (no force-with-lease).
1019
+ pushArgs.push(remoteUrl, branch);
1020
+ return { ok: true, pushArgs };
1021
+ }
1022
+ return {
1023
+ ok: false,
1024
+ error: `failed to fetch tracking ref for ${branch}: ${pushOutput(fetchResult) || 'git fetch failed'}`
1025
+ };
1026
+ }
1027
+ trackingRefResult = resolveTrackingBranchSha(branch, rootDir);
1028
+ }
1029
+ if (!trackingRefResult.ok) {
1030
+ return trackingRefResult;
1031
+ }
1032
+
1033
+ pushArgs.push(`--force-with-lease=refs/heads/${branch}:${trackingRefResult.sha}`);
1034
+ } else if (force) {
1035
+ pushArgs.push('--force-with-lease');
1036
+ }
1037
+ pushArgs.push(remoteUrl, branch);
1038
+ return { ok: true, pushArgs };
1039
+ }
1040
+
1041
+ /**
1042
+ * @param {string} branch
1043
+ * @param {string} rootDir
1044
+ * @param {{user?: string, token?: string}} [opts]
1045
+ * @returns {*}
1046
+ */
1047
+ function deleteReviewRef(branch: string, rootDir: string = process.cwd(), options?: { user?: string, token?: string }) {
1048
+ const { user, token } = options || {};
1049
+ const remote = token
1050
+ ? authenticatedReviewUrl(user || resolveForgejoUser(), token, rootDir)
1051
+ : 'review';
1052
+ const result = git(['-C', rootDir, 'push', remote, '--delete', branch], {
1053
+ stdio: ['ignore', 'pipe', 'pipe']
1054
+ });
1055
+ if (result.stdout) {process.stdout.write(result.stdout);}
1056
+ if (result.stderr) {process.stderr.write(result.stderr);}
1057
+ return result;
1058
+ }
1059
+
1060
+ /** @param {string} commit @param {string} rootDir @returns {*} */
1061
+ function verifyCommitExists(commit: string, rootDir: string = process.cwd()) {
1062
+ return git(['-C', rootDir, 'rev-parse', '--verify', `${commit}^{commit}`], {
1063
+ stdio: ['ignore', 'pipe', 'pipe']
1064
+ });
1065
+ }
1066
+
1067
+ /** @param {string} commit @param {string} [remoteRef] @param {string} rootDir @returns {*} */
1068
+ function remoteRefContainsCommit(commit: string, remoteRef: string = `refs/remotes/review/${getPrimaryBranch(process.cwd())}`, rootDir: string = process.cwd()) {
1069
+ return git(['-C', rootDir, 'merge-base', '--is-ancestor', commit, remoteRef], {
1070
+ stdio: ['ignore', 'pipe', 'pipe']
1071
+ });
1072
+ }
1073
+
1074
+ // Git diagnostics ("could not find remote ref", "stale info", "fetch first")
1075
+ // are localized to the operator's locale. We parse these messages to decide
1076
+ // control flow, so force a stable C locale on git calls whose stderr we
1077
+ // inspect, keeping detection language-independent.
1078
+ /** @returns {Record<string, string>} */
1079
+ function cLocaleEnv(): Record<string, string> {
1080
+ return { ...process.env, LC_ALL: 'C', LANG: 'C' };
1081
+ }
1082
+
1083
+ /** @param {{stderr?: string, stdout?: string}} result @returns {string} */
1084
+ function pushOutput(result: { stderr?: string, stdout?: string }): string {
1085
+ return [result && result.stderr, result && result.stdout]
1086
+ .filter(Boolean)
1087
+ .join('\n')
1088
+ .trim();
1089
+ }
1090
+
1091
+ // Detects the git fetch failure that means the remote branch simply does not
1092
+ // exist yet (first push of a new branch), e.g.
1093
+ // "fatal: could not find remote ref refs/heads/<branch>". This must be
1094
+ // distinguished from real fetch failures (auth/network), which also exit
1095
+ // non-zero but do not carry this message and should still abort.
1096
+ /** @param {{stderr?: string, stdout?: string}} result @returns {boolean} */
1097
+ function isMissingRemoteRef(result: { stderr?: string, stdout?: string }): boolean {
1098
+ const output = pushOutput(result).toLowerCase();
1099
+ return output.includes('could not find remote ref')
1100
+ || output.includes("couldn't find remote ref");
1101
+ }
1102
+
1103
+ /** @param {{status?: number, stderr?: string, stdout?: string}} result @returns {boolean} */
1104
+ function isStaleInfoPushRejection(result: { status: number | null, stderr?: string, stdout?: string }): boolean {
1105
+ return result && result.status !== 0 && /\bstale info\b|\bstale ref\b|fetch first/i.test(pushOutput(result));
1106
+ }
1107
+
1108
+
1109
+ /**
1110
+ * Get formal reviews submitted by a specific reviewer after a given ISO timestamp.
1111
+ *
1112
+ * @param {string} branch - Mission branch (e.g. 'mission/task-089')
1113
+ * @param {string} reviewerUser - Forgejo login of the reviewer
1114
+ * @param {string} sinceIso - ISO 8601 timestamp to filter from
1115
+ * @param {string} token - Forgejo PAT (for reading reviews)
1116
+ * @param {object} [options] - Optional overrides
1117
+ * @returns {{ state: string, submittedAt: string }|null} Most recent eligible review, or null
1118
+ */
1119
+ /**
1120
+ * @param {string} branch
1121
+ * @param {string} reviewerUser
1122
+ * @param {string} sinceIso
1123
+ * @param {string} token
1124
+ * @param {{apiCall?: Function, forgejoUser?: string, rootDir?: string}} [options]
1125
+ * @returns {{state: string, submittedAt: string}|null}
1126
+ */
1127
+ function getLatestReview(branch: string, reviewerUser: string, sinceIso: string, token: string, options: any = {}): { state: string, submittedAt: string } | null {
1128
+ const {
1129
+ apiCall = forgejoApi,
1130
+ forgejoUser,
1131
+ rootDir = process.cwd()
1132
+ } = options;
1133
+
1134
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
1135
+ const slug = slugMatch ? slugMatch[1] : null;
1136
+ const prAccess = resolvePrAccess(branch, token, { apiCall, slug, forgejoUser, rootDir });
1137
+ if (!prAccess || isApiErrorResult(prAccess)) {return null;}
1138
+
1139
+ const result = apiCall('GET', `/pulls/${prAccess.prNumber}/reviews`, prAccess.token);
1140
+ if (!result.ok || !Array.isArray(result.data)) {return null;}
1141
+
1142
+ const since = new Date(sinceIso).getTime();
1143
+
1144
+ const eligible = result.data
1145
+ .filter(/** @param {{user?: {login?: string}, submitted_at?: string, created_at?: string, state?: string}} r */ (r: any) => {
1146
+ const user = (r.user || {}).login;
1147
+ const submittedAt = r.submitted_at || r.created_at || '';
1148
+ const submitted = submittedAt ? new Date(submittedAt).getTime() : 0;
1149
+ return user === reviewerUser && submitted >= since;
1150
+ })
1151
+ .map(/** @param {{state?: string, submitted_at?: string, created_at?: string}} r */ (r: any) => ({ state: r.state, submittedAt: r.submitted_at || r.created_at || '' }))
1152
+ .sort(/** @param {{submittedAt: string}} a @param {{submittedAt: string}} b */ (a: any, b: any) => new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime());
1153
+
1154
+ return eligible.length > 0 ? eligible[eligible.length - 1] : null;
1155
+ }
1156
+
1157
+ /**
1158
+ * @param {string} branch
1159
+ * @param {{forgejoUser?: string, token?: string, apiCall?: Function, rootDir?: string}} [options]
1160
+ * @returns {{ok: boolean, error?: string, reviewState?: string|null, prNumber?: number, defaultUserApproved?: boolean, raw?: string}}
1161
+ */
1162
+ function getLatestReviewDecision(branch: string, options: any = {}): { ok: boolean, error?: string, reviewState?: string | null, prNumber?: number, defaultUserApproved?: boolean, raw?: string } {
1163
+ /** @type {{forgejoUser?: string, token?: string, apiCall?: Function, rootDir?: string}} */
1164
+ const {
1165
+ forgejoUser,
1166
+ token: providedToken,
1167
+ apiCall = forgejoApi,
1168
+ rootDir = process.cwd()
1169
+ } = options;
1170
+
1171
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
1172
+ const slug = slugMatch ? slugMatch[1] : null;
1173
+
1174
+ const { token } = resolveForgejoAuth({ forgejoUser, token: providedToken });
1175
+ if (!token) {
1176
+ return { ok: false, error: 'missing-token', reviewState: null };
1177
+ }
1178
+
1179
+ const prAccess = resolvePrAccess(branch, token, { apiCall, slug, forgejoUser, rootDir });
1180
+ if (prAccess && isApiErrorResult(prAccess)) {
1181
+ const apiErr = (prAccess as any)._apiError || {};
1182
+ return {
1183
+ ok: false,
1184
+ error: 'api-failed',
1185
+ reviewState: null,
1186
+ raw: `failed to resolve PR for ${branch}${apiErr.status === 7 ? ` (${codexSandboxHint()})` : ''}`
1187
+ };
1188
+ }
1189
+ if (!prAccess) {
1190
+ return { ok: false, error: 'pr-not-found', reviewState: null };
1191
+ }
1192
+ const prNumber = prAccess.prNumber;
1193
+
1194
+ const result = apiCall('GET', `/pulls/${prNumber}/reviews`, prAccess.token);
1195
+ if (!result.ok || !Array.isArray(result.data)) {
1196
+ return { ok: false, error: 'reviews-unavailable', reviewState: null, prNumber };
1197
+ }
1198
+
1199
+ // defaultUserApproved tracks whether the repo owner (always DEFAULT_FORGEJO_USER)
1200
+ // has approved, not the current CLI session user.
1201
+ const defaultUserLogin = DEFAULT_FORGEJO_USER;
1202
+ const reviews = result.data
1203
+ .map(/** @param {{user?: {login?: string}, state?: string, submitted_at?: string, created_at?: string, dismissed?: boolean}} review */ (review: any) => ({
1204
+ user: (review.user || {}).login || '?',
1205
+ state: review.state || '',
1206
+ submittedAt: review.submitted_at || review.created_at || '',
1207
+ dismissed: !!review.dismissed
1208
+ }))
1209
+ .filter(/** @param {{state: string, submittedAt: string, dismissed: boolean}} review */ (review: any) => review.state && review.submittedAt && !review.dismissed)
1210
+ .sort(/** @param {{submittedAt: string}} a @param {{submittedAt: string}} b */ (a: any, b: any) => new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime());
1211
+
1212
+ if (reviews.length === 0) {
1213
+ return { ok: true, prNumber, reviewState: null, defaultUserApproved: false };
1214
+ }
1215
+
1216
+ // Find the latest formal decision overall
1217
+ const formalReviews = reviews.filter(/** @param {{state: string}} r */ (r: any) => r.state === 'APPROVED' || r.state === 'REQUEST_CHANGES');
1218
+
1219
+ const finalState = formalReviews.length > 0
1220
+ ? formalReviews[formalReviews.length - 1].state
1221
+ : reviews[reviews.length - 1].state;
1222
+
1223
+ const defaultUserApproved = reviews.some(/** @param {{user: string, state: string}} r */ (r: any) => r.user === defaultUserLogin && r.state === 'APPROVED');
1224
+
1225
+ return {
1226
+ ok: true,
1227
+ prNumber,
1228
+ reviewState: finalState,
1229
+ defaultUserApproved
1230
+ };
1231
+ }
1232
+
1233
+ /**
1234
+ * Get the latest autonomous-review disposition comment posted by the implementer
1235
+ * after a given ISO timestamp.
1236
+ *
1237
+ * @param {string} branch - Mission branch
1238
+ * @param {string} implementerUser - Forgejo login of the implementer
1239
+ * @param {string} sinceIso - ISO 8601 timestamp to filter from
1240
+ * @param {string} token - Forgejo PAT
1241
+ * @param {object} [options] - Optional overrides
1242
+ * @returns {string|null} Disposition value (CHANGES_MADE|PUSHBACK_ALL|PARKED|BLOCKED) or null
1243
+ */
1244
+ function getLatestDisposition(branch: string, implementerUser: string, sinceIso: string, token: string, options: any = {}): string | null {
1245
+ /** @type {{apiCall?: Function, forgejoUser?: string, rootDir?: string}} */
1246
+ const {
1247
+ apiCall = forgejoApi,
1248
+ forgejoUser,
1249
+ rootDir = process.cwd()
1250
+ } = options;
1251
+
1252
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
1253
+ const slug = slugMatch ? slugMatch[1] : null;
1254
+ const prAccess = resolvePrAccess(branch, token, { apiCall, slug, forgejoUser, rootDir });
1255
+ if (prAccess && isApiErrorResult(prAccess)) {return null;}
1256
+ if (!prAccess) {return null;}
1257
+ const prNumber = prAccess.prNumber;
1258
+
1259
+ const result = apiCall('GET', `/issues/${prNumber}/comments`, prAccess.token);
1260
+ if (!result.ok || !Array.isArray(result.data)) {return null;}
1261
+
1262
+ const since = new Date(sinceIso).getTime();
1263
+
1264
+ const eligible = result.data
1265
+ .filter(/** @param {{user?: {login?: string}, created_at?: string, body?: string}} c */ (c: any) => {
1266
+ const user = (c.user || {}).login;
1267
+ const createdStr = c.created_at || '';
1268
+ const created = createdStr ? new Date(createdStr).getTime() : 0;
1269
+ const body = c.body || '';
1270
+ return user === implementerUser && created >= since && DISPOSITION_PATTERN.test(body);
1271
+ })
1272
+ .map(/** @param {{user?: {login?: string}, created_at?: string, body?: string}} c */ (c: any) => {
1273
+ const match = DISPOSITION_PATTERN.exec(c.body || '');
1274
+ return { disposition: match ? match[1] : null, createdAt: c.created_at || '' };
1275
+ })
1276
+ .filter(/** @param {{disposition: string|null}} e */ (e: any) => e.disposition)
1277
+ .sort(/** @param {{createdAt: string}} a @param {{createdAt: string}} b */ (a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
1278
+
1279
+ return eligible.length > 0 ? eligible[eligible.length - 1].disposition : null;
1280
+ }
1281
+
1282
+ async function getLatestReviewForPr(prNumber: number, reviewerUser: string, sinceIso: string, token: string, options: any = {}) {
1283
+ /** @type {{apiCall?: Function}} */
1284
+ const {
1285
+ apiCall = forgejoApiAsync
1286
+ } = options;
1287
+
1288
+ const result = await apiCall('GET', `/pulls/${prNumber}/reviews`, token);
1289
+ if (!result.ok || !Array.isArray(result.data)) {return null;}
1290
+
1291
+ const since = new Date(sinceIso).getTime();
1292
+
1293
+ const eligible = result.data
1294
+ .filter(/** @param {{user?: {login?: string}, submitted_at?: string, created_at?: string, state?: string}} r */ (r: any) => {
1295
+ const user = (r.user || {}).login;
1296
+ const submittedAt = r.submitted_at || r.created_at || '';
1297
+ const submitted = submittedAt ? new Date(submittedAt).getTime() : 0;
1298
+ return user === reviewerUser && submitted >= since;
1299
+ })
1300
+ .map(/** @param {{state?: string, submitted_at?: string, created_at?: string}} r */ (r: any) => ({ state: r.state, submittedAt: r.submitted_at || r.created_at || '' }))
1301
+ .sort(/** @param {{submittedAt: string}} a @param {{submittedAt: string}} b */ (a: any, b: any) => new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime());
1302
+
1303
+ return eligible.length > 0 ? eligible[eligible.length - 1] : null;
1304
+ }
1305
+
1306
+ async function getLatestDispositionForPr(prNumber: number, implementerUser: string, sinceIso: string, token: string, options: any = {}) {
1307
+ /** @type {{apiCall?: Function}} */
1308
+ const {
1309
+ apiCall = forgejoApiAsync
1310
+ } = options;
1311
+
1312
+ const result = await apiCall('GET', `/issues/${prNumber}/comments`, token);
1313
+ if (!result.ok || !Array.isArray(result.data)) {return null;}
1314
+
1315
+ const since = new Date(sinceIso).getTime();
1316
+
1317
+ const eligible = result.data
1318
+ .filter(/** @param {{user?: {login?: string}, created_at?: string, body?: string}} c */ (c: any) => {
1319
+ const user = (c.user || {}).login;
1320
+ const createdAt = c.created_at || '';
1321
+ const created = createdAt ? new Date(createdAt).getTime() : 0;
1322
+ const body = c.body || '';
1323
+ return user === implementerUser && created >= since && DISPOSITION_PATTERN.test(body);
1324
+ })
1325
+ .map(/** @param {{user?: {login?: string}, created_at?: string, body?: string}} c */ (c: any) => {
1326
+ const match = DISPOSITION_PATTERN.exec(c.body || '');
1327
+ return { disposition: match ? match[1] : null, createdAt: c.created_at || '' };
1328
+ })
1329
+ .filter(/** @param {{disposition: string|null}} e */ (e: any) => e.disposition)
1330
+ .sort(/** @param {{createdAt: string}} a @param {{createdAt: string}} b */ (a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
1331
+
1332
+ return eligible.length > 0 ? eligible[eligible.length - 1].disposition : null;
1333
+ }
1334
+
1335
+ /**
1336
+ * Post a comment on the Forgejo PR for a given branch.
1337
+ *
1338
+ * @param {string} branch - Mission branch (e.g. 'mission/task-089')
1339
+ * @param {string} token - Forgejo PAT
1340
+ * @param {string} body - Comment body (markdown)
1341
+ * @returns {{ ok: boolean, data: any, status: number|null, error?: string, raw?: string }}
1342
+ */
1343
+ function postComment(branch: string, token: string, body: string, options: any = {}): { ok: boolean, data: any, status: number | null, error?: string, raw?: string } {
1344
+ /** @type {{apiCall?: Function, resolvePrNumber?: Function, forgejoUser?: string}} */
1345
+ const {
1346
+ apiCall = forgejoApi,
1347
+ resolvePrNumber = getPrNumber,
1348
+ forgejoUser
1349
+ } = options;
1350
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
1351
+ const slug = slugMatch ? slugMatch[1] : null;
1352
+ const prNumber = resolvePrNumber(branch, token, { apiCall, slug, forgejoUser });
1353
+ if (isApiErrorResult(prNumber)) {
1354
+ const apiErr = prNumber._apiError || {};
1355
+ return { ok: false, data: null, status: null, error: 'api-failed', raw: `failed to resolve PR for ${branch}${(apiErr.status || 0) === 7 ? ` (${codexSandboxHint()})` : ''}` };
1356
+ }
1357
+ if (!prNumber) {return { ok: false, data: null, status: null, error: 'pr-not-found' };}
1358
+ return apiCall('POST', `/issues/${prNumber}/comments`, token, { body });
1359
+ }
1360
+
1361
+ const REVIEW_OUTCOME_MAP = {
1362
+ 'approve': 'APPROVED',
1363
+ 'request-changes': 'REQUEST_CHANGES',
1364
+ 'comment': 'COMMENT'
1365
+ };
1366
+
1367
+ /**
1368
+ * Submit a formal review outcome on the Forgejo PR for a given branch.
1369
+ *
1370
+ * @param {string} branch - Mission branch (e.g. 'mission/task-089')
1371
+ * @param {string} token - Forgejo PAT
1372
+ * @param {string} outcome - 'approve' | 'request-changes' | 'comment'
1373
+ * @param {string} summary - Review summary text
1374
+ * @returns {{ ok: boolean, data: any, status: number|null, error?: string, raw?: string }}
1375
+ */
1376
+ function postReview(branch: string, token: string, outcome: string, summary: string, options: any = {}): { ok: boolean, data: any, status: number | null, error?: string, raw?: string } {
1377
+ /** @type {{apiCall?: Function, resolvePrNumber?: Function, forgejoUser?: string}} */
1378
+ const {
1379
+ apiCall = forgejoApi,
1380
+ resolvePrNumber = getPrNumber,
1381
+ forgejoUser
1382
+ } = options;
1383
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
1384
+ const slug = slugMatch ? slugMatch[1] : null;
1385
+ const prNumber = resolvePrNumber(branch, token, { apiCall, slug, forgejoUser });
1386
+ if (isApiErrorResult(prNumber)) {
1387
+ const apiErr = prNumber._apiError || {};
1388
+ return { ok: false, data: null, status: null, error: 'api-failed', raw: `failed to resolve PR for ${branch}${(apiErr.status || 0) === 7 ? ` (${codexSandboxHint()})` : ''}` };
1389
+ }
1390
+ if (!prNumber) {return { ok: false, data: null, status: null, error: 'pr-not-found' };}
1391
+
1392
+ // Resolve current head SHA for the PR to avoid submission failures if PR updated
1393
+ const prRes = apiCall('GET', `/pulls/${prNumber}`, token);
1394
+ const commit_id = (prRes.ok && prRes.data && prRes.data.head) ? prRes.data.head.sha : null;
1395
+
1396
+ const event = /** @type {'APPROVED'|'REQUEST_CHANGES'|'COMMENT'|undefined} */ (REVIEW_OUTCOME_MAP[outcome as keyof typeof REVIEW_OUTCOME_MAP]);
1397
+ if (!event) {return { ok: false, data: null, status: null, error: `unsupported-outcome: ${outcome}` };}
1398
+
1399
+ /** @type {{body: string, event: string, commit_id?: string|null}} */
1400
+ const payload: any = { body: summary, event };
1401
+ if (commit_id) {
1402
+ payload.commit_id = commit_id;
1403
+ }
1404
+
1405
+ const result = apiCall('POST', `/pulls/${prNumber}/reviews`, token, payload);
1406
+ if (!result.ok && result.stderr) {
1407
+ fmt.log.info(`curl stderr: ${result.stderr}`);
1408
+ }
1409
+ return result;
1410
+ }
1411
+
1412
+ /**
1413
+ * Check if Forgejo is reachable at the configured URL.
1414
+ * Returns true if Forgejo responds to HTTP requests within the timeout period.
1415
+ *
1416
+ * @param {string} [url='http://localhost:3300']
1417
+ * @param {object} [options]
1418
+ * @param {Function} [options.request] Injected request implementation for tests.
1419
+ * @param {number} [options.timeout=HTTP_REQUEST_TIMEOUT]
1420
+ * @returns {Promise<boolean>} True if Forgejo is reachable, false otherwise
1421
+ */
1422
+ function forgejoAvailable(url = process.env.FORGEJO_URL || 'http://localhost:3300', options: { request?: Function, timeout?: number } = {} as { request?: Function, timeout?: number }): Promise<boolean> {
1423
+ const {
1424
+ request = http.request,
1425
+ timeout = HTTP_REQUEST_TIMEOUT
1426
+ } = options;
1427
+ const targetUrl = new URL(url);
1428
+
1429
+ return new Promise((resolve) => {
1430
+ const req = request(targetUrl, { method: 'GET', timeout }, (/** @type {import('http').IncomingMessage} */ res: any) => {
1431
+ req.destroy();
1432
+ resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300);
1433
+ });
1434
+ req.on('error', () => resolve(false));
1435
+ req.on('timeout', () => {
1436
+ req.destroy();
1437
+ resolve(false);
1438
+ });
1439
+ req.end();
1440
+ });
1441
+ }
1442
+
1443
+ function syncMerged(branch: string, mergedCommit: string, options: any = {}) {
1444
+ /** @type {{forgejoUser?: string, rootDir?: string, token?: string|null, baseBranch?: string|null, apiCall?: Function, resolvePrNumber?: Function, gitPush?: Function, gitFetch?: Function, gitContainsCommit?: Function, gitDelete?: Function, verifyCommit?: Function, log?: Function}} */
1445
+ const {
1446
+ forgejoUser,
1447
+ rootDir = process.cwd(),
1448
+ token: providedToken,
1449
+ baseBranch = null,
1450
+ apiCall = forgejoApi,
1451
+ resolvePrNumber = getPrNumber,
1452
+ gitPush = pushReviewRef,
1453
+ gitFetch = fetchReviewBranch,
1454
+ gitContainsCommit = remoteRefContainsCommit,
1455
+ gitDelete = deleteReviewRef,
1456
+ verifyCommit = verifyCommitExists,
1457
+ log = fmt.log.info
1458
+ } = options;
1459
+
1460
+ if (!branch) {
1461
+ return { ok: false, error: 'missing-branch' };
1462
+ }
1463
+
1464
+ if (!mergedCommit) {
1465
+ return { ok: false, error: 'missing-merged-commit' };
1466
+ }
1467
+
1468
+ const { token } = resolveForgejoAuth({ forgejoUser, token: providedToken || undefined });
1469
+ if (!token) {
1470
+ return { ok: false, error: 'missing-token' };
1471
+ }
1472
+
1473
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
1474
+ const slug = slugMatch ? slugMatch[1] : null;
1475
+
1476
+ const prNumber = resolvePrNumber(branch, token, { slug, rootDir });
1477
+ if (isApiErrorResult(prNumber)) {
1478
+ const apiErr = prNumber._apiError || {};
1479
+ return {
1480
+ ok: false,
1481
+ error: 'api-failed',
1482
+ raw: `failed to resolve PR for ${branch}${(apiErr.status || 0) === 7 ? ` (${codexSandboxHint()})` : ''}`
1483
+ };
1484
+ }
1485
+ if (!prNumber) {
1486
+ return { ok: false, error: 'pr-not-found' };
1487
+ }
1488
+
1489
+ const commitResult = verifyCommit(mergedCommit, rootDir);
1490
+ if (commitResult.status !== 0) {
1491
+ return { ok: false, error: 'missing-commit' };
1492
+ }
1493
+
1494
+ const verifyMergeState = (/** @type {number} */ statusCode: any) => {
1495
+ const prDetails = apiCall('GET', `/pulls/${prNumber}`, token, undefined, { rootDir });
1496
+ if (!prDetails.ok) {
1497
+ return { ok: false, error: 'merge-verify-failed', prNumber, statusCode: prDetails.statusCode };
1498
+ }
1499
+
1500
+ const pr = prDetails.data || {};
1501
+ const baseSha = pr.base ? pr.base.sha : null;
1502
+ const headSha = pr.head ? pr.head.sha : null;
1503
+ const shaMatch = (/** @type {string|null} */ a: any, /** @type {string|null} */ b: any) => a && b && (a === b || b.startsWith(a));
1504
+ if (shaMatch(baseSha, mergedCommit) && shaMatch(headSha, mergedCommit)) {
1505
+ log(`PR #${prNumber} (${branch}): confirmed head/base match ${mergedCommit} (already merged)`);
1506
+ return { ok: true };
1507
+ }
1508
+
1509
+ return {
1510
+ ok: false,
1511
+ error: 'merge-conflict-sha-mismatch',
1512
+ prNumber,
1513
+ statusCode,
1514
+ expected: mergedCommit,
1515
+ baseSha,
1516
+ headSha
1517
+ };
1518
+ };
1519
+
1520
+ // The mission lands back onto its recorded base branch, which is the primary
1521
+ // branch (main) for normal missions but a feature branch (e.g. skunkworks) for
1522
+ // missions started from a feature branch. Pushing the squash commit anywhere
1523
+ // other than that base branch is a non-fast-forward and is rejected.
1524
+ let primaryBranch = baseBranch;
1525
+ if (!primaryBranch) {
1526
+ try {
1527
+ primaryBranch = getPrimaryBranch(rootDir);
1528
+ } catch (_) {
1529
+ primaryBranch = 'main';
1530
+ }
1531
+ }
1532
+ log(`PR #${prNumber} (${branch}): pushing landed commit ${mergedCommit} to Forgejo ${primaryBranch}...`);
1533
+ const pushMasterResult = gitPush(mergedCommit, `refs/heads/${primaryBranch}`, rootDir, { user: forgejoUser, token });
1534
+ if (pushMasterResult.status !== 0) {
1535
+ log(`PR #${prNumber} (${branch}): ${primaryBranch} push failed; checking whether review/${primaryBranch} already contains ${mergedCommit}`);
1536
+ const fetchMasterResult = gitFetch(primaryBranch, rootDir, { user: forgejoUser, token });
1537
+ const containsResult = fetchMasterResult.status === 0
1538
+ ? gitContainsCommit(mergedCommit, `refs/remotes/review/${primaryBranch}`, rootDir)
1539
+ : { status: 1 };
1540
+ if (containsResult.status === 0) {
1541
+ log(`PR #${prNumber} (${branch}): review/${primaryBranch} already contains ${mergedCommit}; continuing sync-merged closeout`);
1542
+ } else {
1543
+ return { ok: false, error: 'push-primary-failed', prNumber, raw: pushOutput(pushMasterResult) };
1544
+ }
1545
+ }
1546
+
1547
+ log(`PR #${prNumber} (${branch}): updating remote branch to ${mergedCommit}...`);
1548
+ const fetchBeforePushResult = gitFetch(branch, rootDir, { user: forgejoUser, token });
1549
+ if (fetchBeforePushResult.status === 0) {
1550
+ log(`PR #${prNumber} (${branch}): refreshed review/${branch} before branch sync`);
1551
+ } else {
1552
+ log(`PR #${prNumber} (${branch}): could not refresh review/${branch}; attempting force-with-lease branch sync`);
1553
+ }
1554
+
1555
+ // The squash commit is not a descendant of the mission branch tip, so a force push is required.
1556
+ let pushBranchResult = gitPush(mergedCommit, `refs/heads/${branch}`, rootDir, { forceWithLease: true, user: forgejoUser, token });
1557
+ if (isStaleInfoPushRejection(pushBranchResult)) {
1558
+ log(`PR #${prNumber} (${branch}): branch sync rejected as stale; fetching review/${branch} and retrying`);
1559
+ gitFetch(branch, rootDir, { user: forgejoUser, token });
1560
+ pushBranchResult = gitPush(mergedCommit, `refs/heads/${branch}`, rootDir, { forceWithLease: true, user: forgejoUser, token });
1561
+ if (isStaleInfoPushRejection(pushBranchResult)) {
1562
+ log(`PR #${prNumber} (${branch}): force-with-lease still stale; using force push for landed squash commit`);
1563
+ pushBranchResult = gitPush(mergedCommit, `refs/heads/${branch}`, rootDir, { force: true, user: forgejoUser, token });
1564
+ }
1565
+ }
1566
+ if (pushBranchResult.status !== 0) {
1567
+ return { ok: false, error: 'push-branch-failed', prNumber, raw: pushOutput(pushBranchResult) };
1568
+ }
1569
+
1570
+ const mergePayload = {
1571
+ Do: 'manually-merged',
1572
+ MergeCommitID: mergedCommit,
1573
+ head_commit_id: mergedCommit,
1574
+ delete_branch_after_merge: true
1575
+ };
1576
+ const mergeResult = apiCall('POST', `/pulls/${prNumber}/merge`, token, mergePayload, { rootDir });
1577
+ if (!mergeResult.ok) {
1578
+ if (mergeResult.statusCode === 409 || mergeResult.statusCode === 405) {
1579
+ log(`PR #${prNumber} (${branch}): received ${mergeResult.statusCode}${mergeResult.statusCode === 405 ? ' Method Not Allowed' : ' Conflict'}, verifying remote commit state...`);
1580
+ const verificationResult = verifyMergeState(mergeResult.statusCode);
1581
+ if (!verificationResult.ok) {
1582
+ return verificationResult;
1583
+ }
1584
+ } else {
1585
+ return { ok: false, error: 'merge-api-failed', prNumber, statusCode: mergeResult.statusCode };
1586
+ }
1587
+ }
1588
+
1589
+ const deleteResult = gitDelete(branch, rootDir, { user: forgejoUser, token });
1590
+ const branchDeleted = deleteResult.status === 0;
1591
+ if (branchDeleted) {
1592
+ log(`PR #${prNumber} (${branch}): remote branch deleted`);
1593
+ } else {
1594
+ log(`PR #${prNumber} (${branch}): remote branch already gone or could not be deleted after merge`);
1595
+ }
1596
+
1597
+ log(`PR #${prNumber} (${branch}) marked merged at ${mergedCommit}`);
1598
+ return {
1599
+ ok: true,
1600
+ prNumber,
1601
+ branchDeleted
1602
+ };
1603
+ }
1604
+
1605
+ /**
1606
+ * Retrieve all comments (issue, review, and inline) for a given branch.
1607
+ *
1608
+ * @param {string} branch - Mission branch
1609
+ * @param {string} token - Forgejo PAT
1610
+ * @param {object} [options]
1611
+ * @param {Function} [options.apiCall] - Forgejo API function, injectable for tests
1612
+ * @param {Function} [options.log] - Logger, injectable for tests
1613
+ * @returns {any[]|null} - Array of comment objects sorted by creation time, or null if comments could not be fetched
1614
+ */
1615
+ function getCommentsSync(branch: string, token: string, options: any = {}): any[] | null {
1616
+ /** @type {{apiCall?: Function, forgejoUser?: string, rootDir?: string, log?: Function}} */
1617
+ const {
1618
+ apiCall = forgejoApi,
1619
+ forgejoUser,
1620
+ rootDir = process.cwd(),
1621
+ log: logger = noopLog
1622
+ } = options;
1623
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
1624
+ const slug = slugMatch ? slugMatch[1] : null;
1625
+ const prAccess = resolvePrAccess(branch, token, { apiCall, slug, forgejoUser, rootDir });
1626
+ if (prAccess && isApiErrorResult(prAccess)) {
1627
+ logger(`getComments API error resolving PR for ${branch}: status=${((prAccess as any)._apiError || {}).status || 0}`);
1628
+ return null;
1629
+ }
1630
+ if (!prAccess) {return null;}
1631
+ const prNumber = prAccess.prNumber;
1632
+ const accessToken = prAccess.token;
1633
+
1634
+ const issueCommentsRes = apiCall('GET', `/issues/${prNumber}/comments`, accessToken);
1635
+ const reviewsRes = apiCall('GET', `/pulls/${prNumber}/reviews`, accessToken);
1636
+
1637
+ if (!issueCommentsRes.ok || !reviewsRes.ok) {
1638
+ logger(`getComments API failure: issueCommentsRes.ok=${issueCommentsRes.ok}, reviewsRes.ok=${reviewsRes.ok}`);
1639
+ return null;
1640
+ }
1641
+
1642
+ const issueComments = issueCommentsRes.data || [];
1643
+ const reviews = reviewsRes.data || [];
1644
+ const allComments = /** @type {Array<{kind: string, user: string, created: string, body: string, state?: string, location?: string}>} */ ([]);
1645
+
1646
+ // 1. Process issue comments
1647
+ issueComments.forEach(/** @param {{user?: {login?: string}, created_at?: string, body?: string}} c */ (c: any) => {
1648
+ allComments.push({
1649
+ kind: 'issue-comment',
1650
+ user: (c.user || {}).login || '?',
1651
+ created: (c.created_at || '').substring(0, 16),
1652
+ body: (c.body || '').trim()
1653
+ });
1654
+ });
1655
+
1656
+ // 2. Process reviews and their inline comments
1657
+ for (const r of reviews) {
1658
+ const flags = [];
1659
+ if (r.stale) {flags.push('stale');}
1660
+ if (r.dismissed) {flags.push('dismissed');}
1661
+ const kind = flags.length > 0 ? `review [${flags.join(', ')}]` : 'review';
1662
+
1663
+ allComments.push({
1664
+ kind,
1665
+ user: (r.user || {}).login || '?',
1666
+ created: (r.submitted_at || r.created_at || '').substring(0, 16),
1667
+ body: (r.body || '').trim(),
1668
+ state: r.state || ''
1669
+ });
1670
+
1671
+ // Fetch inline comments for this review
1672
+ const inlineRes = apiCall('GET', `/pulls/${prNumber}/reviews/${r.id}/comments`, accessToken);
1673
+ if (inlineRes.ok && Array.isArray(inlineRes.data)) {
1674
+ inlineRes.data.forEach(/** @param {{user?: {login?: string}, created_at?: string, body?: string, path?: string, line?: number, original_line?: number}} c */ (c: any) => {
1675
+ const iFlags = [];
1676
+ if (r.stale) {iFlags.push('stale');}
1677
+ if (r.dismissed) {iFlags.push('dismissed');}
1678
+ let iKind = 'inline-comment';
1679
+ if (iFlags.length > 0) {iKind += ` [${iFlags.join(', ')}]`;}
1680
+
1681
+ const path = c.path || '';
1682
+ const line = c.line || c.original_line || '';
1683
+ const location = line ? `${path}:${line}` : path;
1684
+
1685
+ allComments.push({
1686
+ kind: iKind,
1687
+ user: (c.user || {}).login || '?',
1688
+ created: (c.created_at || '').substring(0, 16),
1689
+ body: (c.body || '').trim(),
1690
+ location
1691
+ });
1692
+ });
1693
+ }
1694
+ }
1695
+
1696
+ return allComments.sort(/** @param {{created: string}} a @param {{created: string}} b */ (a: any, b: any) => a.created.localeCompare(b.created));
1697
+ }
1698
+
1699
+ async function getComments(branch: string, token: string, options: any = {}) {
1700
+ return getCommentsSync(branch, token, options);
1701
+ }
1702
+
1703
+ /**
1704
+ * Close a Forgejo PR and delete the remote branch.
1705
+ * Closes a PR and performs cleanup.
1706
+ *
1707
+ * @param {string} branch - Mission branch
1708
+ * @param {string} token - Forgejo PAT
1709
+ * @returns {Promise<Object>} - { ok: boolean, error: string }
1710
+ */
1711
+ async function closePr(branch: string, token: string): Promise<Object> {
1712
+ const slugMatch = branch.match(/^mission\/(task-\d+)/);
1713
+ const slug = slugMatch ? slugMatch[1] : null;
1714
+ const rootDir = process.cwd();
1715
+ const prNumber = getPrNumber(branch, token, { slug, rootDir });
1716
+
1717
+ if (prNumber && typeof prNumber === 'object' && isApiErrorResult(prNumber)) {
1718
+ const apiErr = (prNumber as any)._apiError || {};
1719
+ fmt.log.warn(`API error resolving PR for ${fmt.branch(branch)}: status=${(apiErr as {status?: number}).status || 0}${((apiErr as {status?: number}).status || 0) === 7 ? ` (${codexSandboxHint()})` : ''}`);
1720
+ return { ok: true };
1721
+ }
1722
+
1723
+ if (prNumber && typeof prNumber === 'number') {
1724
+ // Check current state
1725
+ const prDetails = forgejoApi('GET', `/pulls/${prNumber}`, token, undefined, { rootDir });
1726
+ if (prDetails.ok) {
1727
+ const pr = prDetails.data;
1728
+ if (pr.state !== 'closed' && !pr.merged) {
1729
+ const closeResult = forgejoApi('PATCH', `/pulls/${prNumber}`, token, { state: 'closed' }, { rootDir });
1730
+ if (!closeResult.ok) {
1731
+ return { ok: false, error: `failed to close PR #${prNumber}: ${JSON.stringify(closeResult.data)}` };
1732
+ }
1733
+ fmt.log.pass(`PR #${prNumber} closed.`);
1734
+ } else {
1735
+ fmt.log.info(`PR #${prNumber} is already ${pr.state}${pr.merged ? ' and merged' : ''}.`);
1736
+ }
1737
+ }
1738
+ } else {
1739
+ fmt.log.info(`No open PR found for ${fmt.branch(branch)}.`);
1740
+ }
1741
+
1742
+ // Delete remote branch
1743
+ fmt.log.info(`Deleting remote branch ${fmt.branch(branch)}...`);
1744
+ const deleteResult = deleteReviewRef(branch, rootDir);
1745
+ if (deleteResult.status === 0) {
1746
+ fmt.log.pass(`Remote branch ${fmt.branch(branch)} deleted.`);
1747
+ } else {
1748
+ fmt.log.info(`Remote branch ${fmt.branch(branch)} already gone or could not be deleted.`);
1749
+ }
1750
+
1751
+ return { ok: true };
1752
+ }
1753
+
1754
+
1755
+ export { getPrStatus };
1756
+ export { resolveForgejoUser };
1757
+ export { resolveForgejoHome };
1758
+ export { isForgejoPath };
1759
+ export { resolveTokenFile };
1760
+ export { readToken };
1761
+ export { forgejoApi };
1762
+ export { forgejoApiAsync };
1763
+ export { getPrNumber };
1764
+ export { getPrAuthor };
1765
+ export { listOpenPrsForSlug };
1766
+ export { isApiErrorResult };
1767
+ export { getLatestReview };
1768
+ export { getLatestReviewForPr };
1769
+ export { getLatestReviewDecision };
1770
+ export { getLatestDisposition };
1771
+ export { getLatestDispositionForPr };
1772
+ export { postComment };
1773
+ export { forgejoAvailable };
1774
+ export { postReview };
1775
+ export { syncMerged };
1776
+ export { pushReviewRef };
1777
+ export { isStaleInfoPushRejection };
1778
+ export { fetchReviewBranch };
1779
+ export { deleteReviewRef };
1780
+ export { verifyCommitExists };
1781
+ export { remoteRefContainsCommit };
1782
+ export { resolveTrackingBranchSha };
1783
+ export { deriveRepoFromGitRemote };
1784
+ export { resolveForgejoSettings };
1785
+ export { reviewRemoteUrl };
1786
+ export { authenticatedReviewUrl };
1787
+ export { syncPrimaryBaseline };
1788
+ export { ensureRemoteBaseBranch };
1789
+ export { createPr };
1790
+ export { getCommentsSync };
1791
+ export { getComments };
1792
+ export { closePr };
1793
+ ;