@blogic-cz/agent-tools 0.14.60 → 0.14.61
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/package.json +1 -1
- package/src/gh-tool/pr/core.ts +120 -54
package/package.json
CHANGED
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
WorkflowRunDetail,
|
|
17
17
|
} from "#gh/types";
|
|
18
18
|
|
|
19
|
+
import type { GitHubAuthError, GitHubNotFoundError } from "#gh/errors";
|
|
19
20
|
import { GitHubCommandError, GitHubMergeError } from "#gh/errors";
|
|
20
21
|
import { GitHubService } from "#gh/service";
|
|
21
22
|
|
|
@@ -24,6 +25,7 @@ import { runLocalCommand } from "./helpers";
|
|
|
24
25
|
import { diagnoseLogEntries, fetchJobLogs, formatLogEntries, parseRawJobLogs } from "#gh/workflow";
|
|
25
26
|
|
|
26
27
|
const CHECK_JSON_FIELDS = "name,state,bucket,link";
|
|
28
|
+
const LONG_LIVED_BRANCHES = new Set(["main", "master", "develop", "staging", "production"]);
|
|
27
29
|
const STABLE_SNAPSHOT_ATTEMPTS = 3;
|
|
28
30
|
const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
|
|
29
31
|
|
|
@@ -695,13 +697,22 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
|
|
|
695
697
|
"number,url,title,headRefName,baseRefName,state,isDraft,mergeable",
|
|
696
698
|
]);
|
|
697
699
|
|
|
700
|
+
const repo = opts.deleteBranch ? yield* gh.getRepoInfo() : null;
|
|
701
|
+
|
|
702
|
+
// A long-lived branch (default/env branch) as PR head means a promotion PR
|
|
703
|
+
// (e.g. main -> staging). PRs based on it are unrelated work, not a stack —
|
|
704
|
+
// retargeting them would mass-rewrite their base — and the branch itself
|
|
705
|
+
// must never be deleted.
|
|
706
|
+
const headIsLongLived =
|
|
707
|
+
LONG_LIVED_BRANCHES.has(info.headRefName) || info.headRefName === repo?.defaultBranch;
|
|
708
|
+
|
|
698
709
|
// Stacked-PR safety: find open PRs that depend on this PR's head branch.
|
|
699
710
|
// Deleting the head branch of an open PR that uses it as its base CLOSES that
|
|
700
711
|
// PR (GitHub CLI behavior, see cli/cli#1168) instead of retargeting it. We
|
|
701
712
|
// retarget such dependents onto this PR's base first, and only delete the
|
|
702
713
|
// branch if EVERY retarget succeeds (fail-closed).
|
|
703
714
|
const dependentOpenPrs =
|
|
704
|
-
opts.deleteBranch && info.headRefName
|
|
715
|
+
opts.deleteBranch && !headIsLongLived && info.headRefName
|
|
705
716
|
? yield* gh.runGhJson<Array<{ number: number; headRefName: string; baseRefName: string }>>([
|
|
706
717
|
"pr",
|
|
707
718
|
"list",
|
|
@@ -722,18 +733,23 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
|
|
|
722
733
|
? "PR is mergeable."
|
|
723
734
|
: `PR mergeable status: ${info.mergeable}`;
|
|
724
735
|
|
|
725
|
-
const dependentNote =
|
|
726
|
-
|
|
736
|
+
const dependentNote = headIsLongLived
|
|
737
|
+
? opts.deleteBranch
|
|
738
|
+
? `Head \`${info.headRefName}\` is a long-lived branch; deletion and dependent retargeting are skipped. `
|
|
739
|
+
: ""
|
|
740
|
+
: dependentOpenPrs.length > 0
|
|
727
741
|
? `${dependentOpenPrs.length} dependent open PR(s) (${dependentOpenPrs
|
|
728
742
|
.map((d) => `#${d.number}`)
|
|
729
|
-
.join(", ")}) will be retargeted to \`${info.baseRefName}\` before deletion
|
|
730
|
-
"branch deletion is skipped if any retarget fails. "
|
|
743
|
+
.join(", ")}) will be retargeted to \`${info.baseRefName}\` before deletion ` +
|
|
744
|
+
"(rolled back if the merge fails); branch deletion is skipped if any retarget fails. "
|
|
731
745
|
: "";
|
|
732
746
|
|
|
733
747
|
yield* Console.log(
|
|
734
748
|
`DRY RUN: Would merge PR #${info.number} "${info.title}" via ${opts.strategy.toUpperCase()}. ` +
|
|
735
749
|
`Branch \`${info.headRefName}\` → \`${info.baseRefName}\`. ` +
|
|
736
|
-
(opts.deleteBranch
|
|
750
|
+
(opts.deleteBranch && !headIsLongLived
|
|
751
|
+
? `Remote branch \`${info.headRefName}\` will be deleted. `
|
|
752
|
+
: "") +
|
|
737
753
|
dependentNote +
|
|
738
754
|
mergeableNote,
|
|
739
755
|
);
|
|
@@ -747,14 +763,16 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
|
|
|
747
763
|
return result;
|
|
748
764
|
}
|
|
749
765
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
let willDeleteBranch = opts.deleteBranch;
|
|
753
|
-
let branchDeleteSkipped = false;
|
|
766
|
+
let willDeleteBranch = opts.deleteBranch && !headIsLongLived;
|
|
767
|
+
let branchDeleteSkipped = opts.deleteBranch && headIsLongLived;
|
|
754
768
|
const retargetedChildren: number[] = [];
|
|
755
|
-
const repo = opts.deleteBranch ? yield* gh.getRepoInfo() : null;
|
|
756
769
|
|
|
757
|
-
|
|
770
|
+
// Retarget dependents BEFORE merging: repos with "Automatically delete head
|
|
771
|
+
// branches" delete the head as part of the merge itself, which closes any PR
|
|
772
|
+
// still based on it (cli/cli#1168). A failed merge rolls the retargets back.
|
|
773
|
+
// If any retarget fails, keep the branch (fail-closed) so no dependent PR is
|
|
774
|
+
// closed.
|
|
775
|
+
if (willDeleteBranch && dependentOpenPrs.length > 0 && repo) {
|
|
758
776
|
for (const child of dependentOpenPrs) {
|
|
759
777
|
const retargeted = yield* gh
|
|
760
778
|
.runGh([
|
|
@@ -780,54 +798,102 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
|
|
|
780
798
|
}
|
|
781
799
|
}
|
|
782
800
|
|
|
801
|
+
const rollbackRetargets = Effect.gen(function* () {
|
|
802
|
+
const failed: number[] = [];
|
|
803
|
+
if (retargetedChildren.length === 0 || !repo) {
|
|
804
|
+
return failed;
|
|
805
|
+
}
|
|
806
|
+
for (const child of retargetedChildren) {
|
|
807
|
+
const rolledBack = yield* gh
|
|
808
|
+
.runGh([
|
|
809
|
+
"api",
|
|
810
|
+
"--method",
|
|
811
|
+
"PATCH",
|
|
812
|
+
`repos/${repo.owner}/${repo.name}/pulls/${child}`,
|
|
813
|
+
"-f",
|
|
814
|
+
`base=${info.headRefName}`,
|
|
815
|
+
])
|
|
816
|
+
.pipe(
|
|
817
|
+
Effect.as(true),
|
|
818
|
+
Effect.orElseSucceed(() => false),
|
|
819
|
+
);
|
|
820
|
+
if (!rolledBack) {
|
|
821
|
+
failed.push(child);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return failed;
|
|
825
|
+
});
|
|
826
|
+
|
|
783
827
|
const mergeArgs = ["pr", "merge", String(opts.pr), `--${opts.strategy}`];
|
|
784
828
|
|
|
785
829
|
const mergeResult = yield* gh.runGh(mergeArgs).pipe(
|
|
786
|
-
Effect.
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
830
|
+
Effect.catch((error) =>
|
|
831
|
+
rollbackRetargets.pipe(
|
|
832
|
+
Effect.andThen(
|
|
833
|
+
(
|
|
834
|
+
rollbackFailed,
|
|
835
|
+
): Effect.Effect<never, GitHubNotFoundError | GitHubAuthError | GitHubMergeError> => {
|
|
836
|
+
const rollbackNote =
|
|
837
|
+
rollbackFailed.length > 0
|
|
838
|
+
? ` ROLLBACK INCOMPLETE: dependent PR(s) ${rollbackFailed
|
|
839
|
+
.map((child) => `#${child}`)
|
|
840
|
+
.join(", ")} are still retargeted to \`${info.baseRefName}\`; ` +
|
|
841
|
+
`manually restore their base to \`${info.headRefName}\`.`
|
|
842
|
+
: "";
|
|
843
|
+
|
|
844
|
+
if (error._tag !== "GitHubCommandError") {
|
|
845
|
+
return rollbackNote === ""
|
|
846
|
+
? Effect.fail(error)
|
|
847
|
+
: Console.error(rollbackNote.trim()).pipe(Effect.andThen(Effect.fail(error)));
|
|
848
|
+
}
|
|
799
849
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
850
|
+
const stderr = error.stderr.toLowerCase();
|
|
851
|
+
|
|
852
|
+
if (stderr.includes("merge conflict") || stderr.includes("conflicts")) {
|
|
853
|
+
return Effect.fail(
|
|
854
|
+
new GitHubMergeError({
|
|
855
|
+
message: `PR #${opts.pr} has merge conflicts`,
|
|
856
|
+
reason: "conflicts",
|
|
857
|
+
hint: `Resolve merge conflicts locally, push the fix, then retry the merge.${rollbackNote}`,
|
|
858
|
+
nextCommand: `gh pr diff ${opts.pr}`,
|
|
859
|
+
}),
|
|
860
|
+
);
|
|
861
|
+
}
|
|
811
862
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
863
|
+
if (stderr.includes("required status check") || stderr.includes("checks")) {
|
|
864
|
+
return Effect.fail(
|
|
865
|
+
new GitHubMergeError({
|
|
866
|
+
message: `PR #${opts.pr} has failing required checks`,
|
|
867
|
+
reason: "checks_failing",
|
|
868
|
+
hint: `Wait for CI checks to pass or investigate failures before merging.${rollbackNote}`,
|
|
869
|
+
nextCommand: `agent-tools-gh pr checks --pr ${opts.pr}`,
|
|
870
|
+
retryable: true,
|
|
871
|
+
}),
|
|
872
|
+
);
|
|
873
|
+
}
|
|
821
874
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
875
|
+
if (stderr.includes("protected branch")) {
|
|
876
|
+
return Effect.fail(
|
|
877
|
+
new GitHubMergeError({
|
|
878
|
+
message: `PR #${opts.pr} targets a protected branch`,
|
|
879
|
+
reason: "branch_protected",
|
|
880
|
+
hint: `This branch has protection rules. Ensure required reviews and checks are satisfied, or ask a repo admin.${rollbackNote}`,
|
|
881
|
+
}),
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return Effect.fail(
|
|
886
|
+
new GitHubMergeError({
|
|
887
|
+
message: `Failed to merge PR #${opts.pr}: ${error.stderr}`,
|
|
888
|
+
reason: "unknown",
|
|
889
|
+
hint: `Check the PR state and branch protections. The PR may already be merged or closed.${rollbackNote}`,
|
|
890
|
+
nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
|
|
891
|
+
}),
|
|
892
|
+
);
|
|
893
|
+
},
|
|
894
|
+
),
|
|
895
|
+
),
|
|
896
|
+
),
|
|
831
897
|
);
|
|
832
898
|
|
|
833
899
|
const shaMatch = mergeResult.stdout.match(/([0-9a-f]{7,40})/);
|