@yeaft/webchat-agent 1.0.496 → 1.0.498

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.
@@ -0,0 +1,1437 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { existsSync } from 'node:fs';
4
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
5
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import {
8
+ consumeRepoApprovalCapability,
9
+ isRepoApprovalCapability,
10
+ } from './yeaft/routing/router.js';
11
+
12
+ const execFile = promisify(execFileCallback);
13
+ const DEFAULT_REMOTE = 'origin';
14
+ const DEFAULT_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
15
+ const DEFAULT_POLL_INTERVAL_MS = 15_000;
16
+ const MAX_ERROR_OUTPUT = 4_000;
17
+ const WORKTREE_OWNERSHIP_FILE = 'yeaft-repo-workflow-owner.json';
18
+ const WORKTREE_OWNERSHIP_VERSION = 1;
19
+
20
+ function redactUrlCredentials(value) {
21
+ return String(value || '')
22
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/giu, '$1***@')
23
+ .replace(/((?:[?&]|%3f|%26|%253f|%2526)(?:access(?:_|%5f|%255f)token|token)(?:=|%3d|%253d))(?:(?![&#\s]|%26|%2526).)*/giu, '$1***');
24
+ }
25
+
26
+ function redactStructured(value, seen = new WeakSet()) {
27
+ if (typeof value === 'string') return redactUrlCredentials(value);
28
+ if (!value || typeof value !== 'object') return value;
29
+ if (seen.has(value)) return '[Circular]';
30
+ seen.add(value);
31
+ if (Array.isArray(value)) return value.map(item => redactStructured(item, seen));
32
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
33
+ key,
34
+ /^(?:access_?token|token)$/iu.test(key) ? '***' : redactStructured(item, seen),
35
+ ]));
36
+ }
37
+
38
+ export class RepoWorkflowError extends Error {
39
+ constructor(code, message, details = {}) {
40
+ super(redactUrlCredentials(message));
41
+ this.name = 'RepoWorkflowError';
42
+ this.code = code;
43
+ this.details = redactStructured(details);
44
+ }
45
+ }
46
+
47
+ function clip(value, max = MAX_ERROR_OUTPUT) {
48
+ const text = redactUrlCredentials(value).trim();
49
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
50
+ }
51
+
52
+ function validateRemoteName(value) {
53
+ const remote = String(value || '');
54
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(remote)
55
+ || remote.includes('..')
56
+ || remote.includes('//')
57
+ || remote.endsWith('/')) {
58
+ throw new RepoWorkflowError('INVALID_REMOTE', 'Git remote name contains unsupported characters');
59
+ }
60
+ return remote;
61
+ }
62
+
63
+ export function createRepoCommandRunner({ execFileImpl = execFile, signal } = {}) {
64
+ return async function run(command, args, options = {}) {
65
+ const { cwd, allowExitCodes = [], timeoutMs = 120_000 } = options;
66
+ try {
67
+ const result = await execFileImpl(command, args, {
68
+ cwd,
69
+ encoding: 'utf8',
70
+ windowsHide: true,
71
+ timeout: timeoutMs,
72
+ signal: options.signal || signal,
73
+ maxBuffer: 4 * 1024 * 1024,
74
+ env: { ...process.env, TERM: 'dumb', FORCE_COLOR: '0', GH_PAGER: 'cat' },
75
+ });
76
+ return {
77
+ stdout: String(result.stdout || '').trim(),
78
+ stderr: String(result.stderr || '').trim(),
79
+ exitCode: 0,
80
+ };
81
+ } catch (error) {
82
+ if (signal?.aborted || options.signal?.aborted || error?.name === 'AbortError') {
83
+ throw new RepoWorkflowError('ABORTED', 'Repository workflow aborted');
84
+ }
85
+ const exitCode = Number.isInteger(error?.code) ? error.code : null;
86
+ if (exitCode !== null && allowExitCodes.includes(exitCode)) {
87
+ return {
88
+ stdout: String(error.stdout || '').trim(),
89
+ stderr: String(error.stderr || '').trim(),
90
+ exitCode,
91
+ };
92
+ }
93
+ const safeArgs = args.map(arg => redactUrlCredentials(arg));
94
+ throw new RepoWorkflowError('COMMAND_FAILED', redactUrlCredentials(`${command} ${args.join(' ')} failed`), {
95
+ command,
96
+ args: safeArgs,
97
+ exitCode,
98
+ stderr: clip(error?.stderr || error?.message),
99
+ });
100
+ }
101
+ };
102
+ }
103
+
104
+ function parseJson(text, code, label) {
105
+ try {
106
+ return JSON.parse(text);
107
+ } catch {
108
+ throw new RepoWorkflowError(code, `${label} returned invalid JSON`, { output: clip(text) });
109
+ }
110
+ }
111
+
112
+ async function runGit(run, cwd, args, options = {}) {
113
+ return run('git', args, { cwd, ...options });
114
+ }
115
+
116
+ async function runGh(run, cwd, args, options = {}) {
117
+ return run('gh', args, { cwd, ...options });
118
+ }
119
+
120
+ async function gitOutput(run, cwd, args) {
121
+ return (await runGit(run, cwd, args)).stdout;
122
+ }
123
+
124
+ async function ghJson(run, cwd, args, code = 'GITHUB_QUERY_FAILED') {
125
+ const result = await runGh(run, cwd, args);
126
+ return parseJson(result.stdout, code, `gh ${args.join(' ')}`);
127
+ }
128
+
129
+ function sanitizeName(value, fallback) {
130
+ const sanitized = String(value || fallback)
131
+ .replace(/[^a-zA-Z0-9._-]/g, '-')
132
+ .replace(/^-+|-+$/g, '')
133
+ .slice(0, 64);
134
+ if (!sanitized) {
135
+ throw new RepoWorkflowError('INVALID_NAME', 'Worktree name is empty after sanitization');
136
+ }
137
+ return sanitized;
138
+ }
139
+
140
+ function assertPositiveInteger(value, label) {
141
+ const number = Number(value);
142
+ if (!Number.isInteger(number) || number <= 0) {
143
+ throw new RepoWorkflowError('INVALID_INPUT', `${label} must be a positive integer`);
144
+ }
145
+ return number;
146
+ }
147
+
148
+ function githubRepositoryParts(host, path) {
149
+ const segments = String(path || '').replace(/^\/+|\/+$/g, '').split('/');
150
+ if (segments.length !== 2) return null;
151
+ const [owner, rawName] = segments;
152
+ const name = rawName.replace(/\.git$/i, '');
153
+ const safePart = value => /^[a-zA-Z0-9_.-]+$/.test(value) && value !== '.' && value !== '..';
154
+ if (!safePart(host) || !safePart(owner) || !safePart(name)) return null;
155
+ const normalizedHost = host.toLowerCase();
156
+ const nameWithOwner = `${owner}/${name}`;
157
+ return {
158
+ host: normalizedHost,
159
+ nameWithOwner,
160
+ selector: normalizedHost === 'github.com' ? nameWithOwner : `${normalizedHost}/${nameWithOwner}`,
161
+ };
162
+ }
163
+
164
+ export function parseGithubRemoteUrl(remoteUrl) {
165
+ const value = String(remoteUrl || '').trim();
166
+ let parsed = null;
167
+ try {
168
+ const url = new URL(value);
169
+ if (['ssh:', 'https:', 'http:', 'git:'].includes(url.protocol)) {
170
+ parsed = githubRepositoryParts(url.hostname, url.pathname);
171
+ }
172
+ } catch {
173
+ const scpLike = value.match(/^(?:[^@/:]+@)?([^/:]+):(.+)$/);
174
+ if (scpLike) parsed = githubRepositoryParts(scpLike[1], scpLike[2]);
175
+ }
176
+ if (!parsed) {
177
+ throw new RepoWorkflowError('GITHUB_REMOTE_UNSUPPORTED', 'Selected remote is not a supported GitHub repository URL');
178
+ }
179
+ return parsed;
180
+ }
181
+
182
+ async function resolveRepository(run, cwd, options = {}) {
183
+ const repoRoot = await gitOutput(run, cwd, ['rev-parse', '--show-toplevel']);
184
+ const commonDir = await gitOutput(run, repoRoot, [
185
+ 'rev-parse',
186
+ '--path-format=absolute',
187
+ '--git-common-dir',
188
+ ]);
189
+ // Older Git releases may accept --path-format but still return a relative
190
+ // common dir. Resolve it against the checkout instead of process.cwd().
191
+ const absoluteCommonDir = isAbsolute(commonDir) ? commonDir : resolve(repoRoot, commonDir);
192
+ const workspaceRoot = dirname(absoluteCommonDir);
193
+ const remote = validateRemoteName(options.remote || DEFAULT_REMOTE);
194
+ const remotes = (await gitOutput(run, repoRoot, ['remote'])).split(/\r?\n/).filter(Boolean);
195
+ if (!remotes.includes(remote)) {
196
+ throw new RepoWorkflowError('REMOTE_NOT_FOUND', `Git remote "${remote}" does not exist`, { remotes });
197
+ }
198
+ const remoteUrl = await gitOutput(run, repoRoot, ['remote', 'get-url', remote]);
199
+ return {
200
+ repoRoot,
201
+ workspaceRoot,
202
+ commonDir: absoluteCommonDir,
203
+ remote,
204
+ remoteUrl,
205
+ };
206
+ }
207
+
208
+ async function resolveDefaultBranch(run, repository, explicitBase) {
209
+ if (explicitBase) return explicitBase;
210
+ const symbolic = await runGit(run, repository.repoRoot, [
211
+ 'symbolic-ref',
212
+ '--quiet',
213
+ '--short',
214
+ `refs/remotes/${repository.remote}/HEAD`,
215
+ ], { allowExitCodes: [1] });
216
+ if (symbolic.exitCode === 0 && symbolic.stdout.startsWith(`${repository.remote}/`)) {
217
+ return symbolic.stdout.slice(repository.remote.length + 1);
218
+ }
219
+ const github = githubRepository(repository);
220
+ const info = await ghJson(run, repository.repoRoot, [
221
+ 'repo',
222
+ 'view',
223
+ github.selector,
224
+ '--json',
225
+ 'defaultBranchRef',
226
+ ]);
227
+ const branch = info?.defaultBranchRef?.name;
228
+ if (!branch) {
229
+ throw new RepoWorkflowError('DEFAULT_BRANCH_UNKNOWN', 'Could not determine the repository default branch');
230
+ }
231
+ return branch;
232
+ }
233
+
234
+ async function validateBranchRef(run, cwd, branch) {
235
+ const result = await runGit(run, cwd, ['check-ref-format', `refs/heads/${branch}`], { allowExitCodes: [1] });
236
+ if (result.exitCode !== 0) {
237
+ throw new RepoWorkflowError('INVALID_BRANCH', `Invalid branch name: ${branch}`);
238
+ }
239
+ }
240
+
241
+ async function fetchBase(run, repository, baseBranch) {
242
+ await validateBranchRef(run, repository.repoRoot, baseBranch);
243
+ await runGit(run, repository.repoRoot, [
244
+ 'fetch',
245
+ '--no-tags',
246
+ '--prune',
247
+ repository.remote,
248
+ `+refs/heads/${baseBranch}:refs/remotes/${repository.remote}/${baseBranch}`,
249
+ ]);
250
+ return gitOutput(run, repository.repoRoot, ['rev-parse', `refs/remotes/${repository.remote}/${baseBranch}`]);
251
+ }
252
+
253
+ export function parseWorktreeList(output) {
254
+ const records = [];
255
+ let current = null;
256
+ for (const line of String(output || '').split(/\r?\n/)) {
257
+ if (line.startsWith('worktree ')) {
258
+ if (current) records.push(current);
259
+ current = { path: line.slice('worktree '.length), branch: null, head: null, detached: false };
260
+ } else if (current && line.startsWith('HEAD ')) {
261
+ current.head = line.slice('HEAD '.length);
262
+ } else if (current && line.startsWith('branch ')) {
263
+ current.branch = line.slice('branch refs/heads/'.length);
264
+ } else if (current && line === 'detached') {
265
+ current.detached = true;
266
+ }
267
+ }
268
+ if (current) records.push(current);
269
+ return records;
270
+ }
271
+
272
+ async function listWorktrees(run, repository) {
273
+ return parseWorktreeList(await gitOutput(run, repository.repoRoot, ['worktree', 'list', '--porcelain']));
274
+ }
275
+
276
+ function resolveWorktreePath(repository, requestedPath, defaultName) {
277
+ if (!requestedPath) {
278
+ return join(dirname(repository.workspaceRoot), '.yeaft', 'worktrees', basename(repository.workspaceRoot), defaultName);
279
+ }
280
+ return isAbsolute(requestedPath) ? resolve(requestedPath) : resolve(repository.workspaceRoot, requestedPath);
281
+ }
282
+
283
+ function compactRepository(repository) {
284
+ return {
285
+ root: repository.repoRoot,
286
+ workspaceRoot: repository.workspaceRoot,
287
+ remote: repository.remote,
288
+ };
289
+ }
290
+
291
+ function githubRepository(repository) {
292
+ return parseGithubRemoteUrl(repository.remoteUrl);
293
+ }
294
+
295
+ async function assertPushTarget(run, repository) {
296
+ const github = githubRepository(repository);
297
+ const pushUrls = (await gitOutput(run, repository.repoRoot, [
298
+ 'remote', 'get-url', '--push', '--all', repository.remote,
299
+ ])).split(/\r?\n/).filter(Boolean);
300
+ if (pushUrls.length === 0) {
301
+ throw new RepoWorkflowError('GITHUB_PUSH_URL_UNKNOWN', `Git remote "${repository.remote}" has no push URL`);
302
+ }
303
+ const targets = pushUrls.map(pushUrl => {
304
+ try {
305
+ return parseGithubRemoteUrl(pushUrl);
306
+ } catch {
307
+ return null;
308
+ }
309
+ });
310
+ const valid = targets.every(target => target
311
+ && target.host === github.host
312
+ && target.nameWithOwner.toLowerCase() === github.nameWithOwner.toLowerCase());
313
+ if (!valid) {
314
+ throw new RepoWorkflowError('GITHUB_PUSH_URL_MISMATCH', 'Selected remote fetch and push URLs do not identify the same GitHub repository', {
315
+ remote: repository.remote,
316
+ expected: github.selector,
317
+ pushTargets: targets.map(target => target?.selector || 'unsupported'),
318
+ });
319
+ }
320
+ return pushUrls[0];
321
+ }
322
+
323
+ function worktreeOwnershipKey(path) {
324
+ return createHash('sha256').update(resolve(path)).digest('hex');
325
+ }
326
+
327
+ async function worktreeOwnershipPaths(run, repository, path) {
328
+ const key = worktreeOwnershipKey(path);
329
+ const rawGitDir = await gitOutput(run, path, ['rev-parse', '--path-format=absolute', '--git-dir']);
330
+ const gitDir = isAbsolute(rawGitDir) ? rawGitDir : resolve(path, rawGitDir);
331
+ return {
332
+ worktree: join(gitDir, WORKTREE_OWNERSHIP_FILE),
333
+ repository: join(repository.commonDir, 'yeaft-repo-workflow', `${key}.json`),
334
+ };
335
+ }
336
+
337
+ function createWorktreeOwnership(path, kind, createdHead, branch = null) {
338
+ return {
339
+ version: WORKTREE_OWNERSHIP_VERSION,
340
+ nonce: randomBytes(32).toString('hex'),
341
+ path: resolve(path),
342
+ kind,
343
+ createdHead,
344
+ branch,
345
+ };
346
+ }
347
+
348
+ async function writeJsonAtomic(path, value) {
349
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
350
+ const temporary = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
351
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
352
+ await rename(temporary, path);
353
+ }
354
+
355
+ async function persistWorktreeOwnership(run, repository, ownership) {
356
+ const paths = await worktreeOwnershipPaths(run, repository, ownership.path);
357
+ try {
358
+ await writeJsonAtomic(paths.repository, ownership);
359
+ await writeJsonAtomic(paths.worktree, ownership);
360
+ } catch (error) {
361
+ await Promise.allSettled([unlink(paths.repository), unlink(paths.worktree)]);
362
+ throw new RepoWorkflowError('WORKTREE_OWNERSHIP_WRITE_FAILED', 'Could not persist worktree ownership metadata', {
363
+ path: ownership.path,
364
+ error: error?.message || String(error),
365
+ });
366
+ }
367
+ return ownership;
368
+ }
369
+
370
+ async function readJsonFile(path) {
371
+ try {
372
+ return JSON.parse(await readFile(path, 'utf8'));
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+
378
+ function sameSecret(left, right) {
379
+ if (typeof left !== 'string' || typeof right !== 'string' || left.length !== right.length) return false;
380
+ return timingSafeEqual(Buffer.from(left), Buffer.from(right));
381
+ }
382
+
383
+ async function verifyWorktreeOwnership(run, repository, path, expected = {}) {
384
+ let paths;
385
+ try {
386
+ paths = await worktreeOwnershipPaths(run, repository, path);
387
+ } catch {
388
+ return null;
389
+ }
390
+ const [worktree, repositoryCopy] = await Promise.all([
391
+ readJsonFile(paths.worktree),
392
+ readJsonFile(paths.repository),
393
+ ]);
394
+ const valid = worktree?.version === WORKTREE_OWNERSHIP_VERSION
395
+ && repositoryCopy?.version === WORKTREE_OWNERSHIP_VERSION
396
+ && sameSecret(worktree.nonce, repositoryCopy.nonce)
397
+ && worktree.path === resolve(path)
398
+ && repositoryCopy.path === resolve(path)
399
+ && worktree.kind === repositoryCopy.kind
400
+ && worktree.createdHead === repositoryCopy.createdHead
401
+ && worktree.branch === repositoryCopy.branch
402
+ && (!expected.kind || worktree.kind === expected.kind)
403
+ && (!expected.createdHead || worktree.createdHead === expected.createdHead)
404
+ && (expected.branch === undefined || worktree.branch === expected.branch);
405
+ return valid ? { ownership: worktree, paths } : null;
406
+ }
407
+
408
+ async function worktreeStatus(run, path) {
409
+ return gitOutput(run, path, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching']);
410
+ }
411
+
412
+ export async function prepareRepoWorkflow(options = {}, dependencies = {}) {
413
+ const run = dependencies.run || createRepoCommandRunner({ signal: dependencies.signal });
414
+ const repository = await resolveRepository(run, options.cwd || process.cwd(), options);
415
+ const baseBranch = await resolveDefaultBranch(run, repository, options.baseBranch);
416
+ const baseSha = await fetchBase(run, repository, baseBranch);
417
+ const name = sanitizeName(options.name, `work-${baseSha.slice(0, 8)}`);
418
+ const branch = `yeaft-wt/${name}`;
419
+ const worktreePath = resolveWorktreePath(repository, options.worktreePath, name);
420
+ const worktrees = await listWorktrees(run, repository);
421
+ const existing = worktrees.find(item => resolve(item.path) === worktreePath);
422
+
423
+ if (existing) {
424
+ const ownership = await verifyWorktreeOwnership(run, repository, worktreePath, {
425
+ kind: 'development',
426
+ createdHead: baseSha,
427
+ branch,
428
+ });
429
+ if (existing.branch !== branch || existing.head !== baseSha || !ownership) {
430
+ throw new RepoWorkflowError('WORKTREE_CONFLICT', 'Existing worktree is not an owned checkout of the requested branch and latest base', {
431
+ requested: { worktreePath, branch, baseSha },
432
+ existing,
433
+ ownershipVerified: Boolean(ownership),
434
+ });
435
+ }
436
+ return {
437
+ ok: true,
438
+ phase: 'prepare',
439
+ reused: true,
440
+ repository: compactRepository(repository),
441
+ base: { branch: baseBranch, sha: baseSha },
442
+ worktree: { path: worktreePath, branch, head: existing.head },
443
+ };
444
+ }
445
+
446
+ if (existsSync(worktreePath)) {
447
+ throw new RepoWorkflowError('PATH_EXISTS', `Worktree path already exists: ${worktreePath}`);
448
+ }
449
+ const branchExists = await runGit(run, repository.repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
450
+ allowExitCodes: [1],
451
+ });
452
+ if (branchExists.exitCode === 0) {
453
+ throw new RepoWorkflowError('BRANCH_EXISTS', `Local branch already exists: ${branch}`, { branch });
454
+ }
455
+
456
+ await runGit(run, repository.repoRoot, ['worktree', 'add', '-b', branch, worktreePath, baseSha]);
457
+ try {
458
+ const persistOwnership = dependencies.persistWorktreeOwnership || persistWorktreeOwnership;
459
+ await persistOwnership(
460
+ run,
461
+ repository,
462
+ createWorktreeOwnership(worktreePath, 'development', baseSha, branch),
463
+ );
464
+ } catch (error) {
465
+ await runGit(run, repository.repoRoot, ['worktree', 'remove', worktreePath]);
466
+ const branchRef = `refs/heads/${branch}`;
467
+ if (dependencies.beforePrepareBranchRollback) {
468
+ await dependencies.beforePrepareBranchRollback({ ref: branchRef, expectedHead: baseSha });
469
+ }
470
+ await runGit(run, repository.repoRoot, [
471
+ 'update-ref',
472
+ '--no-deref',
473
+ '-d',
474
+ branchRef,
475
+ baseSha,
476
+ ], { allowExitCodes: [1, 128] });
477
+ throw error;
478
+ }
479
+ return {
480
+ ok: true,
481
+ phase: 'prepare',
482
+ reused: false,
483
+ repository: compactRepository(repository),
484
+ base: { branch: baseBranch, sha: baseSha },
485
+ worktree: { path: worktreePath, branch, head: baseSha },
486
+ };
487
+ }
488
+
489
+ const FAILED_CHECK_STATES = new Set([
490
+ 'ACTION_REQUIRED',
491
+ 'CANCELLED',
492
+ 'ERROR',
493
+ 'FAILURE',
494
+ 'STALE',
495
+ 'STARTUP_FAILURE',
496
+ 'TIMED_OUT',
497
+ ]);
498
+ const PASSED_CHECK_STATES = new Set(['NEUTRAL', 'SKIPPED', 'SUCCESS']);
499
+
500
+ function checkName(check, index) {
501
+ return check?.name || check?.context || check?.workflowName || `check-${index + 1}`;
502
+ }
503
+
504
+ export function summarizeChecks(checks = []) {
505
+ const summary = { total: checks.length, passed: [], pending: [], failed: [] };
506
+ checks.forEach((check, index) => {
507
+ const rawState = check?.conclusion || check?.state || check?.status || 'UNKNOWN';
508
+ const state = String(rawState).toUpperCase();
509
+ const record = { name: checkName(check, index), state };
510
+ if (FAILED_CHECK_STATES.has(state)) summary.failed.push(record);
511
+ else if (PASSED_CHECK_STATES.has(state)) summary.passed.push(record);
512
+ else summary.pending.push(record);
513
+ });
514
+ return summary;
515
+ }
516
+
517
+ async function loadPullRequest(run, repository, pr) {
518
+ const github = githubRepository(repository);
519
+ return ghJson(run, repository.repoRoot, [
520
+ 'pr',
521
+ 'view',
522
+ String(pr),
523
+ '--repo',
524
+ github.selector,
525
+ '--json',
526
+ [
527
+ 'number',
528
+ 'url',
529
+ 'state',
530
+ 'isDraft',
531
+ 'baseRefName',
532
+ 'headRefName',
533
+ 'headRefOid',
534
+ 'mergeable',
535
+ 'mergeStateStatus',
536
+ 'statusCheckRollup',
537
+ 'reviewDecision',
538
+ 'mergedAt',
539
+ 'mergeCommit',
540
+ ].join(','),
541
+ ]);
542
+ }
543
+
544
+ function validateOpenPullRequest(info, { requireChecksComplete = false } = {}) {
545
+ if (info.state !== 'OPEN') {
546
+ throw new RepoWorkflowError('PR_NOT_OPEN', `PR #${info.number} is ${String(info.state).toLowerCase()}`, {
547
+ state: info.state,
548
+ mergeCommit: info.mergeCommit?.oid || null,
549
+ });
550
+ }
551
+ if (info.isDraft) throw new RepoWorkflowError('PR_IS_DRAFT', `PR #${info.number} is a draft`);
552
+ if (info.mergeable === 'CONFLICTING' || info.mergeStateStatus === 'DIRTY') {
553
+ throw new RepoWorkflowError('PR_CONFLICTING', `PR #${info.number} has merge conflicts`, {
554
+ mergeable: info.mergeable,
555
+ mergeStateStatus: info.mergeStateStatus,
556
+ });
557
+ }
558
+ const checks = summarizeChecks(info.statusCheckRollup || []);
559
+ if (checks.failed.length > 0) {
560
+ throw new RepoWorkflowError('CHECKS_FAILED', `PR #${info.number} has failing checks`, { checks });
561
+ }
562
+ if (requireChecksComplete && checks.pending.length > 0) {
563
+ throw new RepoWorkflowError('CHECKS_PENDING', `PR #${info.number} still has pending checks`, { checks });
564
+ }
565
+ return checks;
566
+ }
567
+
568
+ async function fetchPullRequestSnapshot(run, repository, info) {
569
+ const namespace = `refs/yeaft/pull/${info.number}`;
570
+ await runGit(run, repository.repoRoot, [
571
+ 'fetch',
572
+ '--no-tags',
573
+ repository.remote,
574
+ `+refs/heads/${info.baseRefName}:refs/remotes/${repository.remote}/${info.baseRefName}`,
575
+ `+refs/pull/${info.number}/head:${namespace}/head`,
576
+ `+refs/pull/${info.number}/merge:${namespace}/merge`,
577
+ ]);
578
+ const baseSha = await gitOutput(run, repository.repoRoot, ['rev-parse', `refs/remotes/${repository.remote}/${info.baseRefName}`]);
579
+ const headSha = await gitOutput(run, repository.repoRoot, ['rev-parse', `${namespace}/head`]);
580
+ const snapshotSha = await gitOutput(run, repository.repoRoot, ['rev-parse', `${namespace}/merge`]);
581
+ if (headSha !== info.headRefOid) {
582
+ throw new RepoWorkflowError('PR_HEAD_INCONSISTENT', 'GitHub PR metadata and fetched head ref disagree', {
583
+ metadataHead: info.headRefOid,
584
+ fetchedHead: headSha,
585
+ });
586
+ }
587
+ const parentLine = await gitOutput(run, repository.repoRoot, ['rev-list', '--parents', '-n', '1', snapshotSha]);
588
+ const [, ...parents] = parentLine.split(/\s+/);
589
+ if (parents.length !== 2 || parents[0] !== baseSha || parents[1] !== headSha) {
590
+ throw new RepoWorkflowError('SNAPSHOT_INCONSISTENT', 'GitHub merge snapshot does not have the frozen base/head parents', {
591
+ baseSha,
592
+ headSha,
593
+ snapshotSha,
594
+ parents,
595
+ });
596
+ }
597
+ return { baseSha, headSha, snapshotSha, parents };
598
+ }
599
+
600
+ export async function prepareRepoReview(options = {}, dependencies = {}) {
601
+ const run = dependencies.run || createRepoCommandRunner({ signal: dependencies.signal });
602
+ const pr = assertPositiveInteger(options.pr, 'pr');
603
+ const repository = await resolveRepository(run, options.cwd || process.cwd(), options);
604
+ const info = await loadPullRequest(run, repository, pr);
605
+ const checks = validateOpenPullRequest(info);
606
+ const frozen = await fetchPullRequestSnapshot(run, repository, info);
607
+ const name = sanitizeName(options.name, `review-pr-${pr}-${frozen.headSha.slice(0, 8)}`);
608
+ const worktreePath = resolveWorktreePath(repository, options.worktreePath, name);
609
+ const worktrees = await listWorktrees(run, repository);
610
+ const existing = worktrees.find(item => resolve(item.path) === worktreePath);
611
+
612
+ if (existing) {
613
+ const [status, ownership] = await Promise.all([
614
+ worktreeStatus(run, worktreePath),
615
+ verifyWorktreeOwnership(run, repository, worktreePath, {
616
+ kind: 'review',
617
+ createdHead: frozen.snapshotSha,
618
+ branch: null,
619
+ }),
620
+ ]);
621
+ if (!existing.detached || existing.head !== frozen.snapshotSha || status || !ownership) {
622
+ throw new RepoWorkflowError('REVIEW_WORKTREE_CONFLICT', 'Existing review worktree is not an owned clean checkout of the frozen merge snapshot', {
623
+ existing,
624
+ dirty: Boolean(status),
625
+ ownershipVerified: Boolean(ownership),
626
+ });
627
+ }
628
+ } else {
629
+ if (existsSync(worktreePath)) {
630
+ throw new RepoWorkflowError('PATH_EXISTS', `Review worktree path already exists: ${worktreePath}`);
631
+ }
632
+ await runGit(run, repository.repoRoot, ['worktree', 'add', '--detach', worktreePath, frozen.snapshotSha]);
633
+ try {
634
+ await persistWorktreeOwnership(
635
+ run,
636
+ repository,
637
+ createWorktreeOwnership(worktreePath, 'review', frozen.snapshotSha),
638
+ );
639
+ } catch (error) {
640
+ await runGit(run, repository.repoRoot, ['worktree', 'remove', worktreePath]);
641
+ throw error;
642
+ }
643
+ }
644
+
645
+ return {
646
+ ok: true,
647
+ phase: 'review-prep',
648
+ reused: Boolean(existing),
649
+ repository: compactRepository(repository),
650
+ pullRequest: {
651
+ number: info.number,
652
+ url: info.url,
653
+ baseBranch: info.baseRefName,
654
+ headBranch: info.headRefName,
655
+ baseSha: frozen.baseSha,
656
+ headSha: frozen.headSha,
657
+ snapshotSha: frozen.snapshotSha,
658
+ checks,
659
+ },
660
+ reviewWorktree: { path: worktreePath, head: frozen.snapshotSha, detached: true },
661
+ landInput: {
662
+ pr: info.number,
663
+ baseBranch: info.baseRefName,
664
+ baseSha: frozen.baseSha,
665
+ reviewedHead: frozen.headSha,
666
+ reviewedSnapshot: frozen.snapshotSha,
667
+ },
668
+ };
669
+ }
670
+
671
+ function validateTagPrefix(prefix) {
672
+ if (typeof prefix !== 'string' || !/^v\d+\.\d+\.$/.test(prefix)) {
673
+ throw new RepoWorkflowError('INVALID_TAG_PREFIX', 'tagPrefix must match v<digits>.<digits>.');
674
+ }
675
+ return prefix;
676
+ }
677
+
678
+ export function nextNumericTag(tagNames, prefix, start = 0) {
679
+ validateTagPrefix(prefix);
680
+ const suffixes = tagNames
681
+ .filter(name => name.startsWith(prefix))
682
+ .map(name => name.slice(prefix.length))
683
+ .filter(suffix => /^\d+$/.test(suffix))
684
+ .map(Number)
685
+ .filter(Number.isSafeInteger);
686
+ const next = suffixes.length === 0 ? Number(start) : Math.max(...suffixes) + 1;
687
+ if (!Number.isSafeInteger(next) || next < 0) {
688
+ throw new RepoWorkflowError('INVALID_TAG_SEQUENCE', `Could not calculate the next tag for prefix ${prefix}`);
689
+ }
690
+ return `${prefix}${next}`;
691
+ }
692
+
693
+ function parseRemoteTags(output) {
694
+ const refs = new Map();
695
+ for (const line of String(output || '').split(/\r?\n/)) {
696
+ const match = line.match(/^([0-9a-f]{40})\s+refs\/tags\/(.+?)(\^\{\})?$/i);
697
+ if (!match) continue;
698
+ const [, sha, name, peeled] = match;
699
+ const current = refs.get(name) || { name, directSha: null, commitSha: null };
700
+ if (peeled) current.commitSha = sha;
701
+ else current.directSha = sha;
702
+ refs.set(name, current);
703
+ }
704
+ return [...refs.values()].map(ref => ({
705
+ name: ref.name,
706
+ directSha: ref.directSha,
707
+ commitSha: ref.commitSha || ref.directSha,
708
+ }));
709
+ }
710
+
711
+ function numericTagSuffix(name, prefix) {
712
+ if (!name.startsWith(prefix)) return null;
713
+ const suffix = name.slice(prefix.length);
714
+ if (!/^\d+$/.test(suffix)) return null;
715
+ const number = Number(suffix);
716
+ return Number.isSafeInteger(number) ? number : null;
717
+ }
718
+
719
+ async function resolveRemoteTag(run, repository, tag) {
720
+ const result = await runGit(run, repository.repoRoot, [
721
+ 'ls-remote',
722
+ '--tags',
723
+ repository.remote,
724
+ `refs/tags/${tag}`,
725
+ `refs/tags/${tag}^{}`,
726
+ ]);
727
+ return parseRemoteTags(result.stdout).find(ref => ref.name === tag) || null;
728
+ }
729
+
730
+ async function resolveRemoteRefSha(run, repository, ref, remote = repository.remote) {
731
+ const output = await gitOutput(run, repository.repoRoot, ['ls-remote', remote, ref]);
732
+ const line = output.split(/\r?\n/).find(candidate => candidate.endsWith(`\t${ref}`));
733
+ return line?.split(/\s+/)[0] || null;
734
+ }
735
+
736
+ async function rollbackRemoteTag(run, repository, pushUrl, tag, directSha, targetSha, onStage) {
737
+ const ref = `refs/tags/${tag}`;
738
+ onStage({ stage: 'rollback-started', status: 'unknown', name: tag, sha: targetSha });
739
+ let rollbackError = null;
740
+ try {
741
+ await runGit(run, repository.repoRoot, [
742
+ 'push',
743
+ '--porcelain',
744
+ `--force-with-lease=${ref}:${directSha}`,
745
+ pushUrl,
746
+ `:${ref}`,
747
+ ]);
748
+ } catch (error) {
749
+ rollbackError = error;
750
+ }
751
+ let remaining;
752
+ try {
753
+ remaining = await resolveRemoteTag(run, repository, tag);
754
+ } catch (verifyError) {
755
+ onStage({ stage: 'rollback-verify-failed', status: 'unknown', name: tag, sha: targetSha });
756
+ throw new RepoWorkflowError('TAG_ROLLBACK_VERIFY_FAILED', `Remote tag ${tag} rollback could not be verified after the base advanced`, {
757
+ tag,
758
+ expected: null,
759
+ actual: 'unknown',
760
+ rollbackError: rollbackError ? formatRepoWorkflowError(rollbackError) : null,
761
+ verifyError: formatRepoWorkflowError(verifyError),
762
+ transientTagMayHaveTriggeredAutomation: true,
763
+ });
764
+ }
765
+ if (remaining !== null) {
766
+ onStage({ stage: 'rollback-failed', status: 'unknown', name: tag, sha: targetSha });
767
+ throw new RepoWorkflowError('TAG_ROLLBACK_FAILED', `Remote tag ${tag} could not be rolled back after the base advanced`, {
768
+ tag,
769
+ expected: null,
770
+ actual: remaining,
771
+ rollbackError: rollbackError ? formatRepoWorkflowError(rollbackError) : null,
772
+ transientTagMayHaveTriggeredAutomation: true,
773
+ });
774
+ }
775
+ onStage({
776
+ stage: 'rolled-back',
777
+ status: 'unknown',
778
+ name: tag,
779
+ sha: targetSha,
780
+ transientTagMayHaveTriggeredAutomation: true,
781
+ });
782
+ }
783
+
784
+ async function assertTagBaseGuard(run, repository, baseGuard, tag) {
785
+ if (!baseGuard) return;
786
+ const currentBase = await resolveRemoteRefSha(run, repository, baseGuard.ref);
787
+ if (currentBase !== baseGuard.sha) {
788
+ throw new RepoWorkflowError('BASE_ADVANCED_DURING_TAG_PUSH', `Remote base advanced while tag ${tag} was being reused`, {
789
+ tag,
790
+ expected: baseGuard.sha,
791
+ actual: currentBase,
792
+ tagRolledBack: false,
793
+ transientTagMayHaveTriggeredAutomation: false,
794
+ });
795
+ }
796
+ }
797
+
798
+ async function createAndPushNextTag(run, repository, pushUrl, targetSha, prefix, start = 0, callbacks = {}) {
799
+ const onStage = callbacks.onStage || (() => {});
800
+ const beforePush = callbacks.beforePush || (async () => {});
801
+ const baseGuard = callbacks.baseGuard || null;
802
+ onStage({ stage: 'validate', status: 'not-attempted', prefix, sha: targetSha });
803
+ validateTagPrefix(prefix);
804
+ const initialTag = nextNumericTag([], prefix, start);
805
+ const refCheck = await runGit(run, repository.repoRoot, ['check-ref-format', `refs/tags/${initialTag}`], {
806
+ allowExitCodes: [1],
807
+ });
808
+ if (refCheck.exitCode !== 0) {
809
+ throw new RepoWorkflowError('INVALID_TAG_PREFIX', `tagPrefix does not form a valid Git tag: ${prefix}`);
810
+ }
811
+ onStage({ stage: 'scan-remote', status: 'not-attempted', prefix, sha: targetSha });
812
+ const remoteTags = await runGit(run, repository.repoRoot, [
813
+ 'ls-remote',
814
+ '--tags',
815
+ repository.remote,
816
+ `refs/tags/${prefix}*`,
817
+ ]);
818
+ const matchingTags = parseRemoteTags(remoteTags.stdout)
819
+ .map(ref => ({ ...ref, suffix: numericTagSuffix(ref.name, prefix) }))
820
+ .filter(ref => ref.suffix !== null)
821
+ .sort((a, b) => b.suffix - a.suffix);
822
+ const existingTarget = matchingTags.find(ref => ref.commitSha === targetSha);
823
+ if (existingTarget) {
824
+ await assertTagBaseGuard(run, repository, baseGuard, existingTarget.name);
825
+ onStage({ stage: 'verified-preexisting', status: 'preexisting', name: existingTarget.name, sha: targetSha });
826
+ return { name: existingTarget.name, sha: targetSha, reused: true };
827
+ }
828
+
829
+ const tag = nextNumericTag(matchingTags.map(ref => ref.name), prefix, start);
830
+ const tagRef = `refs/tags/${tag}`;
831
+ onStage({ stage: 'selected', status: 'not-attempted', name: tag, sha: targetSha });
832
+ const remoteExisting = await resolveRemoteTag(run, repository, tag);
833
+ if (remoteExisting) {
834
+ if (remoteExisting.commitSha !== targetSha) {
835
+ throw new RepoWorkflowError('TAG_CONFLICT', `Remote tag ${tag} already points to another commit`, {
836
+ tag,
837
+ expected: targetSha,
838
+ actual: remoteExisting,
839
+ });
840
+ }
841
+ await assertTagBaseGuard(run, repository, baseGuard, tag);
842
+ onStage({ stage: 'verified-preexisting', status: 'preexisting', name: tag, sha: targetSha });
843
+ return { name: tag, sha: targetSha, reused: true };
844
+ }
845
+
846
+ await beforePush();
847
+ onStage({ stage: 'push-started', status: 'unknown', name: tag, sha: targetSha });
848
+ let reused = false;
849
+ try {
850
+ // Push the commit object directly. This deliberately leaves the local tag
851
+ // namespace untouched and makes the empty-ref lease the creation fence.
852
+ const pushed = await runGit(run, repository.repoRoot, [
853
+ 'push',
854
+ '--porcelain',
855
+ `--force-with-lease=${tagRef}:`,
856
+ pushUrl,
857
+ `${targetSha}:${tagRef}`,
858
+ ]);
859
+ const porcelain = `${pushed.stdout}\n${pushed.stderr}`;
860
+ reused = porcelain.split(/\r?\n/).some(line => line.startsWith('=') && line.includes(tagRef));
861
+ onStage({ stage: 'push-returned', status: 'unknown', name: tag, sha: targetSha });
862
+ } catch (pushError) {
863
+ let observedTag;
864
+ let currentBase;
865
+ try {
866
+ [observedTag, currentBase] = await Promise.all([
867
+ resolveRemoteTag(run, repository, tag),
868
+ baseGuard ? resolveRemoteRefSha(run, repository, baseGuard.ref) : Promise.resolve(null),
869
+ ]);
870
+ } catch (verifyError) {
871
+ const effect = {
872
+ stage: 'push-outcome-unknown',
873
+ status: 'unknown',
874
+ name: tag,
875
+ sha: targetSha,
876
+ transientTagMayHaveTriggeredAutomation: true,
877
+ };
878
+ onStage(effect);
879
+ throw new RepoWorkflowError('TAG_PUSH_OUTCOME_UNKNOWN', `Remote tag ${tag} state is unknown after push failed`, {
880
+ tag,
881
+ directSha: 'unknown',
882
+ commitSha: 'unknown',
883
+ currentBase: 'unknown',
884
+ pushError: formatRepoWorkflowError(pushError),
885
+ verifyError: formatRepoWorkflowError(verifyError),
886
+ transientTagMayHaveTriggeredAutomation: true,
887
+ });
888
+ }
889
+ const effect = {
890
+ stage: 'push-outcome-unknown',
891
+ status: 'unknown',
892
+ name: tag,
893
+ sha: targetSha,
894
+ transientTagMayHaveTriggeredAutomation: true,
895
+ };
896
+ onStage(effect);
897
+ throw new RepoWorkflowError('TAG_PUSH_OUTCOME_UNKNOWN', `Remote tag ${tag} outcome is unknown after push failed`, {
898
+ tag,
899
+ directSha: observedTag?.directSha || null,
900
+ commitSha: observedTag?.commitSha || null,
901
+ currentBase,
902
+ pushError: formatRepoWorkflowError(pushError),
903
+ transientTagMayHaveTriggeredAutomation: true,
904
+ });
905
+ }
906
+
907
+ const remoteTag = await resolveRemoteTag(run, repository, tag);
908
+ const currentBase = baseGuard
909
+ ? await resolveRemoteRefSha(run, repository, baseGuard.ref)
910
+ : null;
911
+ if (remoteTag?.commitSha !== targetSha || (!reused && remoteTag.directSha !== targetSha)) {
912
+ throw new RepoWorkflowError('TAG_VERIFY_FAILED', `Remote tag ${tag} could not be verified`, {
913
+ tag,
914
+ expected: { directSha: reused ? 'preexisting' : targetSha, commitSha: targetSha },
915
+ actual: remoteTag,
916
+ });
917
+ }
918
+ if (baseGuard && currentBase !== baseGuard.sha) {
919
+ if (!reused) await rollbackRemoteTag(run, repository, pushUrl, tag, remoteTag.directSha, targetSha, onStage);
920
+ throw new RepoWorkflowError('BASE_ADVANCED_DURING_TAG_PUSH', `Remote base advanced while tag ${tag} was being created`, {
921
+ tag,
922
+ expected: baseGuard.sha,
923
+ actual: currentBase,
924
+ tagRolledBack: !reused,
925
+ transientTagMayHaveTriggeredAutomation: !reused,
926
+ });
927
+ }
928
+ const effect = { stage: reused ? 'verified-preexisting' : 'verified-created', status: reused ? 'preexisting' : 'created', name: tag, sha: targetSha };
929
+ onStage(effect);
930
+ return { name: tag, sha: targetSha, reused };
931
+ }
932
+
933
+ function abortedError() {
934
+ return new RepoWorkflowError('ABORTED', 'Repository workflow aborted');
935
+ }
936
+
937
+ function sleep(ms, signal) {
938
+ return new Promise((resolvePromise, reject) => {
939
+ if (signal?.aborted) {
940
+ reject(abortedError());
941
+ return;
942
+ }
943
+ const onAbort = () => {
944
+ clearTimeout(timer);
945
+ reject(abortedError());
946
+ };
947
+ const timer = setTimeout(() => {
948
+ signal?.removeEventListener('abort', onAbort);
949
+ resolvePromise();
950
+ }, ms);
951
+ signal?.addEventListener('abort', onAbort, { once: true });
952
+ });
953
+ }
954
+
955
+ async function captureWorkflowIdentity(run, repository, workflow) {
956
+ const github = githubRepository(repository);
957
+ const response = await ghJson(run, repository.repoRoot, [
958
+ 'api',
959
+ '--hostname',
960
+ github.host,
961
+ '--paginate',
962
+ '--slurp',
963
+ `repos/${github.nameWithOwner}/actions/workflows?per_page=100`,
964
+ ]);
965
+ const pages = Array.isArray(response) ? response : [response];
966
+ const workflows = pages.flatMap(page => Array.isArray(page?.workflows) ? page.workflows : []);
967
+ const selector = String(workflow || '').trim();
968
+ const matches = workflows.filter(item => {
969
+ const id = Number(item?.id);
970
+ const path = String(item?.path || '');
971
+ return (Number.isSafeInteger(id) && String(id) === selector)
972
+ || path === selector
973
+ || basename(path) === selector
974
+ || String(item?.name || '') === selector;
975
+ });
976
+ if (matches.length > 1) {
977
+ throw new RepoWorkflowError('WORKFLOW_AMBIGUOUS', `Workflow "${selector}" matches multiple GitHub workflows`, {
978
+ matches: matches.map(item => ({ id: item.id, name: item.name, path: item.path })),
979
+ });
980
+ }
981
+ const info = matches[0];
982
+ const workflowId = Number(info?.id);
983
+ if (!Number.isSafeInteger(workflowId) || workflowId <= 0) {
984
+ throw new RepoWorkflowError('WORKFLOW_ID_UNKNOWN', `Workflow "${selector}" has no immutable GitHub workflow ID`);
985
+ }
986
+ return { id: workflowId, name: String(info.name || selector), path: String(info.path || '') };
987
+ }
988
+
989
+ async function listWorkflowRuns(run, repository, workflowIdentity) {
990
+ const github = githubRepository(repository);
991
+ const response = await ghJson(run, repository.repoRoot, [
992
+ 'api',
993
+ '--hostname',
994
+ github.host,
995
+ `repos/${github.nameWithOwner}/actions/workflows/${workflowIdentity.id}/runs?per_page=100`,
996
+ ]);
997
+ return Array.isArray(response?.workflow_runs) ? response.workflow_runs : [];
998
+ }
999
+
1000
+ async function captureWorkflowBaseline(run, repository, workflowIdentity) {
1001
+ const runs = await listWorkflowRuns(run, repository, workflowIdentity);
1002
+ const baselineRunId = runs.reduce((maximum, item) => {
1003
+ const id = Number(item?.databaseId ?? item?.id);
1004
+ return Number.isSafeInteger(id) ? Math.max(maximum, id) : maximum;
1005
+ }, 0);
1006
+ return { ...workflowIdentity, baselineRunId };
1007
+ }
1008
+
1009
+ function workflowRunTimestamp(runInfo) {
1010
+ const value = Date.parse(runInfo?.createdAt
1011
+ || runInfo?.startedAt
1012
+ || runInfo?.created_at
1013
+ || runInfo?.run_started_at
1014
+ || '');
1015
+ return Number.isFinite(value) ? value : null;
1016
+ }
1017
+
1018
+ function matchesWorkflowRun(runInfo, options, { requireWorkflowId = false } = {}) {
1019
+ const id = Number(runInfo?.databaseId ?? runInfo?.id);
1020
+ const workflowId = Number(runInfo?.workflowDatabaseId ?? runInfo?.workflow_id);
1021
+ const timestamp = workflowRunTimestamp(runInfo);
1022
+ return Number.isSafeInteger(id)
1023
+ && id > options.workflowFence.baselineRunId
1024
+ && (!requireWorkflowId || workflowId === options.workflowFence.id)
1025
+ && runInfo.event === 'push'
1026
+ && (runInfo.headSha ?? runInfo.head_sha) === options.targetSha
1027
+ && (runInfo.headBranch ?? runInfo.head_branch) === options.ref
1028
+ && timestamp !== null
1029
+ && timestamp >= Math.floor(options.notBeforeMs / 1000) * 1000;
1030
+ }
1031
+
1032
+ async function loadWorkflowRunDetail(run, repository, runId) {
1033
+ const github = githubRepository(repository);
1034
+ return ghJson(run, repository.repoRoot, [
1035
+ 'api',
1036
+ '--hostname',
1037
+ github.host,
1038
+ `repos/${github.nameWithOwner}/actions/runs/${runId}`,
1039
+ ]);
1040
+ }
1041
+
1042
+ async function waitForWorkflow(run, repository, options, dependencies = {}) {
1043
+ const sleepImpl = dependencies.sleep || sleep;
1044
+ const signal = dependencies.signal;
1045
+ const now = dependencies.now || (() => Date.now());
1046
+ const github = githubRepository(repository);
1047
+ const timeoutMs = Number(options.waitTimeoutMs || DEFAULT_WAIT_TIMEOUT_MS);
1048
+ const pollIntervalMs = Number(options.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS);
1049
+ const waitStartedAt = now();
1050
+ let runInfo = null;
1051
+
1052
+ while (now() - waitStartedAt <= timeoutMs) {
1053
+ const runs = await listWorkflowRuns(run, repository, options.workflowFence);
1054
+ runInfo = runs
1055
+ .filter(item => matchesWorkflowRun(item, options, { requireWorkflowId: true }))
1056
+ .sort((a, b) => Number(b.id ?? b.databaseId) - Number(a.id ?? a.databaseId))[0] || null;
1057
+ if (runInfo) break;
1058
+ await sleepImpl(pollIntervalMs, signal);
1059
+ }
1060
+
1061
+ if (!runInfo) {
1062
+ throw new RepoWorkflowError('WORKFLOW_NOT_FOUND', `Workflow "${options.workflowFence.name}" did not produce a new matching push run before timeout`, {
1063
+ effect: 'merge-and-tag-completed',
1064
+ workflow: options.workflowFence,
1065
+ targetSha: options.targetSha,
1066
+ ref: options.ref,
1067
+ notBefore: new Date(options.notBeforeMs).toISOString(),
1068
+ });
1069
+ }
1070
+
1071
+ const selectedRunId = Number(runInfo.id ?? runInfo.databaseId);
1072
+ while (now() - waitStartedAt <= timeoutMs) {
1073
+ const detail = await loadWorkflowRunDetail(run, repository, selectedRunId);
1074
+ const detailRunId = Number(detail.id ?? detail.databaseId);
1075
+ if (!matchesWorkflowRun(detail, options, { requireWorkflowId: true })
1076
+ || detailRunId !== selectedRunId) {
1077
+ throw new RepoWorkflowError('WORKFLOW_IDENTITY_MISMATCH', 'GitHub workflow run details do not match the fenced push run', {
1078
+ expected: {
1079
+ runId: selectedRunId,
1080
+ workflowId: options.workflowFence.id,
1081
+ targetSha: options.targetSha,
1082
+ ref: options.ref,
1083
+ event: 'push',
1084
+ },
1085
+ actual: {
1086
+ runId: detailRunId || null,
1087
+ workflowId: Number(detail.workflow_id ?? detail.workflowDatabaseId) || null,
1088
+ targetSha: detail.head_sha ?? detail.headSha ?? null,
1089
+ ref: detail.head_branch ?? detail.headBranch ?? null,
1090
+ event: detail.event || null,
1091
+ },
1092
+ });
1093
+ }
1094
+ if (detail.status === 'completed') {
1095
+ const jobsResponse = await ghJson(run, repository.repoRoot, [
1096
+ 'run',
1097
+ 'view',
1098
+ String(selectedRunId),
1099
+ '--repo',
1100
+ github.selector,
1101
+ '--json',
1102
+ 'jobs',
1103
+ ]);
1104
+ const jobs = (jobsResponse.jobs || []).map(job => ({
1105
+ name: job.name,
1106
+ status: job.status,
1107
+ conclusion: job.conclusion || null,
1108
+ }));
1109
+ if (detail.conclusion !== 'success') {
1110
+ throw new RepoWorkflowError('WORKFLOW_FAILED', `Workflow "${options.workflowFence.name}" completed with ${detail.conclusion}`, {
1111
+ effect: 'merge-and-tag-completed',
1112
+ run: { id: detailRunId, url: detail.html_url || detail.url, conclusion: detail.conclusion, jobs },
1113
+ });
1114
+ }
1115
+ return {
1116
+ id: detailRunId,
1117
+ workflowId: options.workflowFence.id,
1118
+ url: detail.html_url || detail.url,
1119
+ conclusion: detail.conclusion,
1120
+ jobs,
1121
+ };
1122
+ }
1123
+ await sleepImpl(pollIntervalMs, signal);
1124
+ }
1125
+
1126
+ throw new RepoWorkflowError('WORKFLOW_TIMEOUT', `Workflow "${options.workflowFence.name}" did not finish before timeout`, {
1127
+ effect: 'merge-and-tag-completed',
1128
+ run: { id: selectedRunId, url: runInfo.html_url || runInfo.url },
1129
+ });
1130
+ }
1131
+
1132
+ function attachRemoteEffects(error, remoteEffects) {
1133
+ if (error instanceof RepoWorkflowError) {
1134
+ error.details = { ...error.details, remoteEffects };
1135
+ return error;
1136
+ }
1137
+ return new RepoWorkflowError('POST_LANDING_FAILED', error?.message || String(error), {
1138
+ remoteEffects,
1139
+ });
1140
+ }
1141
+
1142
+ function rejectLegacyLandingOptions(options) {
1143
+ if (Object.hasOwn(options, 'mergeMethod')) {
1144
+ throw new RepoWorkflowError('MERGE_METHOD_UNSUPPORTED', 'land always installs the exact reviewed merge snapshot; mergeMethod is not supported');
1145
+ }
1146
+ if (Object.hasOwn(options, 'worktreePaths') || Object.hasOwn(options, 'cleanupWorktree')) {
1147
+ throw new RepoWorkflowError('LANDING_CLEANUP_UNSUPPORTED', 'land never removes worktrees; cleanup must be requested separately after landing');
1148
+ }
1149
+ }
1150
+
1151
+ async function pushReviewedSnapshot(run, repository, pushUrl, baseRef, frozenBase, reviewedSnapshot) {
1152
+ let pushFailure = null;
1153
+ let pushRejected = false;
1154
+ try {
1155
+ const pushed = await runGit(run, repository.repoRoot, [
1156
+ 'push',
1157
+ '--porcelain',
1158
+ `--force-with-lease=${baseRef}:${frozenBase}`,
1159
+ pushUrl,
1160
+ `${reviewedSnapshot}:${baseRef}`,
1161
+ ], { allowExitCodes: [1] });
1162
+ if (pushed.exitCode !== 0) {
1163
+ pushFailure = pushed;
1164
+ // A non-zero exit can follow remote acceptance (for example a lost
1165
+ // connection). Only a porcelain rejection for this exact destination
1166
+ // proves that Git rejected the write; stderr/exit status alone cannot.
1167
+ pushRejected = String(pushed.stdout || '').split('\n').some(line => {
1168
+ const [flag, refspec, summary] = line.split('\t');
1169
+ return flag === '!'
1170
+ && refspec === `${reviewedSnapshot}:${baseRef}`
1171
+ && /^\[(?:rejected|remote rejected)\](?: |$)/.test(summary || '');
1172
+ });
1173
+ }
1174
+ } catch (error) {
1175
+ pushFailure = error;
1176
+ }
1177
+
1178
+ let actual;
1179
+ try {
1180
+ actual = await resolveRemoteRefSha(run, repository, baseRef, pushUrl);
1181
+ } catch (error) {
1182
+ throw new RepoWorkflowError('BASE_UPDATE_OUTCOME_UNKNOWN', 'Could not verify the remote base after the atomic update attempt', {
1183
+ ref: baseRef,
1184
+ expectedOld: frozenBase,
1185
+ expectedNew: reviewedSnapshot,
1186
+ cause: error?.message || String(error),
1187
+ });
1188
+ }
1189
+ if (actual === reviewedSnapshot) return;
1190
+ if (pushFailure) {
1191
+ if (pushRejected && actual === frozenBase) {
1192
+ throw new RepoWorkflowError('BASE_LEASE_REJECTED', 'Remote base did not accept the exact reviewed snapshot with the frozen-base lease', {
1193
+ ref: baseRef,
1194
+ expectedOld: frozenBase,
1195
+ expectedNew: reviewedSnapshot,
1196
+ actual,
1197
+ });
1198
+ }
1199
+ throw new RepoWorkflowError('BASE_UPDATE_OUTCOME_UNKNOWN', 'Remote base outcome is unknown after the atomic update attempt failed', {
1200
+ ref: baseRef,
1201
+ expectedOld: frozenBase,
1202
+ expectedNew: reviewedSnapshot,
1203
+ actual,
1204
+ pushError: formatRepoWorkflowError(pushFailure),
1205
+ });
1206
+ }
1207
+ throw new RepoWorkflowError('BASE_UPDATE_VERIFY_FAILED', 'Remote base does not point at the exact reviewed snapshot after push', {
1208
+ ref: baseRef,
1209
+ expected: reviewedSnapshot,
1210
+ actual,
1211
+ });
1212
+ }
1213
+
1214
+ export async function landRepoWorkflow(options = {}, dependencies = {}) {
1215
+ const run = dependencies.run || createRepoCommandRunner({ signal: dependencies.signal });
1216
+ const pr = assertPositiveInteger(options.pr, 'pr');
1217
+ if (!isRepoApprovalCapability(dependencies.approvalCapability)) {
1218
+ throw new RepoWorkflowError('APPROVAL_REQUIRED', 'Landing requires a host-issued repository approval capability');
1219
+ }
1220
+ if (typeof options.baseBranch !== 'string' || !options.baseBranch.trim()) {
1221
+ throw new RepoWorkflowError('INVALID_INPUT', 'baseBranch must be the exact reviewed destination branch');
1222
+ }
1223
+ if (!/^[0-9a-f]{40}$/i.test(options.baseSha || '')) {
1224
+ throw new RepoWorkflowError('INVALID_INPUT', 'baseSha must be the exact 40-character reviewed base commit SHA');
1225
+ }
1226
+ if (!/^[0-9a-f]{40}$/i.test(options.reviewedHead || '')) {
1227
+ throw new RepoWorkflowError('INVALID_INPUT', 'reviewedHead must be the exact 40-character reviewed commit SHA');
1228
+ }
1229
+ if (!/^[0-9a-f]{40}$/i.test(options.reviewedSnapshot || '')) {
1230
+ throw new RepoWorkflowError('INVALID_INPUT', 'reviewedSnapshot must be the exact 40-character reviewed merge snapshot SHA');
1231
+ }
1232
+ rejectLegacyLandingOptions(options);
1233
+ const approvedBaseBranch = options.baseBranch.trim();
1234
+ const approvedBaseSha = options.baseSha.toLowerCase();
1235
+ if (options.tagPrefix !== undefined) validateTagPrefix(options.tagPrefix);
1236
+
1237
+ const repository = await resolveRepository(run, options.cwd || process.cwd(), options);
1238
+ const github = githubRepository(repository);
1239
+ const approval = consumeRepoApprovalCapability(dependencies.approvalCapability, {
1240
+ ...dependencies.approvalContext,
1241
+ repository: `${github.host}/${github.nameWithOwner}`,
1242
+ pr,
1243
+ baseBranch: approvedBaseBranch,
1244
+ baseSha: approvedBaseSha,
1245
+ reviewedHead: options.reviewedHead,
1246
+ reviewedSnapshot: options.reviewedSnapshot,
1247
+ }, { now: dependencies.now || Date.now });
1248
+ if (!approval) {
1249
+ throw new RepoWorkflowError('APPROVAL_REQUIRED', 'Repository approval capability is missing, invalid, stale, or already consumed');
1250
+ }
1251
+
1252
+ // The push URL and workflow identity are frozen before the first write. The
1253
+ // base update itself is one receive-pack ref transaction guarded by the exact
1254
+ // base observed together with the reviewed PR head and merge snapshot.
1255
+ const pushUrl = await assertPushTarget(run, repository);
1256
+ const workflowIdentity = options.workflow
1257
+ ? await captureWorkflowIdentity(run, repository, options.workflow)
1258
+ : null;
1259
+ const now = dependencies.now || (() => Date.now());
1260
+ let workflowFence = null;
1261
+ let workflowNotBeforeMs = null;
1262
+ const info = await loadPullRequest(run, repository, pr);
1263
+ if (info.baseRefName !== approvedBaseBranch) {
1264
+ throw new RepoWorkflowError('REVIEW_STALE', 'PR destination branch changed after review', {
1265
+ reviewed: { baseBranch: approvedBaseBranch, baseSha: approvedBaseSha },
1266
+ current: { baseBranch: info.baseRefName },
1267
+ });
1268
+ }
1269
+ let checks = null;
1270
+ let frozenBase = null;
1271
+ let alreadyMerged = false;
1272
+ let tag = null;
1273
+
1274
+ if (info.state === 'OPEN') {
1275
+ checks = validateOpenPullRequest(info, { requireChecksComplete: true });
1276
+ const frozen = await fetchPullRequestSnapshot(run, repository, info);
1277
+ if (frozen.headSha !== options.reviewedHead || frozen.snapshotSha !== options.reviewedSnapshot) {
1278
+ throw new RepoWorkflowError('REVIEW_STALE', 'PR head or merge snapshot changed after review', {
1279
+ reviewed: { headSha: options.reviewedHead, snapshotSha: options.reviewedSnapshot },
1280
+ current: { headSha: frozen.headSha, snapshotSha: frozen.snapshotSha, baseSha: frozen.baseSha },
1281
+ });
1282
+ }
1283
+ if (frozen.baseSha !== approvedBaseSha) {
1284
+ throw new RepoWorkflowError('REVIEW_STALE', 'PR base changed after review', {
1285
+ reviewed: { baseBranch: approvedBaseBranch, baseSha: approvedBaseSha },
1286
+ current: { baseBranch: info.baseRefName, baseSha: frozen.baseSha },
1287
+ });
1288
+ }
1289
+ frozenBase = approvedBaseSha;
1290
+ } else if (info.state === 'MERGED') {
1291
+ const mergeSha = info.mergeCommit?.oid || null;
1292
+ if (info.headRefOid !== options.reviewedHead || mergeSha !== options.reviewedSnapshot) {
1293
+ throw new RepoWorkflowError('REVIEW_STALE', 'Merged pull request does not match the exact reviewed head and snapshot', {
1294
+ reviewed: { headSha: options.reviewedHead, snapshotSha: options.reviewedSnapshot },
1295
+ current: { headSha: info.headRefOid, mergeSha },
1296
+ });
1297
+ }
1298
+ const parentLine = await gitOutput(run, repository.repoRoot, ['rev-list', '--parents', '-n', '1', options.reviewedSnapshot]);
1299
+ const [, ...parents] = parentLine.split(/\s+/);
1300
+ if (parents.length !== 2 || parents[0] !== approvedBaseSha || parents[1] !== options.reviewedHead) {
1301
+ throw new RepoWorkflowError('SNAPSHOT_INCONSISTENT', 'Reviewed snapshot does not have the approved base and reviewed head as its parents', {
1302
+ baseSha: approvedBaseSha,
1303
+ headSha: options.reviewedHead,
1304
+ snapshotSha: options.reviewedSnapshot,
1305
+ parents,
1306
+ });
1307
+ }
1308
+ const currentBase = await fetchBase(run, repository, approvedBaseBranch);
1309
+ if (currentBase !== options.reviewedSnapshot) {
1310
+ throw new RepoWorkflowError('BASE_NOT_EXACT_REVIEWED_SNAPSHOT', 'Remote base no longer points at the exact reviewed snapshot', {
1311
+ expected: options.reviewedSnapshot,
1312
+ actual: currentBase,
1313
+ });
1314
+ }
1315
+ frozenBase = parents[0];
1316
+ alreadyMerged = true;
1317
+ } else {
1318
+ throw new RepoWorkflowError('PR_NOT_LANDABLE', `PR #${pr} is ${String(info.state).toLowerCase()}`);
1319
+ }
1320
+
1321
+ if (workflowIdentity && !options.tagPrefix) {
1322
+ if (alreadyMerged) {
1323
+ throw new RepoWorkflowError('WORKFLOW_NOT_TRIGGERED', 'The exact reviewed snapshot is already landed, so this call cannot fence a new branch workflow run');
1324
+ }
1325
+ workflowFence = await captureWorkflowBaseline(run, repository, workflowIdentity);
1326
+ workflowNotBeforeMs = now();
1327
+ }
1328
+
1329
+ const baseRef = `refs/heads/${approvedBaseBranch}`;
1330
+ const remoteEffects = {
1331
+ base: {
1332
+ status: alreadyMerged ? 'preexisting' : 'not-attempted',
1333
+ ref: baseRef,
1334
+ before: frozenBase,
1335
+ sha: options.reviewedSnapshot,
1336
+ },
1337
+ };
1338
+ try {
1339
+ if (!alreadyMerged) {
1340
+ remoteEffects.base.status = 'unknown';
1341
+ try {
1342
+ await pushReviewedSnapshot(
1343
+ run,
1344
+ repository,
1345
+ pushUrl,
1346
+ baseRef,
1347
+ frozenBase,
1348
+ options.reviewedSnapshot,
1349
+ );
1350
+ } catch (error) {
1351
+ if (error instanceof RepoWorkflowError && error.code === 'BASE_LEASE_REJECTED') {
1352
+ remoteEffects.base.status = 'rejected';
1353
+ }
1354
+ throw error;
1355
+ }
1356
+ remoteEffects.base.status = 'created';
1357
+ }
1358
+
1359
+ if (options.tagPrefix) {
1360
+ tag = await createAndPushNextTag(
1361
+ run,
1362
+ repository,
1363
+ pushUrl,
1364
+ options.reviewedSnapshot,
1365
+ options.tagPrefix,
1366
+ options.tagStart ?? 0,
1367
+ {
1368
+ baseGuard: { ref: baseRef, sha: options.reviewedSnapshot },
1369
+ onStage: effect => { remoteEffects.tag = effect; },
1370
+ beforePush: async () => {
1371
+ if (!workflowIdentity) return;
1372
+ workflowFence = await captureWorkflowBaseline(run, repository, workflowIdentity);
1373
+ workflowNotBeforeMs = now();
1374
+ },
1375
+ },
1376
+ );
1377
+ if (workflowIdentity && !workflowFence) {
1378
+ throw new RepoWorkflowError('WORKFLOW_NOT_TRIGGERED', 'The target tag already existed, so this landing did not trigger a new workflow run', {
1379
+ tag,
1380
+ });
1381
+ }
1382
+ }
1383
+ const workflow = workflowFence
1384
+ ? await waitForWorkflow(run, repository, {
1385
+ workflowFence,
1386
+ targetSha: options.reviewedSnapshot,
1387
+ ref: tag?.name || approvedBaseBranch,
1388
+ notBeforeMs: workflowNotBeforeMs,
1389
+ waitTimeoutMs: options.waitTimeoutMs,
1390
+ pollIntervalMs: options.pollIntervalMs,
1391
+ }, dependencies)
1392
+ : null;
1393
+
1394
+ return {
1395
+ ok: true,
1396
+ phase: 'land',
1397
+ repository: compactRepository(repository),
1398
+ approval: {
1399
+ by: approval.issuerVpId,
1400
+ baseBranch: approvedBaseBranch,
1401
+ baseSha: approvedBaseSha,
1402
+ headSha: options.reviewedHead,
1403
+ snapshotSha: options.reviewedSnapshot,
1404
+ },
1405
+ pullRequest: { number: info.number, url: info.url, baseBranch: approvedBaseBranch, checks },
1406
+ merge: { sha: options.reviewedSnapshot, alreadyMerged },
1407
+ tag,
1408
+ workflow,
1409
+ };
1410
+ } catch (error) {
1411
+ throw attachRemoteEffects(error, remoteEffects);
1412
+ }
1413
+ }
1414
+
1415
+ export function formatRepoWorkflowError(error) {
1416
+ const payload = error instanceof RepoWorkflowError
1417
+ ? (() => {
1418
+ const remoteEffects = error.details?.remoteEffects;
1419
+ const hasPossibleRemoteEffect = remoteEffects
1420
+ && Object.values(remoteEffects).some(effect => ['created', 'unknown'].includes(effect?.status));
1421
+ return {
1422
+ ok: false,
1423
+ errorEffect: hasPossibleRemoteEffect ? 'unknown' : 'none',
1424
+ error: error.message,
1425
+ code: error.code,
1426
+ details: error.details,
1427
+ };
1428
+ })()
1429
+ : {
1430
+ ok: false,
1431
+ errorEffect: 'unknown',
1432
+ error: error?.message || String(error),
1433
+ code: 'UNEXPECTED_ERROR',
1434
+ details: {},
1435
+ };
1436
+ return redactStructured(payload);
1437
+ }