@yeaft/webchat-agent 1.0.526 → 1.0.528
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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +119 -97
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/repo-workflow-cli.js +34 -26
- package/repo-workflow.js +0 -779
- package/yeaft/engine.js +13 -1
- package/yeaft/routing/router.js +0 -135
- package/yeaft/sub-agent/execution-control.js +16 -9
- package/yeaft/sub-agent/liveness.js +20 -12
- package/yeaft/sub-agent/runner.js +12 -15
- package/yeaft/sub-agent/status.js +5 -5
- package/yeaft/tools/activation.js +1 -4
- package/yeaft/tools/agent.js +21 -40
- package/yeaft/tools/apply-patch.js +442 -134
- package/yeaft/tools/bash.js +11 -9
- package/yeaft/tools/exit-worktree.js +1 -1
- package/yeaft/tools/file-read.js +36 -4
- package/yeaft/tools/git-read.js +29 -9
- package/yeaft/tools/index.js +0 -2
- package/yeaft/tools/list-agents.js +60 -58
- package/yeaft/tools/list-tasks.js +31 -3
- package/yeaft/tools/process-runner.js +29 -1
- package/yeaft/tools/registry.js +18 -0
- package/yeaft/tools/route-forward.js +1 -19
- package/yeaft/tools/update-agent.js +2 -1
- package/yeaft/tools/wait-agent.js +17 -17
- package/yeaft/tools/web-fetch.js +6 -1
- package/yeaft/web-bridge.js +2 -54
- package/skills/review-merge-tag/SKILL.md +0 -78
- package/yeaft/tools/repo-workflow.js +0 -80
package/repo-workflow.js
CHANGED
|
@@ -4,15 +4,8 @@ import { existsSync } from 'node:fs';
|
|
|
4
4
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
-
import {
|
|
8
|
-
consumeRepoApprovalCapability,
|
|
9
|
-
isRepoApprovalCapability,
|
|
10
|
-
} from './yeaft/routing/router.js';
|
|
11
|
-
|
|
12
7
|
const execFile = promisify(execFileCallback);
|
|
13
8
|
const DEFAULT_REMOTE = 'origin';
|
|
14
|
-
const DEFAULT_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
15
|
-
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
16
9
|
const MAX_ERROR_OUTPUT = 4_000;
|
|
17
10
|
const WORKTREE_OWNERSHIP_FILE = 'yeaft-repo-workflow-owner.json';
|
|
18
11
|
const WORKTREE_OWNERSHIP_VERSION = 1;
|
|
@@ -292,34 +285,6 @@ function githubRepository(repository) {
|
|
|
292
285
|
return parseGithubRemoteUrl(repository.remoteUrl);
|
|
293
286
|
}
|
|
294
287
|
|
|
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
288
|
function worktreeOwnershipKey(path) {
|
|
324
289
|
return createHash('sha256').update(resolve(path)).digest('hex');
|
|
325
290
|
}
|
|
@@ -668,750 +633,6 @@ export async function prepareRepoReview(options = {}, dependencies = {}) {
|
|
|
668
633
|
};
|
|
669
634
|
}
|
|
670
635
|
|
|
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
636
|
export function formatRepoWorkflowError(error) {
|
|
1416
637
|
const payload = error instanceof RepoWorkflowError
|
|
1417
638
|
? (() => {
|