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