@yeaft/webchat-agent 1.0.526 → 1.0.527

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/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
  ? (() => {