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
82 changes: 82 additions & 0 deletions .github/workflows/codeturtle-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Self-review workflow for the CodeTurtle repo.
# For other repos, copy examples/github-action.yml instead.
# See docs/github-action.md
name: CodeTurtle review

on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
dry_run:
description: "Do not post to GitHub"
type: boolean
default: false
execute_tests:
description: "Opt-in pytest in the PR worktree"
type: boolean
default: false
python_version:
type: string
default: "3.12"

permissions:
contents: read
pull-requests: write

jobs:
review:
if: >
github.event_name != 'pull_request'
|| (
github.event.pull_request.draft != true
&& github.actor != 'dependabot[bot]'
)
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CODETURTLE_GITHUB_TOKEN: ${{ secrets.CODETURTLE_GITHUB_TOKEN }}
CODETURTLE_PR_DRAFT: ${{ github.event.pull_request.draft }}
GRAPHIFY_GRAPH_PATH: ${{ github.workspace }}/graphify-out/graph.json
CODETURTLE_CONFIG: ${{ github.workspace }}/.codeturtle.yaml
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0

- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python_version || '3.12' }}

- uses: astral-sh/setup-uv@v4

- name: Install
run: uv sync --frozen || uv sync

- name: Graphify (code-only)
continue-on-error: true
run: uv run graphify . --code-only || true

- name: Review PR
env:
GITHUB_ACTOR: ${{ github.actor }}
run: |
REPO="${{ github.repository }}"
PR="${{ github.event.pull_request.number }}"
if [ -z "$PR" ]; then
echo "No pull_request.number" >&2
exit 1
fi
DRY="${{ inputs.dry_run || false }}"
EX="${{ inputs.execute_tests || false }}"
ARGS=(uv run python -m cli.main review "$REPO" "$PR")
if [ "$DRY" = "true" ]; then
ARGS+=(--dry-run)
else
ARGS+=(--comment)
fi
if [ "$EX" = "true" ]; then
ARGS+=(--execute-tests)
fi
"${ARGS[@]}"
841 changes: 280 additions & 561 deletions README.md

Large diffs are not rendered by default.

121 changes: 114 additions & 7 deletions cli/commands/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
memory = MemoryManager()


class _SkipReview(Exception):
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason


@dataclass
class PipelineContext:
repo: str = ""
Expand All @@ -39,13 +45,14 @@ class PipelineContext:
execute_tests: bool = False
execute_install: bool = False
pr_head_sha: str = ""
config_path: str = ""
repo_cfg: Optional[object] = None


def get_current_session() -> str:
if not os.path.exists(".current_session"):
raise Exception("No active session found. Run: python -m cli.main new-session")
with open(".current_session", "r") as f:
return f.read().strip()
from core.ci import ensure_session

return ensure_session()


def _as_dict(obj: Any) -> dict:
Expand Down Expand Up @@ -92,12 +99,64 @@ def run(
execute_tests: bool = False,
execute_install: bool = False,
comment: bool = False,
config_path: str = "",
):
try:
from core.repo_config import (
RepoConfigError,
find_config_path,
load_repo_config,
merge_review_config,
)

self.context.repo = repo
self.context.number = number
self.context.execute_tests = bool(execute_tests)
self.context.execute_install = bool(execute_install)
self.context.config_path = config_path or ""

try:
cfg_path = find_config_path(config_path or None)
repo_yaml = load_repo_config(cfg_path)
except RepoConfigError as exc:
console.print(f"[red]{exc}[/red]")
raise SystemExit(1) from exc

cfg = merge_review_config(
repo=repo_yaml,
cli_execute_tests=bool(execute_tests),
cli_execute_install=bool(execute_install),
settings=settings,
config_path=cfg_path,
)
self.context.repo_cfg = cfg
self.context.execute_tests = bool(cfg.execute_tests)
self.context.execute_install = bool(cfg.execute_install)
if cfg.model:
settings.ollama_model = cfg.model
if cfg.llm_backend:
settings.llm_backend = cfg.llm_backend
if cfg_path:
print(f"[Review] config={cfg_path}")

from core.ci import should_skip_pr_review

if os.environ.get("GITHUB_ACTIONS"):
draft = (os.environ.get("CODETURTLE_PR_DRAFT") or "").strip().lower() in (
"1",
"true",
"yes",
)
review_drafts = (
os.environ.get("CODETURTLE_REVIEW_DRAFTS") or ""
).strip().lower() in ("1", "true", "yes")
skip, why = should_skip_pr_review(
actor=os.environ.get("GITHUB_ACTOR") or "",
draft=draft,
review_drafts=review_drafts,
)
if skip:
console.print(f"[dim][CI] skip reason={why}[/dim]")
return

self.context.conversation_id = get_current_session()

