atris 3.68.9 → 3.70.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/README.md +53 -0
- package/atris/team/opus-overnight/MEMBER.md +1 -0
- package/commands/init.js +9 -0
- package/commands/member.js +436 -40
- package/commands/mission.js +158 -12
- package/commands/sync.js +13 -0
- package/commands/task.js +63 -1
- package/commands/worktree.js +42 -5
- package/lib/auto-accept-certified.js +1 -0
- package/lib/engine-ask.js +2 -1
- package/lib/engine-registry.js +5 -4
- package/lib/engine-validate.js +189 -9
- package/lib/gitignore.js +56 -0
- package/lib/member-autoland.js +442 -0
- package/lib/member-board.js +257 -29
- package/lib/member-progress.js +68 -4
- package/lib/member-report.js +314 -0
- package/lib/member-scaffold.js +1 -2
- package/lib/member-switches.js +7 -0
- package/lib/member-task-pick.js +116 -0
- package/lib/member-tidy.js +235 -0
- package/lib/member-up.js +286 -0
- package/lib/member-verbs.js +84 -0
- package/lib/runner-command.js +29 -0
- package/lib/task-db.js +21 -1
- package/package.json +1 -1
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// After an ignited member run ends with its check passing, hand the branch to
|
|
4
|
+
// GitHub auto-merge. Every local gate fails closed: the branch stays put and
|
|
5
|
+
// one plain line says which gate held it. GitHub then waits for the branch to
|
|
6
|
+
// be up to date and CI green; this never merges, rebases, or polls itself.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const {
|
|
13
|
+
deniedTagOf,
|
|
14
|
+
declaredProtectedLane,
|
|
15
|
+
sniffedProtectedLane,
|
|
16
|
+
} = require('./auto-accept-certified');
|
|
17
|
+
const { isDecisionHoldTag, isDecisionTask } = require('./task-decision');
|
|
18
|
+
|
|
19
|
+
// Git replacement refs can make a local check read different code than the
|
|
20
|
+
// commit that gets pushed; every git and gh call here ignores them.
|
|
21
|
+
function run(cmd, args, cwd, extraEnv = {}, { raw = false } = {}) {
|
|
22
|
+
const env = { ...process.env, ...extraEnv, GIT_NO_REPLACE_OBJECTS: '1' };
|
|
23
|
+
const res = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: 120000, env });
|
|
24
|
+
const stdout = String(res.stdout || '');
|
|
25
|
+
return {
|
|
26
|
+
ok: res.status === 0,
|
|
27
|
+
status: res.status,
|
|
28
|
+
stdout: raw ? stdout : stdout.trim(),
|
|
29
|
+
stderr: String(res.stderr || res.error?.message || '').trim(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The policy lives in the main checkout, not the mission's worktree copy.
|
|
34
|
+
function mainCheckoutRoot(cwd) {
|
|
35
|
+
const common = run('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
|
|
36
|
+
if (!common.ok || !common.stdout) return null;
|
|
37
|
+
return path.dirname(common.stdout);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function policyOn(cwd) {
|
|
41
|
+
const root = mainCheckoutRoot(cwd);
|
|
42
|
+
if (!root) return false;
|
|
43
|
+
try {
|
|
44
|
+
const policy = JSON.parse(fs.readFileSync(path.join(root, '.atris', 'policy', 'autoland.json'), 'utf8'));
|
|
45
|
+
return Boolean(policy) && typeof policy === 'object' && policy.enabled === true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TAG_KEYS = new Set(['tag', 'tags', 'todo_tags', 'lane']);
|
|
52
|
+
const METADATA_DEPTH_LIMIT = 6;
|
|
53
|
+
const METADATA_TEXT_CAP = 64 * 1024;
|
|
54
|
+
|
|
55
|
+
// Every string anywhere in metadata, and the ones anywhere below a tag key.
|
|
56
|
+
// Metadata too deep or too large to read whole returns null, which holds.
|
|
57
|
+
function metadataStrings(metadata) {
|
|
58
|
+
const texts = [];
|
|
59
|
+
const tags = [];
|
|
60
|
+
let size = 0;
|
|
61
|
+
const walk = (value, depth, tagged) => {
|
|
62
|
+
if (typeof value === 'string') {
|
|
63
|
+
size += value.length;
|
|
64
|
+
texts.push(value);
|
|
65
|
+
if (tagged) tags.push(value);
|
|
66
|
+
return size <= METADATA_TEXT_CAP;
|
|
67
|
+
}
|
|
68
|
+
if (!value || typeof value !== 'object') return true;
|
|
69
|
+
if (depth >= METADATA_DEPTH_LIMIT) return false;
|
|
70
|
+
if (Array.isArray(value)) return value.every((item) => walk(item, depth + 1, tagged));
|
|
71
|
+
return Object.entries(value).every(([key, item]) => walk(item, depth + 1, tagged || TAG_KEYS.has(key)));
|
|
72
|
+
};
|
|
73
|
+
return walk(metadata, 0, false) ? { texts, tags } : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A row's protected lane, or null. Tags are its own tag and lane, every
|
|
77
|
+
// string under a tag key anywhere in metadata, and '#tag' words in the title.
|
|
78
|
+
// Text is the title, objective, and every string in metadata.
|
|
79
|
+
function protectedLane(row) {
|
|
80
|
+
if (!row || typeof row !== 'object') return 'unreadable';
|
|
81
|
+
// Metadata is a plain object or absent; anything else, including stored
|
|
82
|
+
// text that would not parse, cannot be checked.
|
|
83
|
+
const meta = row.metadata;
|
|
84
|
+
if (row.metadata_unreadable || (meta !== undefined && meta !== null && (typeof meta !== 'object' || Array.isArray(meta)))) {
|
|
85
|
+
return 'metadata could not be read';
|
|
86
|
+
}
|
|
87
|
+
const found = metadataStrings(meta);
|
|
88
|
+
if (!found) return 'metadata too large to check';
|
|
89
|
+
const title = String(row.title || '');
|
|
90
|
+
const tags = [
|
|
91
|
+
row.tag,
|
|
92
|
+
row.lane,
|
|
93
|
+
...(Array.isArray(row.tags) ? row.tags : []),
|
|
94
|
+
...found.tags,
|
|
95
|
+
...(title.match(/#[A-Za-z0-9_-]+/g) || []).map((t) => t.slice(1)),
|
|
96
|
+
];
|
|
97
|
+
// Decision rows wait for a person; the same rule member go's picker uses.
|
|
98
|
+
if (isDecisionTask(row)) return 'needs a human decision';
|
|
99
|
+
for (const tag of tags) {
|
|
100
|
+
const text = String(tag ?? '');
|
|
101
|
+
if ([text, ...text.split(/[\s,]+/)].some(isDecisionHoldTag)) return 'needs a human decision';
|
|
102
|
+
const denied = deniedTagOf({ tag: text });
|
|
103
|
+
if (denied) return denied;
|
|
104
|
+
}
|
|
105
|
+
const objective = [title, typeof row.objective === 'string' ? row.objective : '', ...found.texts].filter(Boolean).join('\n');
|
|
106
|
+
const view = { title, objective, metadata: {} };
|
|
107
|
+
const declared = declaredProtectedLane(view);
|
|
108
|
+
if (declared) return `declared "${declared.phrase}"`;
|
|
109
|
+
const sniffed = sniffedProtectedLane(view);
|
|
110
|
+
if (sniffed) return sniffed.lane;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Every task this mission points at, or null when a link is malformed.
|
|
115
|
+
function linkedTaskIds(mission) {
|
|
116
|
+
const ids = [];
|
|
117
|
+
if (mission.task_ids !== undefined) {
|
|
118
|
+
if (!Array.isArray(mission.task_ids)) return null;
|
|
119
|
+
for (const id of mission.task_ids) {
|
|
120
|
+
if (typeof id !== 'string' || !id.trim()) return null;
|
|
121
|
+
ids.push(id.trim());
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const xp = mission.xp_task;
|
|
125
|
+
if (xp !== undefined && xp !== null && (typeof xp !== 'object' || Array.isArray(xp))) return null;
|
|
126
|
+
for (const value of [mission.task_id, mission.task_ref, mission.current_task_id, xp?.task_id, xp?.ref]) {
|
|
127
|
+
if (value === undefined || value === null || value === '') continue;
|
|
128
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
129
|
+
ids.push(value.trim());
|
|
130
|
+
}
|
|
131
|
+
return [...new Set(ids)];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function defaultLoadTask(id) {
|
|
135
|
+
try {
|
|
136
|
+
const taskDb = require('./task-db');
|
|
137
|
+
return taskDb.getTask(taskDb.open(), id) || null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const RECEIPT_STAMP_RE = /^\d{4}-\d{2}-\d{2}T[0-9-]+Z\.json$/;
|
|
144
|
+
|
|
145
|
+
// The newest run receipt this mission wrote in its own folder, or null.
|
|
146
|
+
function latestRunReceipt(missionId, cwd) {
|
|
147
|
+
if (typeof missionId !== 'string' || !/^[A-Za-z0-9._-]+$/.test(missionId)) return null;
|
|
148
|
+
const dir = path.join(cwd, 'atris', 'runs');
|
|
149
|
+
const prefix = `mission-${missionId}-`;
|
|
150
|
+
let names;
|
|
151
|
+
try {
|
|
152
|
+
names = fs.readdirSync(dir);
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const latest = names
|
|
157
|
+
.filter((name) => name.startsWith(prefix) && RECEIPT_STAMP_RE.test(name.slice(prefix.length)))
|
|
158
|
+
.sort((a, b) => (a.slice(prefix.length) < b.slice(prefix.length) ? -1 : 1))
|
|
159
|
+
.pop();
|
|
160
|
+
if (!latest) return null;
|
|
161
|
+
try {
|
|
162
|
+
const receipt = JSON.parse(fs.readFileSync(path.join(dir, latest), 'utf8'));
|
|
163
|
+
if (!receipt || typeof receipt !== 'object' || receipt.mission_id !== missionId) return null;
|
|
164
|
+
return receipt;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Review must cover the final code of the final run. The newest receipt for
|
|
171
|
+
// this mission must be a finished run's summary, recorded on a clean folder
|
|
172
|
+
// whose code tree is the one checked out now, with nothing newer in the
|
|
173
|
+
// mission's own state and no commit made after it. Every tick in that run
|
|
174
|
+
// must have passed its check and a verdict from a reviewer engine other than
|
|
175
|
+
// the member and every engine that did the work.
|
|
176
|
+
function reviewHold(mission, workerEngine, cwd, pin = {}) {
|
|
177
|
+
const receipt = latestRunReceipt(mission.id, cwd);
|
|
178
|
+
if (!receipt) return 'no run receipt for this mission could be read';
|
|
179
|
+
const result = receipt.result && typeof receipt.result === 'object' ? receipt.result : {};
|
|
180
|
+
if (result.kind !== 'mission_run_summary') return 'the latest run has not finished';
|
|
181
|
+
const receiptAt = Date.parse(receipt.at || '');
|
|
182
|
+
if (!Number.isFinite(receiptAt)) return 'the latest run receipt has no time';
|
|
183
|
+
if (receipt.clean !== true) return 'the latest run ended with unsaved changes';
|
|
184
|
+
const head = run('git', ['rev-parse', 'HEAD^{tree}'], cwd);
|
|
185
|
+
if (!head.ok || !head.stdout || receipt.git_tree !== head.stdout) {
|
|
186
|
+
return 'the latest review was not recorded on the code that is checked out now';
|
|
187
|
+
}
|
|
188
|
+
const commitTime = run('git', ['log', '-1', '--format=%ct', 'HEAD'], cwd);
|
|
189
|
+
const committedAt = Number(commitTime.stdout) * 1000;
|
|
190
|
+
if (!commitTime.ok || !Number.isFinite(committedAt) || committedAt <= 0 || committedAt > receiptAt) {
|
|
191
|
+
return 'the code was committed after the latest review';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const ticks = Array.isArray(result.ticks) ? result.ticks : [];
|
|
195
|
+
if (!ticks.length || result.tick_count !== ticks.length) return 'no reviewed work is recorded on the latest run';
|
|
196
|
+
const lastTick = ticks[ticks.length - 1] || {};
|
|
197
|
+
const lastTickAt = Date.parse(mission.last_tick_at || '');
|
|
198
|
+
if (!Number.isFinite(lastTickAt) || lastTickAt > receiptAt) return 'the mission ran again after the latest receipt';
|
|
199
|
+
if (mission.last_tick_status !== 'ran') return 'the mission\'s last step did not finish cleanly';
|
|
200
|
+
if (mission.last_tick_index !== undefined && mission.last_tick_index !== lastTick.tick_index) {
|
|
201
|
+
return 'the mission ran again after the latest receipt';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const worker = String(workerEngine || mission.runner || '').trim().toLowerCase();
|
|
205
|
+
if (!worker || worker === 'auto') return 'the engine that did the work is unknown';
|
|
206
|
+
const self = new Set([mission.owner, worker, ...ticks.map((tick) => tick && tick.engine_id)]
|
|
207
|
+
.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
|
|
208
|
+
for (const tick of ticks) {
|
|
209
|
+
if (!tick || typeof tick !== 'object' || tick.status !== 'ran' || tick.verifier_passed !== true) {
|
|
210
|
+
return 'a step on the latest run did not pass its check';
|
|
211
|
+
}
|
|
212
|
+
const referee = tick.referee_result;
|
|
213
|
+
if (!referee || typeof referee !== 'object' || referee.mode !== 'engine' || referee.passed !== true) {
|
|
214
|
+
return 'a reviewer on the latest run did not pass it';
|
|
215
|
+
}
|
|
216
|
+
const reviewer = String(referee.engine || '').trim().toLowerCase();
|
|
217
|
+
if (!reviewer || self.has(reviewer)) return 'the latest run was reviewed by the same engine that did the work';
|
|
218
|
+
}
|
|
219
|
+
if (lastTick.reviewed_git_tree !== head.stdout) return 'the review did not see the code that is checked out now';
|
|
220
|
+
pin.tree = head.stdout;
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The checked-out code as git sees it with config tricks switched off: every
|
|
225
|
+
// untracked file listed, submodules compared, no file monitor, and no index
|
|
226
|
+
// entry marked assume-unchanged or skip-worktree (anything but an 'H' tag).
|
|
227
|
+
function gitCodeState(cwd) {
|
|
228
|
+
const status = run('git', [
|
|
229
|
+
'-c', 'status.showUntrackedFiles=all', '-c', 'core.fsmonitor=false',
|
|
230
|
+
'status', '--porcelain', '--untracked-files=all', '--ignore-submodules=none',
|
|
231
|
+
], cwd);
|
|
232
|
+
const index = run('git', ['-c', 'core.fsmonitor=false', 'ls-files', '-v'], cwd);
|
|
233
|
+
const hidden = !index.ok || index.stdout.split('\n').some((line) => line && !line.startsWith('H '));
|
|
234
|
+
const tree = run('git', ['rev-parse', 'HEAD^{tree}'], cwd);
|
|
235
|
+
return {
|
|
236
|
+
clean: status.ok && !status.stdout && !hidden,
|
|
237
|
+
hidden,
|
|
238
|
+
status: status.ok ? status.stdout : null,
|
|
239
|
+
tree: tree.ok && tree.stdout ? tree.stdout : null,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// New files Atris writes for itself while a run is going (receipts, state,
|
|
244
|
+
// logs). Changed tracked files always count.
|
|
245
|
+
const BOOKKEEPING = /^\?\? (?:\.atris\/|atris\/(?:runs|status|logs)\/|atris\/team\/[^/]+\/logs\/)/;
|
|
246
|
+
|
|
247
|
+
function codeStatus(state) {
|
|
248
|
+
if (!state || state.status == null) return null;
|
|
249
|
+
return state.status.split('\n').filter((line) => line && !BOOKKEEPING.test(line)).join('\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function unsavedHold(cwd) {
|
|
253
|
+
if (!gitCodeState(cwd).clean) return 'unsaved changes; the check may not match what would merge';
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const MAP_FILE = /(?:^|\/)MAP\.md$/;
|
|
258
|
+
|
|
259
|
+
// Keep both sides' lines of one clashing map file, as a stored blob.
|
|
260
|
+
function unionMapBlob(file, mergeBase, ours, theirs, cwd, tmp) {
|
|
261
|
+
const sides = [];
|
|
262
|
+
for (const [name, rev] of [['base', mergeBase], ['ours', ours], ['theirs', theirs]]) {
|
|
263
|
+
const blob = run('git', ['cat-file', 'blob', `${rev}:${file}`], cwd, {}, { raw: true });
|
|
264
|
+
if (!blob.ok) return null;
|
|
265
|
+
const out = path.join(tmp, name);
|
|
266
|
+
fs.writeFileSync(out, blob.stdout);
|
|
267
|
+
sides.push(out);
|
|
268
|
+
}
|
|
269
|
+
const merged = run('git', ['merge-file', '-p', '--union', sides[1], sides[0], sides[2]], cwd, {}, { raw: true });
|
|
270
|
+
if (!merged.ok) return null;
|
|
271
|
+
const out = path.join(tmp, 'merged');
|
|
272
|
+
fs.writeFileSync(out, merged.stdout);
|
|
273
|
+
const blob = run('git', ['hash-object', '-w', out], cwd);
|
|
274
|
+
return blob.ok && /^[0-9a-f]{40,64}$/.test(blob.stdout) ? blob.stdout : null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Merge the main branch into the reviewed commit without touching the
|
|
278
|
+
// checkout. A clean merge pushes the reviewed commit as is. When every clash
|
|
279
|
+
// is a map file, both sides' lines are kept and a merge commit on top of the
|
|
280
|
+
// reviewed commit is pushed instead, so GitHub has nothing left to resolve.
|
|
281
|
+
// Any other clash holds.
|
|
282
|
+
function mergeWithBase(headSha, base, branch, cwd) {
|
|
283
|
+
const remoteRef = `refs/remotes/origin/${base}`;
|
|
284
|
+
const fetched = run('git', ['fetch', '-q', 'origin', `+refs/heads/${base}:${remoteRef}`], cwd);
|
|
285
|
+
if (!fetched.ok) return { hold: `the main branch could not be fetched (${fetched.stderr.split('\n')[0]})` };
|
|
286
|
+
const baseRead = run('git', ['rev-parse', remoteRef], cwd);
|
|
287
|
+
const baseSha = baseRead.ok ? baseRead.stdout : '';
|
|
288
|
+
if (!/^[0-9a-f]{40,64}$/.test(baseSha)) return { hold: 'the main branch could not be read' };
|
|
289
|
+
const tree = run('git', ['merge-tree', '--write-tree', '--name-only', headSha, baseSha], cwd);
|
|
290
|
+
if (tree.status === 0) return { sha: headSha };
|
|
291
|
+
if (tree.status !== 1) return { hold: 'the branch could not be compared with the main branch' };
|
|
292
|
+
const [treeId, ...files] = tree.stdout.split(/\r?\n\r?\n/)[0].split(/\r?\n/).filter(Boolean);
|
|
293
|
+
const clashes = files.filter((file) => !MAP_FILE.test(file));
|
|
294
|
+
if (!files.length || clashes.length) {
|
|
295
|
+
return { hold: `the branch clashes with the main branch in ${(clashes.length ? clashes : ['unknown files']).join(', ')}` };
|
|
296
|
+
}
|
|
297
|
+
const bases = run('git', ['merge-base', '--all', headSha, baseSha], cwd);
|
|
298
|
+
const mergeBase = bases.ok ? bases.stdout.split('\n').filter(Boolean) : [];
|
|
299
|
+
if (mergeBase.length !== 1) return { hold: 'the branch and the main branch share no single starting point' };
|
|
300
|
+
|
|
301
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-map-merge-'));
|
|
302
|
+
try {
|
|
303
|
+
const index = { GIT_INDEX_FILE: path.join(tmp, 'index') };
|
|
304
|
+
if (!run('git', ['read-tree', treeId], cwd, index).ok) return { hold: 'the merged map could not be built' };
|
|
305
|
+
for (const file of files) {
|
|
306
|
+
const blob = unionMapBlob(file, mergeBase[0], headSha, baseSha, cwd, tmp);
|
|
307
|
+
const mode = (run('git', ['ls-tree', headSha, '--', file], cwd).stdout.match(/^(\d{6}) blob /) || [])[1];
|
|
308
|
+
if (!blob || !mode) return { hold: `the map file ${file} could not be merged line by line` };
|
|
309
|
+
if (!run('git', ['update-index', '--cacheinfo', `${mode},${blob},${file}`], cwd, index).ok) {
|
|
310
|
+
return { hold: 'the merged map could not be built' };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const merged = run('git', ['write-tree'], cwd, index);
|
|
314
|
+
if (!merged.ok) return { hold: 'the merged map could not be built' };
|
|
315
|
+
const commit = run('git', [
|
|
316
|
+
'commit-tree', merged.stdout, '-p', headSha, '-p', baseSha,
|
|
317
|
+
'-m', `Merge ${base} into ${branch}, keeping map lines from both sides`,
|
|
318
|
+
], cwd);
|
|
319
|
+
if (!commit.ok || !/^[0-9a-f]{40,64}$/.test(commit.stdout)) return { hold: 'the map merge could not be saved' };
|
|
320
|
+
return { sha: commit.stdout, mapMerged: files };
|
|
321
|
+
} finally {
|
|
322
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function held(gate, line) {
|
|
327
|
+
return { merged: false, auto_merge: false, gate, line: `left the branch for you: ${line}` };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', loadTask = defaultLoadTask } = {}) {
|
|
331
|
+
if (!mission || typeof mission !== 'object') return held('mission', 'the mission could not be read');
|
|
332
|
+
if (mission.verifier_result?.passed !== true) return held('check', 'the check did not pass');
|
|
333
|
+
if (!policyOn(cwd)) return held('policy', 'self-landing is not switched on in the main checkout');
|
|
334
|
+
|
|
335
|
+
const missionLane = protectedLane({ ...mission, title: mission.objective });
|
|
336
|
+
if (missionLane) return held('protected', `this mission sits in a protected area (${missionLane})`);
|
|
337
|
+
const taskIds = linkedTaskIds(mission);
|
|
338
|
+
if (!taskIds) return held('protected', 'the mission\'s linked tasks are not a clean list of ids');
|
|
339
|
+
for (const id of taskIds) {
|
|
340
|
+
let task = null;
|
|
341
|
+
try {
|
|
342
|
+
task = loadTask(id, cwd);
|
|
343
|
+
} catch {
|
|
344
|
+
task = null;
|
|
345
|
+
}
|
|
346
|
+
if (!task || typeof task !== 'object') return held('protected', `a linked task (${id}) could not be read`);
|
|
347
|
+
const lane = protectedLane(task);
|
|
348
|
+
if (lane) return held('protected', `a linked task sits in a protected area (${lane})`);
|
|
349
|
+
// A passing run cannot override a person's send-back. Read the current
|
|
350
|
+
// task, not historical events: submitting fresh proof resets it to pending.
|
|
351
|
+
const approvals = [task.review?.approval_status, task.metadata?.approval_status];
|
|
352
|
+
if (approvals.some((value) => ['revise', 'revised', 'rejected'].includes(String(value || '').trim().toLowerCase()))) {
|
|
353
|
+
return held('approval', 'a linked task was turned down; it needs fresh proof before it can go to github');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const unsaved = unsavedHold(cwd);
|
|
358
|
+
if (unsaved) return held('unsaved', unsaved);
|
|
359
|
+
const pin = {};
|
|
360
|
+
const reviewGap = reviewHold(mission, workerEngine, cwd, pin);
|
|
361
|
+
if (reviewGap) return held('review', reviewGap);
|
|
362
|
+
// Pin the commit the review passed on. Only this commit (or a map-only
|
|
363
|
+
// merge built on it) is pushed and merged, so a commit made after this
|
|
364
|
+
// point never rides along.
|
|
365
|
+
const headRead = run('git', ['rev-parse', 'HEAD'], cwd);
|
|
366
|
+
const headSha = headRead.ok ? headRead.stdout : '';
|
|
367
|
+
if (!/^[0-9a-f]{40,64}$/.test(headSha)) return held('branch', 'the latest commit could not be read');
|
|
368
|
+
const pinnedTree = () => {
|
|
369
|
+
const tree = run('git', ['rev-parse', `${headSha}^{tree}`], cwd);
|
|
370
|
+
return tree.ok && tree.stdout === pin.tree;
|
|
371
|
+
};
|
|
372
|
+
if (!pin.tree || !pinnedTree()) return held('review', 'the code moved while the review was being checked');
|
|
373
|
+
|
|
374
|
+
const branchRead = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
375
|
+
const branch = String(mission.worktree?.branch || branchRead.stdout || '').trim();
|
|
376
|
+
if (!branch || branch === 'HEAD' || branch === 'master' || branch === 'main') {
|
|
377
|
+
return held('branch', 'the work is not on its own branch');
|
|
378
|
+
}
|
|
379
|
+
const remoteHead = run('git', ['ls-remote', '--symref', 'origin', 'HEAD'], cwd);
|
|
380
|
+
const base = (remoteHead.stdout.match(/^ref: refs\/heads\/(\S+)\s+HEAD$/m) || [])[1] || '';
|
|
381
|
+
if (!remoteHead.ok || !base) return held('base', 'the main branch on GitHub could not be read');
|
|
382
|
+
if (branch === base) return held('branch', 'the work is not on its own branch');
|
|
383
|
+
|
|
384
|
+
const landing = mergeWithBase(headSha, base, branch, cwd);
|
|
385
|
+
if (landing.hold) return held('conflict', landing.hold);
|
|
386
|
+
const pushSha = landing.sha;
|
|
387
|
+
|
|
388
|
+
if (!pinnedTree()) return held('review', 'the reviewed commit no longer holds the reviewed code');
|
|
389
|
+
const push = run('git', ['push', 'origin', `${pushSha}:refs/heads/${branch}`], cwd);
|
|
390
|
+
if (!push.ok) return held('push', `the branch could not be pushed (${push.stderr.split('\n')[0]})`);
|
|
391
|
+
|
|
392
|
+
// Find the open pull request from exactly this branch into the main
|
|
393
|
+
// branch, or open one. Merge is always addressed by number, never by a
|
|
394
|
+
// branch name gh might read as a number, and pinned to the pushed commit.
|
|
395
|
+
const listed = run('gh', [
|
|
396
|
+
'pr', 'list', '--head', branch, '--base', base, '--state', 'open',
|
|
397
|
+
'--json', 'number,headRefName,baseRefName,headRefOid,isCrossRepository',
|
|
398
|
+
], cwd);
|
|
399
|
+
if (!listed.ok) return held('pull request', `open pull requests could not be read (${listed.stderr.split('\n')[0]})`);
|
|
400
|
+
let rows;
|
|
401
|
+
try {
|
|
402
|
+
rows = JSON.parse(listed.stdout || '[]');
|
|
403
|
+
} catch {
|
|
404
|
+
rows = null;
|
|
405
|
+
}
|
|
406
|
+
if (!Array.isArray(rows)) return held('pull request', 'open pull requests could not be read');
|
|
407
|
+
const matches = rows.filter((pr) => pr && pr.headRefName === branch && pr.baseRefName === base
|
|
408
|
+
&& pr.isCrossRepository === false && Number.isInteger(pr.number) && pr.number > 0);
|
|
409
|
+
if (matches.length > 1) return held('pull request', 'more than one pull request is open for this branch');
|
|
410
|
+
let number = matches.length ? matches[0].number : 0;
|
|
411
|
+
let url = '';
|
|
412
|
+
if (!number) {
|
|
413
|
+
const title = String(mission.objective || branch).replace(/\s+/g, ' ').trim().slice(0, 70);
|
|
414
|
+
const body = String(summary || '').trim() || `Receipt: ${mission.receipt_path || 'none recorded'}`;
|
|
415
|
+
const created = run('gh', ['pr', 'create', '--head', branch, '--base', base, '--title', title, '--body', body], cwd);
|
|
416
|
+
url = created.ok ? created.stdout.split('\n').pop().trim() : '';
|
|
417
|
+
number = Number((url.match(/\/pull\/(\d+)$/) || [])[1] || 0);
|
|
418
|
+
if (!created.ok || !number) {
|
|
419
|
+
return held('pull request', `the pull request could not be opened (${(created.stderr || url).split('\n')[0]})`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const label = url || `pull request #${number}`;
|
|
423
|
+
|
|
424
|
+
const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit', pushSha], cwd);
|
|
425
|
+
if (!merge.ok) return held('auto-merge', `auto-merge could not be switched on for ${label}`);
|
|
426
|
+
const mapNote = landing.mapMerged ? ' (map lines from both sides kept)' : '';
|
|
427
|
+
return {
|
|
428
|
+
merged: false,
|
|
429
|
+
auto_merge: true,
|
|
430
|
+
gate: null,
|
|
431
|
+
url: label,
|
|
432
|
+
pushed: pushSha,
|
|
433
|
+
map_merged: landing.mapMerged || [],
|
|
434
|
+
line: `merges on its own once checks pass${mapNote}: ${label}`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
module.exports = {
|
|
439
|
+
autolandMemberRun,
|
|
440
|
+
codeStatus,
|
|
441
|
+
gitCodeState,
|
|
442
|
+
};
|