@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.59",
3
+ "version": "0.14.61",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -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("Delete branch after merge"),
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,
@@ -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
- dependentOpenPrs.length > 0
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 ? `Branch \`${info.headRefName}\` will be deleted. ` : "") +
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
- // Retarget dependents BEFORE merging so the head branch can be deleted safely.
751
- // If any retarget fails, keep the branch (fail-closed) so no dependent PR is closed.
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
- if (opts.deleteBranch && dependentOpenPrs.length > 0) {
757
- const repo = yield* gh.getRepoInfo();
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 mergeArgs = ["pr", "merge", String(opts.pr), `--${opts.strategy}`];
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
- if (willDeleteBranch) {
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.catchTag("GitHubCommandError", (error) => {
792
- const stderr = error.stderr.toLowerCase();
793
-
794
- if (stderr.includes("merge conflict") || stderr.includes("conflicts")) {
795
- return Effect.fail(
796
- new GitHubMergeError({
797
- message: `PR #${opts.pr} has merge conflicts`,
798
- reason: "conflicts",
799
- hint: "Resolve merge conflicts locally, push the fix, then retry the merge.",
800
- nextCommand: `gh pr diff ${opts.pr}`,
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
- if (stderr.includes("required status check") || stderr.includes("checks")) {
806
- return Effect.fail(
807
- new GitHubMergeError({
808
- message: `PR #${opts.pr} has failing required checks`,
809
- reason: "checks_failing",
810
- hint: "Wait for CI checks to pass or investigate failures before merging.",
811
- nextCommand: `agent-tools-gh pr checks --pr ${opts.pr}`,
812
- retryable: true,
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
- if (stderr.includes("protected branch")) {
818
- return Effect.fail(
819
- new GitHubMergeError({
820
- message: `PR #${opts.pr} targets a protected branch`,
821
- reason: "branch_protected",
822
- hint: "This branch has protection rules. Ensure required reviews and checks are satisfied, or ask a repo admin.",
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
- return Effect.fail(
828
- new GitHubMergeError({
829
- message: `Failed to merge PR #${opts.pr}: ${error.stderr}`,
830
- reason: "unknown",
831
- hint: "Check the PR state and branch protections. The PR may already be merged or closed.",
832
- nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
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,