Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
49 changes: 12 additions & 37 deletions scripts/core/skill_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
import time
from pathlib import Path

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

from libs.discovery import discover_projects as ssot_discover_projects
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.

P0: Import from non-existent module libs.discovery — the module does not exist anywhere in the repository. This will cause a ModuleNotFoundError at import time, making the entire script unusable. The old inline discover_projects implementation was removed, so there is no fallback. Ensure libs/discovery.py (with a discover_projects function returning objects with .name and .kind attributes) is added to the repository, or revert to the previous inline implementation.

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

<comment>Import from non-existent module `libs.discovery` — the module does not exist anywhere in the repository. This will cause a `ModuleNotFoundError` at import time, making the entire script unusable. The old inline `discover_projects` implementation was removed, so there is no fallback. Ensure `libs/discovery.py` (with a `discover_projects` function returning objects with `.name` and `.kind` attributes) is added to the repository, or revert to the previous inline implementation.</comment>

<file context>
@@ -12,6 +12,11 @@
+if str(Path(__file__).resolve().parents[2]) not in sys.path:
+    sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+from libs.discovery import discover_projects as ssot_discover_projects
+
 try:
</file context>
Fix with Cubic


try:
import yaml # type: ignore[import-untyped]
except ImportError:
Expand Down Expand Up @@ -198,43 +203,13 @@ def discover_projects(root: Path) -> dict[str, object]:
"root": "."}

"""
gitmodules = root / ".gitmodules"
gitmodules_text = ""
if gitmodules.exists():
try:
gitmodules_text = gitmodules.read_text(encoding="utf-8")
except OSError as exc:
msg = f"Cannot read {gitmodules}"
raise SkillInfraError(msg) from exc

flext_projects: list[str] = []
for line in gitmodules_text.splitlines():
stripped = line.strip()
if "path = flext-" not in stripped:
continue
name = stripped.split("path = ", 1)[-1].strip()
if not name:
continue
if (root / name / "pyproject.toml").is_file():
flext_projects.append(name)

external_projects: list[str] = []
for child in sorted(root.iterdir(), key=lambda p: p.name):
if not child.is_dir():
continue
name = child.name
pyproject = child / "pyproject.toml"
if not pyproject.is_file():
continue
if f"path = {name}" in gitmodules_text:
continue
try:
pyproject_text = pyproject.read_text(encoding="utf-8")
except OSError as exc:
msg = f"Cannot read {pyproject}"
raise SkillInfraError(msg) from exc
if "flext-core" in pyproject_text or "flext_core" in pyproject_text:
external_projects.append(name)
discovered = ssot_discover_projects(root)
flext_projects = [
project.name for project in discovered if project.kind == "submodule"
]
external_projects = [
project.name for project in discovered if project.kind == "external"
]

return {
"flext": unique_sorted(flext_projects),
Expand Down
18 changes: 11 additions & 7 deletions scripts/core/stub_supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
from dataclasses import dataclass
from pathlib import Path

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

from libs.selection import resolve_projects

MISSING_IMPORT_RE = re.compile(r"Cannot find module `([^`]+)` \[missing-import\]")
MYPY_HINT_RE = re.compile(r'note: Hint: "python3 -m pip install ([^"]+)"')
MYPY_STUB_RE = re.compile(r'Library stubs not installed for "([^"]+)"')
Expand Down Expand Up @@ -65,13 +70,12 @@ def run_cmd(


def discover_projects(root: Path) -> list[Path]:
projects: list[Path] = []
for entry in sorted(root.iterdir()):
if not entry.is_dir():
continue
if (entry / "pyproject.toml").exists() and (entry / "src").is_dir():
projects.append(entry)
return projects
return [
project.path
for project in resolve_projects(root, names=[])
if (project.path / "pyproject.toml").exists()
and (project.path / "src").is_dir()
]


def load_pyproject(project_dir: Path) -> dict[str, object]:
Expand Down
39 changes: 20 additions & 19 deletions scripts/dependencies/dependency_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@
from __future__ import annotations

import json
import os
import re
import subprocess
import sys
import tomllib
from pathlib import Path
from typing import Any

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

from libs.selection import resolve_projects
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.

P0: Module libs.selection does not exist in the repository. This top-level import will raise ModuleNotFoundError at runtime, making the entire dependency_detection module unimportable. If this module is expected to come from a separate PR or workspace propagation step, consider gating the import with a try/except or ensuring it's added atomically with this change.

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

<comment>Module `libs.selection` does not exist in the repository. This top-level import will raise `ModuleNotFoundError` at runtime, making the entire `dependency_detection` module unimportable. If this module is expected to come from a separate PR or workspace propagation step, consider gating the import with a try/except or ensuring it's added atomically with this change.</comment>

<file context>
@@ -12,12 +12,19 @@
+if str(Path(__file__).resolve().parents[2]) not in sys.path:
+    sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+from libs.selection import resolve_projects
+
 # Mypy output patterns for typing library detection (aligned with stub_supply_chain)
</file context>
Fix with Cubic


# Mypy output patterns for typing library detection (aligned with stub_supply_chain)
MYPY_HINT_RE = re.compile(r'note: Hint: "python3 -m pip install ([^"]+)"')
MYPY_STUB_RE = re.compile(r'Library stubs not installed for "([^"]+)"')
Expand Down Expand Up @@ -62,22 +69,16 @@ def discover_projects(
workspace_root: Path,
projects_filter: list[str] | None = None,
) -> list[Path]:
"""Discover all Python projects under workspace (top-level dirs with pyproject.toml).

