Skip to content
69 changes: 68 additions & 1 deletion scripts/github/pr_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import argparse
import json
import re
import subprocess
from pathlib import Path

Expand All @@ -28,6 +29,23 @@ def _run_stream(command: list[str], cwd: Path) -> int:
return result.returncode


def _run_stream_with_output(command: list[str], cwd: Path) -> tuple[int, str]:
result = subprocess.run(
command,
cwd=cwd,
capture_output=True,
text=True,
check=False,
)
output_parts = [
part.strip() for part in (result.stdout, result.stderr) if part.strip()
]
output = "\n".join(output_parts)
if output:
print(output)
return result.returncode, output


def _current_branch(repo_root: Path) -> str:
return _run_capture(["git", "rev-parse", "--abbrev-ref", "HEAD"], repo_root)

Expand Down Expand Up @@ -79,6 +97,41 @@ def _selector(pr_number: str, head: str) -> str:
return pr_number if pr_number else head


def _release_tag_from_head(head: str) -> str | None:
version = head.removesuffix("-dev")
if re.fullmatch(r"\d+\.\d+\.\d+", version):
return f"v{version}"
match = re.fullmatch(r"release/(?P<version>\d+\.\d+\.\d+)", head)
if match:
return f"v{match.group('version')}"
return None


def _is_workspace_release_repo(repo_root: Path) -> bool:
return (repo_root / ".github" / "workflows" / "release.yml").exists()


def _trigger_release_if_needed(repo_root: Path, head: str) -> None:
if not _is_workspace_release_repo(repo_root):
return
tag = _release_tag_from_head(head)
if tag is None:
return

if _run_stream(["gh", "release", "view", tag], repo_root) == 0:
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: gh release view existence check leaks verbose output to stdout. _run_stream does not capture output, so the full release details are printed to the terminal. Since this is only an existence check, the output should be suppressed to keep the structured key=value output consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/github/pr_manager.py, line 104:

<comment>`gh release view` existence check leaks verbose output to stdout. `_run_stream` does not capture output, so the full release details are printed to the terminal. Since this is only an existence check, the output should be suppressed to keep the structured `key=value` output consistent.</comment>

<file context>
@@ -79,6 +80,41 @@ def _selector(pr_number: str, head: str) -> str:
+    if tag is None:
+        return
+
+    if _run_stream(["gh", "release", "view", tag], repo_root) == 0:
+        print(f"status=release-exists tag={tag}")
+        return
</file context>
Fix with Cubic

print(f"status=release-exists tag={tag}")
return

run_code = _run_stream(
["gh", "workflow", "run", "release.yml", "-f", f"tag={tag}"],
repo_root,
)
if run_code == 0:
print(f"status=release-dispatched tag={tag}")
else:
print(f"status=release-dispatch-failed tag={tag} exit={run_code}")


