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