Matches root Makefile / sync_dependencies: any dir with pyproject.toml, excluding SKIP_DIRS.
"""
projects: list[Path] = []
for item in sorted(workspace_root.iterdir()):
if not item.is_dir():
continue
if any(skip in item.name for skip in SKIP_DIRS):
continue
if not (item / "pyproject.toml").exists():
continue
if projects_filter is not None and item.name not in projects_filter:
continue
projects.append(item)
return projects
projects = [
project.path
for project in resolve_projects(workspace_root, names=[])
if (project.path / "pyproject.toml").exists()
and not any(skip in project.name for skip in SKIP_DIRS)
]
if projects_filter is not None:
filter_set = set(projects_filter)
projects = [path for path in projects if path.name in filter_set]
return sorted(projects)


def run_deptry(
Expand Down Expand Up @@ -146,7 +147,7 @@ def run_pip_check(workspace_root: Path, venv_bin: Path) -> tuple[list[str], int]
capture_output=True,
text=True,
timeout=60,
env={**subprocess.os.environ, "VIRTUAL_ENV": str(venv_bin.parent)},
env={**os.environ, "VIRTUAL_ENV": str(venv_bin.parent)},
)
out = (result.stdout or "").strip().splitlines() if result.stdout else []
return out, result.returncode
Expand Down Expand Up @@ -219,9 +220,9 @@ def run_mypy_stub_hints(
"--no-error-summary",
]
env = {
**subprocess.os.environ,
**os.environ,
"VIRTUAL_ENV": str(venv_bin.parent),
"PATH": f"{venv_bin}:{subprocess.os.environ.get('PATH', '')}",
"PATH": f"{venv_bin}:{os.environ.get('PATH', '')}",
}
result = subprocess.run(
cmd,
Expand Down
204 changes: 204 additions & 0 deletions scripts/github/pr_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import subprocess
from pathlib import Path


def _run_capture(command: list[str], cwd: Path) -> str:
result = subprocess.run(
command,
cwd=cwd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise RuntimeError(
f"command failed ({result.returncode}): {' '.join(command)}: {detail}"
)
return result.stdout.strip()


def _run_stream(command: list[str], cwd: Path) -> int:
result = subprocess.run(command, cwd=cwd, check=False)
return result.returncode


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


def _open_pr_for_head(repo_root: Path, head: str) -> dict[str, object] | None:
raw = _run_capture(
[
"gh",
"pr",
"list",
"--state",
"open",
"--head",
head,
"--json",
"number,title,state,baseRefName,headRefName,url,isDraft",
"--limit",
"1",
],
repo_root,
)
payload = json.loads(raw)
if not payload:
return None
first = payload[0]
if not isinstance(first, dict):
return None
return first


def _print_status(repo_root: Path, base: str, head: str) -> int:
pr = _open_pr_for_head(repo_root, head)
print(f"repo={repo_root}")
print(f"base={base}")
print(f"head={head}")
if pr is None:
print("status=no-open-pr")
return 0
print("status=open")
print(f"pr_number={pr.get('number')}")
print(f"pr_title={pr.get('title')}")
print(f"pr_url={pr.get('url')}")
print(f"pr_state={pr.get('state')}")
print(f"pr_draft={pr.get('isDraft')}")
return 0


def _selector(pr_number: str, head: str) -> str:
return pr_number if pr_number else head


def _create_pr(
repo_root: Path,
base: str,
head: str,
title: str,
body: str,
draft: int,
) -> int:
existing = _open_pr_for_head(repo_root, head)
if existing is not None:
print(f"status=already-open")
print(f"pr_url={existing.get('url')}")
return 0

command = [
"gh",
"pr",
"create",
"--base",
base,
"--head",
head,
"--title",
title,
"--body",
body,
]
if draft == 1:
command.append("--draft")

created = _run_capture(command, repo_root)
print("status=created")
print(f"pr_url={created}")
return 0


def _merge_pr(
repo_root: Path,
selector: str,
method: str,
auto: int,
delete_branch: int,
) -> int:
command = ["gh", "pr", "merge", selector]
merge_flag = {
"merge": "--merge",
"rebase": "--rebase",
"squash": "--squash",
}.get(method, "--squash")
command.append(merge_flag)
if auto == 1:
command.append("--auto")
if delete_branch == 1:
command.append("--delete-branch")
exit_code = _run_stream(command, repo_root)
if exit_code == 0:
print("status=merged")
return exit_code


def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
_ = parser.add_argument("--repo-root", type=Path, default=Path("."))
_ = parser.add_argument(
"--action",
default="status",
choices=["status", "create", "view", "checks", "merge", "close"],
)
_ = parser.add_argument("--base", default="main")
_ = parser.add_argument("--head", default="")
_ = parser.add_argument("--number", default="")
_ = parser.add_argument("--title", default="")
_ = parser.add_argument("--body", default="")
_ = parser.add_argument("--draft", type=int, default=0)
_ = parser.add_argument("--merge-method", default="squash")
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: The --merge-method argument lacks a choices constraint, so a typo (e.g., reabse) silently defaults to --squash via the .get() fallback in _merge_pr. Add choices=["merge", "rebase", "squash"] to catch invalid values at argument-parsing time, consistent with how --action is handled.

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 156:

<comment>The `--merge-method` argument lacks a `choices` constraint, so a typo (e.g., `reabse`) silently defaults to `--squash` via the `.get()` fallback in `_merge_pr`. Add `choices=["merge", "rebase", "squash"]` to catch invalid values at argument-parsing time, consistent with how `--action` is handled.</comment>

<file context>
@@ -0,0 +1,199 @@
+    _ = parser.add_argument("--title", default="")
+    _ = parser.add_argument("--body", default="")
+    _ = parser.add_argument("--draft", type=int, default=0)
+    _ = parser.add_argument("--merge-method", default="squash")
+    _ = parser.add_argument("--auto", type=int, default=0)
+    _ = parser.add_argument("--delete-branch", type=int, default=0)
</file context>
Fix with Cubic

_ = 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)
return parser.parse_args()


def main() -> int:
args = _parse_args()
repo_root = args.repo_root.resolve()
head = args.head or _current_branch(repo_root)
base = args.base
selector = _selector(args.number, head)

if args.action == "status":
return _print_status(repo_root, base, head)

if args.action == "create":
title = args.title or f"chore: sync {head}"
body = args.body or "Automated PR managed by scripts/github/pr_manager.py"
return _create_pr(repo_root, base, head, title, body, args.draft)

if args.action == "view":
return _run_stream(["gh", "pr", "view", selector], repo_root)

if args.action == "checks":
exit_code = _run_stream(["gh", "pr", "checks", selector], repo_root)
if exit_code != 0 and args.checks_strict == 0:
print("status=checks-nonblocking")
return 0
return exit_code

if args.action == "merge":
return _merge_pr(
repo_root,
selector,
args.merge_method,
args.auto,
args.delete_branch,
)

if args.action == "close":
return _run_stream(["gh", "pr", "close", selector], repo_root)

raise RuntimeError(f"unknown action: {args.action}")


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