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