@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.60",
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",
@@ -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 ? `Remote 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,14 +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
- const repo = opts.deleteBranch ? yield* gh.getRepoInfo() : null;
756
769
 
757
- if (opts.deleteBranch && dependentOpenPrs.length > 0 && repo) {
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.catchTag("GitHubCommandError", (error) => {
787
- const stderr = error.stderr.toLowerCase();
788
-
789
- if (stderr.includes("merge conflict") || stderr.includes("conflicts")) {
790
- return Effect.fail(
791
- new GitHubMergeError({
792
- message: `PR #${opts.pr} has merge conflicts`,
793
- reason: "conflicts",
794
- hint: "Resolve merge conflicts locally, push the fix, then retry the merge.",
795
- nextCommand: `gh pr diff ${opts.pr}`,
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
- if (stderr.includes("required status check") || stderr.includes("checks")) {
801
- return Effect.fail(
802
- new GitHubMergeError({
803
- message: `PR #${opts.pr} has failing required checks`,
804
- reason: "checks_failing",
805
- hint: "Wait for CI checks to pass or investigate failures before merging.",
806
- nextCommand: `agent-tools-gh pr checks --pr ${opts.pr}`,
807
- retryable: true,
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
- if (stderr.includes("protected branch")) {
813
- return Effect.fail(
814
- new GitHubMergeError({
815
- message: `PR #${opts.pr} targets a protected branch`,
816
- reason: "branch_protected",
817
- hint: "This branch has protection rules. Ensure required reviews and checks are satisfied, or ask a repo admin.",
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
- return Effect.fail(
823
- new GitHubMergeError({
824
- message: `Failed to merge PR #${opts.pr}: ${error.stderr}`,
825
- reason: "unknown",
826
- hint: "Check the PR state and branch protections. The PR may already be merged or closed.",
827
- nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
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})/);