@blogic-cz/agent-tools 0.14.59 → 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/commands.ts +3 -1
- package/src/gh-tool/pr/core.ts +141 -59
package/package.json
CHANGED
|
@@ -520,7 +520,9 @@ export const prMergeCommand = Command.make(
|
|
|
520
520
|
Flag.withDefault(false),
|
|
521
521
|
),
|
|
522
522
|
deleteBranch: Flag.boolean("delete-branch").pipe(
|
|
523
|
-
Flag.withDescription(
|
|
523
|
+
Flag.withDescription(
|
|
524
|
+
"Delete the remote branch after merge (local/worktree cleanup is separate)",
|
|
525
|
+
),
|
|
524
526
|
Flag.withDefault(DEFAULT_DELETE_BRANCH),
|
|
525
527
|
),
|
|
526
528
|
format: formatOption,
|
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,15 +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
769
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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) {
|
|
759
776
|
for (const child of dependentOpenPrs) {
|
|
760
777
|
const retargeted = yield* gh
|
|
761
778
|
.runGh([
|
|
@@ -781,62 +798,127 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
|
|
|
781
798
|
}
|
|
782
799
|
}
|
|
783
800
|
|
|
784
|
-
const
|
|
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
|
+
});
|
|
785
826
|
|
|
786
|
-
|
|
787
|
-
mergeArgs.push("--delete-branch");
|
|
788
|
-
}
|
|
827
|
+
const mergeArgs = ["pr", "merge", String(opts.pr), `--${opts.strategy}`];
|
|
789
828
|
|
|
790
829
|
const mergeResult = yield* gh.runGh(mergeArgs).pipe(
|
|
791
|
-
Effect.
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
+
}
|
|
804
849
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
+
}
|
|
816
862
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
+
}
|
|
826
874
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
+
),
|
|
836
897
|
);
|
|
837
898
|
|
|
838
899
|
const shaMatch = mergeResult.stdout.match(/([0-9a-f]{7,40})/);
|
|
839
900
|
|
|
901
|
+
// `gh pr merge --delete-branch` aborts its remote delete when the head branch is checked out in a
|
|
902
|
+
// worktree; deleting the remote ref explicitly is worktree-independent. Local cleanup is separate.
|
|
903
|
+
if (willDeleteBranch && repo && info.headRefName) {
|
|
904
|
+
const remoteDeleted = yield* gh
|
|
905
|
+
.runGh([
|
|
906
|
+
"api",
|
|
907
|
+
"--method",
|
|
908
|
+
"DELETE",
|
|
909
|
+
`repos/${repo.owner}/${repo.name}/git/refs/heads/${info.headRefName}`,
|
|
910
|
+
])
|
|
911
|
+
.pipe(
|
|
912
|
+
Effect.as(true),
|
|
913
|
+
Effect.orElseSucceed(() => false),
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
if (!remoteDeleted) {
|
|
917
|
+
willDeleteBranch = false;
|
|
918
|
+
branchDeleteSkipped = true;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
840
922
|
const result: MergeResult = {
|
|
841
923
|
merged: true,
|
|
842
924
|
strategy: opts.strategy,
|