console.print(
Expand Down Expand Up @@ -127,6 +186,9 @@ def run(
if posted is False:
raise SystemExit(1)

except _SkipReview as skip:
console.print(f"[dim][Review] skip reason={skip.reason}[/dim]")
return
except SystemExit:
raise
except Exception as e:
Expand All @@ -139,14 +201,50 @@ def _load_knowledge_base(self):
print("[Review] Retrieval backend: Graphify only (Qdrant disabled)")

def _fetch_pr(self):
g = Github(settings.github_token)
from core.ci import resolve_github_token

token = resolve_github_token(fallback=str(settings.github_token or ""))
g = Github(token)
repo_obj = g.get_repo(self.context.repo)
self.context.pr = repo_obj.get_pull(self.context.number)
head = getattr(self.context.pr, "head", None)
self.context.pr_head_sha = str(getattr(head, "sha", "") or "")
from core.repo_config import review_skip_reason

cfg = self.context.repo_cfg
author = ""
try:
author = str(self.context.pr.user.login or "")
except Exception:
author = ""
draft = bool(getattr(self.context.pr, "draft", False))
why = review_skip_reason(author=author, draft=draft, cfg=cfg) if cfg else None
if why:
raise _SkipReview(why)

def _build_full_diff(self):
from core.ignore import is_ignored

files = list(self.context.pr.get_files())
patterns = []
cfg = self.context.repo_cfg
if cfg is not None:
patterns = list(getattr(cfg, "ignore_paths", None) or [])
if patterns:
kept = []
dropped = []
for f in files:
name = (f.filename or "").replace("\\", "/")
if is_ignored(name, patterns):
dropped.append(name)
else:
kept.append(f)
if dropped:
print(f"[Review] ignore_paths dropped={dropped[:12]}")
files = kept
if not files:
print("[Review] skip reason=all_files_ignored")
raise _SkipReview("all_files_ignored")
self.context.files_changed = [f.filename for f in files]
parts = []
for f in files:
Expand Down Expand Up @@ -231,6 +329,9 @@ def _create_review_state(self):
"traces": [],
"execute_tests": bool(self.context.execute_tests),
"execute_install": bool(self.context.execute_install),
"inline_max": int(getattr(self.context.repo_cfg, "inline_max", 8) or 8),
"inline_lockfile": bool(getattr(self.context.repo_cfg, "inline_lockfile", False)),
"ignore_paths": list(getattr(self.context.repo_cfg, "ignore_paths", None) or []),
"pr_head_sha": self.context.pr_head_sha or "",
"execute_timeout_s": int(getattr(settings, "execute_timeout_s", 120)),
"execute_max_files": int(getattr(settings, "execute_max_files", 8)),
Expand Down Expand Up @@ -572,6 +673,11 @@ def review(
"so tests can import. Implies network. No-op without --execute-tests."
),
),
config_path: Optional[str] = typer.Option(
None,
"--config",
help="Path to .codeturtle.yaml (else CODETURTLE_CONFIG or ./.codeturtle.yaml)",
),
):
logger.info("Starting review", repo=repo, pr_number=number)
ReviewPipeline().run(
Expand All @@ -582,5 +688,6 @@ def review(
execute_tests=execute_tests,
execute_install=execute_install,
comment=comment,
config_path=config_path or "",
)

90 changes: 90 additions & 0 deletions core/ci.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""CI helpers for GitHub Actions (6.3a). No webhook server."""

from __future__ import annotations

import os
from pathlib import Path
from typing import Mapping, Optional, Tuple

SESSION_FILE = ".current_session"
DEPENDABOT_ACTORS = frozenset(
{
"dependabot[bot]",
"dependabot-preview[bot]",
}
)


def resolve_github_token(
environ: Optional[Mapping[str, str]] = None,
fallback: str = "",
) -> str:
"""CODETURTLE_GITHUB_TOKEN, then GITHUB_TOKEN, then fallback/settings."""
env = environ if environ is not None else os.environ
for key in ("CODETURTLE_GITHUB_TOKEN", "GITHUB_TOKEN"):
v = (env.get(key) or "").strip()
if v:
return v
if fallback.strip():
return fallback.strip()
try:
from config import settings

return str(getattr(settings, "github_token", "") or "").strip()
except Exception:
return ""


def ensure_session(
path: str = SESSION_FILE,
*,
create=None,
) -> str:
"""Return existing session id, or create one (CI has no prior new-session)."""
p = Path(path)
if p.is_file():
sid = p.read_text(encoding="utf-8").strip()
if sid:
return sid
if create is None:
from core.memory.manager import MemoryManager

sid = MemoryManager().create_new_session()
else:
sid = str(create())
p.write_text(sid + "\n", encoding="utf-8")
return sid


def should_skip_pr_review(
*,
actor: str = "",
draft: bool = False,
review_drafts: bool = False,
) -> Tuple[bool, str]:
"""Default: skip Dependabot and drafts. No review posted."""
a = (actor or "").strip()
al = a.lower()
if a in DEPENDABOT_ACTORS or al.startswith("dependabot"):
return True, "dependabot"
if draft and not review_drafts:
return True, "draft"
return False, ""


def action_review_argv(
repo: str,
number: int,
*,
dry_run: bool = False,
execute_tests: bool = False,
) -> list[str]:
"""Default Action command: review --comment, never --execute-install."""
argv = ["python", "-m", "cli.main", "review", repo, str(number)]
if dry_run:
argv.append("--dry-run")
else:
argv.append("--comment")
if execute_tests:
argv.append("--execute-tests")
return argv
Loading
Loading