@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.
- package/index.js +190 -185
- package/lib/commands/active.js +502 -579
- package/lib/commands/active.ts +665 -0
- package/lib/commands/checkpoint.js +98 -74
- package/lib/commands/checkpoint.ts +79 -0
- package/lib/commands/config.js +61 -31
- package/lib/commands/config.ts +47 -0
- package/lib/commands/coverage-gate.js +271 -246
- package/lib/commands/coverage-gate.ts +362 -0
- package/lib/commands/diff.js +140 -119
- package/lib/commands/diff.ts +125 -0
- package/lib/commands/draft.js +770 -923
- package/lib/commands/draft.ts +1021 -0
- package/lib/commands/handoff.js +588 -608
- package/lib/commands/handoff.ts +694 -0
- package/lib/commands/integrate.js +1331 -1468
- package/lib/commands/integrate.ts +1688 -0
- package/lib/commands/mission-start.js +279 -237
- package/lib/commands/mission-start.ts +263 -0
- package/lib/commands/rebase.js +606 -582
- package/lib/commands/rebase.ts +635 -0
- package/lib/commands/repair-handoff.js +181 -179
- package/lib/commands/repair-handoff.ts +228 -0
- package/lib/commands/resolve-conflict.js +121 -102
- package/lib/commands/resolve-conflict.ts +112 -0
- package/lib/commands/review.js +3 -4
- package/lib/commands/review.ts +14 -0
- package/lib/commands/setup-review.js +4 -5
- package/lib/commands/setup-review.ts +14 -0
- package/lib/commands/setup.js +3 -3
- package/lib/commands/setup.ts +2 -0
- package/lib/commands/stats-backfill.js +394 -359
- package/lib/commands/stats-backfill.ts +418 -0
- package/lib/commands/stats.js +1424 -1665
- package/lib/commands/stats.ts +2186 -0
- package/lib/commands/status.js +219 -200
- package/lib/commands/status.ts +219 -0
- package/lib/commands/verify.js +3 -1
- package/lib/commands/verify.ts +2 -0
- package/lib/core/nels.js +112 -127
- package/lib/core/nels.ts +189 -0
- package/lib/core/subagent-limit.js +15 -16
- package/lib/core/subagent-limit.ts +25 -0
- package/lib/index.js +142 -59
- package/lib/index.ts +134 -0
- package/lib/review/rebase.js +142 -145
- package/lib/review/rebase.ts +188 -0
- package/lib/review/review-adapter.js +99 -87
- package/lib/review/review-adapter.ts +198 -0
- package/lib/review/review-artifacts.js +486 -629
- package/lib/review/review-artifacts.ts +622 -0
- package/lib/review/review-commands.js +1220 -1373
- package/lib/review/review-commands.ts +1579 -0
- package/lib/review/review-events.js +544 -886
- package/lib/review/review-events.ts +954 -0
- package/lib/review/review-loop.js +945 -1020
- package/lib/review/review-loop.ts +1174 -0
- package/lib/review/review-polling.js +86 -115
- package/lib/review/review-polling.ts +194 -0
- package/lib/review/review-prompts.js +154 -189
- package/lib/review/review-prompts.ts +182 -0
- package/lib/review/review-state.js +246 -225
- package/lib/review/review-state.ts +353 -0
- package/lib/review/review.js +63 -84
- package/lib/review/review.ts +138 -0
- package/lib/tools/backlog.js +694 -680
- package/lib/tools/backlog.ts +835 -0
- package/lib/tools/forgejo.js +1270 -1405
- package/lib/tools/forgejo.ts +1793 -0
- package/lib/tools/gatekeeper.js +106 -96
- package/lib/tools/gatekeeper.ts +124 -0
- package/lib/tools/redgreen.js +200 -181
- package/lib/tools/redgreen.ts +221 -0
- package/lib/tools/sessions.js +58 -52
- package/lib/tools/sessions.ts +81 -0
- package/lib/tools/setup-review.js +902 -975
- package/lib/tools/setup-review.ts +1152 -0
- package/package.json +4 -4
- package/px.js +226 -208
package/lib/tools/forgejo.js
CHANGED
|
@@ -1,210 +1,271 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
return `${rootDir}::${remoteName}`;
|
|
24
96
|
}
|
|
25
97
|
/** @param {string} rootDir @param {string} remoteName @returns {string|null} */
|
|
26
98
|
function deriveRepoFromGitRemote(rootDir, remoteName) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
125
|
+
return explicitUser || process.env.FORGEJO_USER || DEFAULT_FORGEJO_USER;
|
|
54
126
|
}
|
|
55
|
-
|
|
56
127
|
function resolveForgejoHome() {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
if (!pushArgsResult.ok) {
|
|
548
|
+
gitFetch: authenticatedGitFetch
|
|
549
|
+
});
|
|
550
|
+
if (!pushArgsResult.ok) {
|
|
533
551
|
return { ok: false, error: pushArgsResult.error };
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
forgejoUser,
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
687
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
forceWithLease
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
};
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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(),
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
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
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
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
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
forgejoUser
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
forgejoUser
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
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
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
if (r.
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
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
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
1736
|
-
fmt.log.info(`PR
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
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
|
+
;
|