def _create_pr(
repo_root: Path,
base: str,
Expand Down Expand Up @@ -118,9 +171,11 @@ def _create_pr(
def _merge_pr(
repo_root: Path,
selector: str,
head: str,
method: str,
auto: int,
delete_branch: int,
release_on_merge: int,
) -> int:
command = ["gh", "pr", "merge", selector]
merge_flag = {
Expand All @@ -133,9 +188,18 @@ def _merge_pr(
command.append("--auto")
if delete_branch == 1:
command.append("--delete-branch")
exit_code = _run_stream(command, repo_root)
exit_code, output = _run_stream_with_output(command, repo_root)
if exit_code != 0 and "not mergeable" in output:
update_code, _ = _run_stream_with_output(
["gh", "pr", "update-branch", selector, "--rebase"],
repo_root,
)
if update_code == 0:
exit_code, _ = _run_stream_with_output(command, repo_root)
if exit_code == 0:
print("status=merged")
if release_on_merge == 1:
_trigger_release_if_needed(repo_root, head)
return exit_code


Expand All @@ -157,6 +221,7 @@ def _parse_args() -> argparse.Namespace:
_ = parser.add_argument("--auto", type=int, default=0)
_ = parser.add_argument("--delete-branch", type=int, default=0)
_ = parser.add_argument("--checks-strict", type=int, default=0)
_ = parser.add_argument("--release-on-merge", type=int, default=1)
return parser.parse_args()


Expand Down Expand Up @@ -189,9 +254,11 @@ def main() -> int:
return _merge_pr(
repo_root,
selector,
head,
args.merge_method,
args.auto,
args.delete_branch,
args.release_on_merge,
)

if args.action == "close":
Expand Down
213 changes: 213 additions & 0 deletions scripts/github/pr_workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import subprocess
import time
from pathlib import Path
import sys


REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))

from libs.selection import resolve_projects
from libs.subprocess import run_capture, run_checked


def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
_ = parser.add_argument("--workspace-root", type=Path, default=Path("."))
_ = parser.add_argument("--project", action="append", default=[])
_ = parser.add_argument("--include-root", type=int, default=1)
_ = parser.add_argument("--branch", default="")
_ = parser.add_argument("--fail-fast", type=int, default=0)
_ = parser.add_argument("--checkpoint", type=int, default=1)
_ = parser.add_argument("--pr-action", default="status")
_ = parser.add_argument("--pr-base", default="main")
_ = parser.add_argument("--pr-head", default="")
_ = parser.add_argument("--pr-number", default="")
_ = parser.add_argument("--pr-title", default="")
_ = parser.add_argument("--pr-body", default="")
_ = parser.add_argument("--pr-draft", default="0")
_ = parser.add_argument("--pr-merge-method", default="squash")
_ = parser.add_argument("--pr-auto", default="0")
_ = parser.add_argument("--pr-delete-branch", default="0")
_ = parser.add_argument("--pr-checks-strict", default="0")
_ = parser.add_argument("--pr-release-on-merge", default="1")
return parser.parse_args()


def _repo_display_name(repo_root: Path, workspace_root: Path) -> str:
return workspace_root.name if repo_root == workspace_root else repo_root.name


def _has_changes(repo_root: Path) -> bool:
return bool(run_capture(["git", "status", "--porcelain"], cwd=repo_root).strip())


def _current_branch(repo_root: Path) -> str:
return run_capture(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_root)


def _checkout_branch(repo_root: Path, branch: str) -> None:
if not branch:
return
current = _current_branch(repo_root)
if current == branch:
return
checkout = subprocess.run(
["git", "checkout", branch],
cwd=repo_root,
check=False,
capture_output=True,
text=True,
)
if checkout.returncode == 0:
return
detail = (checkout.stderr or checkout.stdout).lower()
if "local changes" in detail or "would be overwritten" in detail:
run_checked(["git", "checkout", "-B", branch], cwd=repo_root)
return
fetch = subprocess.run(
["git", "fetch", "origin", branch],
cwd=repo_root,
check=False,
capture_output=True,
text=True,
)
if fetch.returncode == 0:
run_checked(
["git", "checkout", "-B", branch, f"origin/{branch}"], cwd=repo_root
)
else:
run_checked(["git", "checkout", "-B", branch], cwd=repo_root)


def _checkpoint(repo_root: Path, branch: str) -> None:
if not _has_changes(repo_root):
return
run_checked(["git", "add", "-A"], cwd=repo_root)
staged = run_capture(["git", "diff", "--cached", "--name-only"], cwd=repo_root)
if not staged.strip():
return
run_checked(
["git", "commit", "-m", "chore: checkpoint pending 0.11.0-dev changes"],
cwd=repo_root,
)
push_cmd = ["git", "push", "-u", "origin", branch] if branch else ["git", "push"]
push = subprocess.run(
push_cmd, cwd=repo_root, check=False, capture_output=True, text=True
)
if push.returncode == 0:
return
if branch:
run_checked(["git", "pull", "--rebase", "origin", branch], cwd=repo_root)
else:
run_checked(["git", "pull", "--rebase"], cwd=repo_root)
run_checked(push_cmd, cwd=repo_root)


def _run_pr(repo_root: Path, workspace_root: Path, args: argparse.Namespace) -> int:
report_dir = workspace_root / ".reports" / "workspace" / "pr"
report_dir.mkdir(parents=True, exist_ok=True)
display = _repo_display_name(repo_root, workspace_root)
log_path = report_dir / f"{display}.log"
if repo_root == workspace_root:
command = [
"python",
"scripts/github/pr_manager.py",
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The script path scripts/github/pr_manager.py is relative but the subprocess.run call has no cwd set. If the working directory differs from workspace_root (e.g., --workspace-root points elsewhere), the script won't be found. Use an absolute path derived from repo_root (which equals workspace_root in this branch).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/github/pr_workspace.py, line 120:

<comment>The script path `scripts/github/pr_manager.py` is relative but the `subprocess.run` call has no `cwd` set. If the working directory differs from `workspace_root` (e.g., `--workspace-root` points elsewhere), the script won't be found. Use an absolute path derived from `repo_root` (which equals `workspace_root` in this branch).</comment>

<file context>
@@ -114,28 +114,60 @@ def _run_pr(repo_root: Path, workspace_root: Path, args: argparse.Namespace) ->
+    if repo_root == workspace_root:
+        command = [
+            "python",
+            "scripts/github/pr_manager.py",
+            "--repo-root",
+            str(repo_root),
</file context>
Suggested change
"scripts/github/pr_manager.py",
str(repo_root / "scripts" / "github" / "pr_manager.py"),
Fix with Cubic

"--repo-root",
str(repo_root),
"--action",
args.pr_action,
"--base",
args.pr_base,
"--draft",
args.pr_draft,
"--merge-method",
args.pr_merge_method,
"--auto",
args.pr_auto,
"--delete-branch",
args.pr_delete_branch,
"--checks-strict",
args.pr_checks_strict,
"--release-on-merge",
args.pr_release_on_merge,
]
if args.pr_head:
command.extend(["--head", args.pr_head])
if args.pr_number:
command.extend(["--number", args.pr_number])
if args.pr_title:
command.extend(["--title", args.pr_title])
if args.pr_body:
command.extend(["--body", args.pr_body])
else:
command = [
"make",
"-C",
str(repo_root),
"pr",
f"PR_ACTION={args.pr_action}",
f"PR_BASE={args.pr_base}",
f"PR_DRAFT={args.pr_draft}",
f"PR_MERGE_METHOD={args.pr_merge_method}",
f"PR_AUTO={args.pr_auto}",
f"PR_DELETE_BRANCH={args.pr_delete_branch}",
f"PR_CHECKS_STRICT={args.pr_checks_strict}",
f"PR_RELEASE_ON_MERGE={args.pr_release_on_merge}",
]
if args.pr_head:
command.append(f"PR_HEAD={args.pr_head}")
if args.pr_number:
command.append(f"PR_NUMBER={args.pr_number}")
if args.pr_title:
command.append(f"PR_TITLE={args.pr_title}")
if args.pr_body:
command.append(f"PR_BODY={args.pr_body}")

started = time.monotonic()
with log_path.open("w", encoding="utf-8") as handle:
result = subprocess.run(
command, stdout=handle, stderr=subprocess.STDOUT, check=False
)
elapsed = int(time.monotonic() - started)
status = "OK" if result.returncode == 0 else "FAIL"
print(
f"[{status}] {display} pr ({elapsed}s) exit={result.returncode} log={log_path}"
)
return result.returncode


def main() -> int:
args = _parse_args()
workspace_root = args.workspace_root.resolve()
projects = resolve_projects(workspace_root, list(args.project))
repos = [project.path for project in projects]
if args.include_root == 1:
repos.append(workspace_root)

failures = 0
for repo_root in repos:
display = _repo_display_name(repo_root, workspace_root)
print(f"[RUN] {display}", flush=True)
_checkout_branch(repo_root, args.branch)
if args.checkpoint == 1:
_checkpoint(repo_root, args.branch)
exit_code = _run_pr(repo_root, workspace_root, args)
if exit_code != 0:
failures += 1
if args.fail_fast == 1:
break

total = len(repos)
success = total - failures
print(f"summary total={total} success={success} fail={failures} skip=0")
return 1 if failures else 0


if __name__ == "__main__":
raise SystemExit(main())
Loading