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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

## [Unreleased]

## [1.2.2] - 2026-09-16

### Changed
- **BREAKING**: Codebase restructured to separate agents from workflow orchestration
- Review agents moved from `codespy.agents.reviewer.modules.*` to `codespy.agents.review.*` subpackages (audit, code_review, doc, scope, summary, supply_chain)
- Workflow orchestration moved from `codespy.agents.reviewer` to `codespy.workflows.review` (pipeline, reporters, server, workflow-owned models)
- Agent-owned types (`Issue`, `PRContext`, `ReviewContext`, `ReviewMetadata`, `IssueCategory`, `IssueSeverity`) moved to `codespy.agents.review.models`
- Scope-specific types (`ScopeResult`, `ScopeType`, `PackageManifest`) moved to `codespy.agents.review.scope.models`
- Workflow-owned types (`ReviewResult`, `ReviewConfig`, `RemoteReviewConfig`, `LocalReviewConfig`, `SignatureStatsResult`) moved to `codespy.workflows.review.models`
- `helpers.py` moved from `codespy.agents.reviewer.modules` to `codespy.agents.review`
- `doc_extractor.py` moved from `codespy.agents.reviewer.modules` to `codespy.agents.review.doc`
- `manifest_parser.py` moved from `codespy.agents.reviewer.modules` to `codespy.agents.review.scope`
- MCP server moved from `codespy.agents.reviewer.server` to `codespy.workflows.review.server`
- CLI modules (`cli_local`, `cli_remote`, `cli_mcp_server`) updated for new import paths
- All tests updated for new import paths

### Removed
- `codespy.agents.reviewer` package (replaced by `codespy.agents.review` + `codespy.workflows.review`)

## [1.2.1] - 2026-09-14

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "codespy-ai"
version = "1.2.1"
version = "1.2.2"
description = "Code review agent powered by DSPy"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/codespy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""codespy - Code review agent powered by DSPy."""

__version__ = "1.2.1"
__version__ = "1.2.2"
6 changes: 6 additions & 0 deletions src/codespy/agents/review/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from codespy.agents.review.audit import Auditor
from codespy.agents.review.code_review import CodeReviewer
from codespy.agents.review.doc import DocReviewer
from codespy.agents.review.scope import ScopeResolver
from codespy.agents.review.summary import Summarizer
from codespy.agents.review.supply_chain import SupplyChainAuditor
1 change: 1 addition & 0 deletions src/codespy/agents/review/audit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from codespy.agents.review.audit.agent import Auditor
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@
from codespy.agents import SignatureContext, get_cost_tracker
from codespy.agents.context_safe import ContextSafe
from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus
from codespy.agents.memory.hippocampus.context_memory import Topic
from codespy.agents.reviewer.models import Issue, ReviewContext
from codespy.agents.reviewer.modules.scope_resolver import _deepest_common_folder
from codespy.agents.review.models import Issue, ReviewContext
from codespy.agents.review.helpers import deepest_common_folder
from codespy.config import get_settings
from codespy.config_memory import get_episode_store

if TYPE_CHECKING:
from codespy.agents.reviewer.models import ScopeResult
from codespy.agents.review.scope.models import ScopeResult

logger = logging.getLogger(__name__)

Expand Down
1 change: 1 addition & 0 deletions src/codespy/agents/review/code_review/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from codespy.agents.review.code_review.agent import CodeReviewer
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
from codespy.agents.context_safe import ContextSafe
from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus
from codespy.agents.memory.hippocampus.episode import submit_episode_save
from codespy.agents.reviewer.models import Issue, IssueCategory, ReviewContext, ScopeResult
from codespy.agents.reviewer.modules.helpers import (
from codespy.agents.review.models import Issue, IssueCategory, ReviewContext
from codespy.agents.review.scope.models import ScopeResult
from codespy.agents.review.helpers import (
issues_to_markdown,
make_scope_relative,
resolve_scope_root,
Expand Down
1 change: 1 addition & 0 deletions src/codespy/agents/review/doc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from codespy.agents.review.doc.agent import DocReviewer
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
from codespy.agents.context_safe import ContextSafe
from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus
from codespy.agents.memory.hippocampus.episode import submit_episode_save
from codespy.agents.reviewer.models import Issue, IssueCategory, ReviewContext, ScopeResult
from codespy.agents.reviewer.modules.doc_extractor import extract_documentation
from codespy.agents.reviewer.modules.helpers import (
from codespy.agents.review.models import Issue, IssueCategory, ReviewContext
from codespy.agents.review.scope.models import ScopeResult
from codespy.agents.review.doc.doc_extractor import extract_documentation
from codespy.agents.review.helpers import (
build_patches,
issues_to_markdown,
make_scope_relative,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
from pathlib import Path
from typing import TYPE_CHECKING

from codespy.agents.reviewer.models import Issue
from codespy.agents.review.models import Issue
from codespy.tools.git.models import ChangedFile

if TYPE_CHECKING:
from codespy.agents.reviewer.models import ScopeResult
from codespy.agents.review.scope.models import ScopeResult, PackageManifest

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -102,7 +102,29 @@ def resolve_scope_root(repo_path: Path, subroot: str) -> Path:
return repo_path if subroot == "." else repo_path / subroot


def make_scope_relative(scope: ScopeResult) -> ScopeResult:
def deepest_common_folder(scopes: list["ScopeResult"], repo_slug: str) -> str:
"""Compute the deepest common ancestor directory across all scope subroots.

Args:
scopes: List of scope results
repo_slug: Repository slug for fallback path

Returns:
Deepest common ancestor path (e.g., "/repo/scope/subroot/")
"""
subroots = [s.subroot for s in scopes]
if not subroots or any(sr in (".", "") for sr in subroots):
return f"/{repo_slug}/"
try:
common = os.path.commonpath(subroots)
except ValueError:
common = ""
if not common or common == ".":
return f"/{repo_slug}/"
return f"/{repo_slug}/{common.strip('/')}/"


def make_scope_relative(scope: "ScopeResult") -> "ScopeResult":
"""Create a copy of a ScopeResult with file paths relative to scope.subroot.

When MCP tools are rooted at repo_path/scope.subroot, the agent needs file
Expand All @@ -116,7 +138,7 @@ def make_scope_relative(scope: ScopeResult) -> ScopeResult:
New ScopeResult with scope-relative file paths in changed_files.
The subroot is set to "." since paths are now relative to it.
"""
from codespy.agents.reviewer.models import PackageManifest, ScopeResult
from codespy.agents.review.scope.models import ScopeResult, PackageManifest

if scope.subroot == ".":
return scope # Already at repo root, no transformation needed
Expand Down Expand Up @@ -145,6 +167,7 @@ def make_scope_relative(scope: ScopeResult) -> ScopeResult:
),
package_manager=scope.package_manifest.package_manager,
dependencies_changed=scope.package_manifest.dependencies_changed,
package_name=scope.package_manifest.package_name,
)
return ScopeResult(
repo=scope.repo,
Expand All @@ -156,6 +179,7 @@ def make_scope_relative(scope: ScopeResult) -> ScopeResult:
changed_files=relative_files,
reason=scope.reason,
skills=scope.skills,
description=scope.description,
)


Expand Down
138 changes: 138 additions & 0 deletions src/codespy/agents/review/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Cross-cutting review types."""

from __future__ import annotations

from enum import StrEnum
from pathlib import Path
from typing import TYPE_CHECKING

from pydantic import BaseModel, Field

from codespy.agents.memory.hippocampus import ContextMemory
from codespy.tools.git.models import PullRequest

if TYPE_CHECKING:
from codespy.agents.memory.hippocampus.context_memory import Topic


class IssueSeverity(StrEnum):
"""Severity level of an issue."""

CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
INFO = "info"


class IssueCategory(StrEnum):
"""Category of an issue."""

SECURITY = "security"
BUG = "bug"
DOCUMENTATION = "documentation"
SMELL = "smell"


class PRContext(BaseModel):
"""Shared PR identity passed to all review modules after summarization.

Built by the pipeline orchestrator after the Summarizer runs, then
threaded through scope identification, review modules, and audit.
Each module constructs its own Hippocampus question from these fields.
"""

repo_slug: str = Field(
description="Host-qualified repo identifier (e.g. github.com/owner/repo)"
)
pr_number: int = Field(description="PR number")
pr_title: str = Field(description="PR title")
pr_url: str = Field(description="Full PR URL (e.g. https://github.com/owner/repo/pull/123)")
pr_description: str = Field(default="", description="PR body/description")
summary: str = Field(description="2-3 sentence PR summary produced by Summarizer")

@property
def repo_full_name(self) -> str:
"""Get owner/repo from host-qualified repo_slug."""
# repo_slug is "github.com/owner/repo" or just "owner/repo"
parts = self.repo_slug.split("/")
if len(parts) >= 3:
return "/".join(parts[1:]) # strip host
return self.repo_slug

def to_topic(self) -> "Topic":
"""Build a Topic representing this PR.

Returns:
Topic object with id as PR URL, type as "pull_request", and description as "PR #N: Title"
"""
from codespy.agents.memory.hippocampus.context_memory import Topic
return Topic(
id=self.pr_url,
type="pull_request",
description=f"PR #{self.pr_number}: {self.pr_title}"[:500],
)


class ReviewMetadata(BaseModel):
"""Runtime pipeline state, stable once constructed at pipeline start.

Groups repo_path, run_id, pr, and is_local to reduce parameter
proliferation across module method signatures.
"""

repo_path: Path
run_id: str | None = None
pr: PullRequest | None = None
is_local: bool = False


class ReviewContext(BaseModel):
"""Evolving pipeline state threaded through review stages.

Carries the immutable PR identity and runtime pipeline metadata.
Context memory is loaded independently by each module from its own prior episodes.
"""

pr_context: PRContext = Field(
description="Immutable PR identity (repo, number, title, summary)"
)
memory: ContextMemory | None = Field(
default=None, description="Unused — each module loads its own prior episodes. Kept for API compatibility."
)
metadata: ReviewMetadata | None = Field(default=None, description="Runtime pipeline state")


class Issue(BaseModel):
"""Represents a single issue found during review."""

category: IssueCategory = Field(description="Issue category")
severity: IssueSeverity = Field(
default=IssueSeverity.MEDIUM,
description="Issue severity: critical, high, medium, low, or info",
)
title: str = Field(description="Brief title of the issue")
description: str = Field(description="≤25 word imperative description. No filler.")
filename: str = Field(description="File where the issue was found")
line_start: int | None = Field(default=None, description="Starting line number")
line_end: int | None = Field(default=None, description="Ending line number")
code_snippet: str | None = Field(
default=None, description="Deprecated—use line numbers. Leave None."
)
suggestion: str | None = Field(default=None, description="Suggested fix or improvement")
cwe_id: str | None = Field(
default=None, description="CWE ID for security issues (e.g., CWE-79)"
)
confidence: float = Field(default=0.8, ge=0.0, le=1.0, description="Confidence score (0-1)")

@property
def location(self) -> str:
"""Get a human-readable location string."""
if self.line_start:
if self.line_end and self.line_end != self.line_start:
return f"{self.filename}:{self.line_start}-{self.line_end}"
return f"{self.filename}:{self.line_start}"
return self.filename



2 changes: 2 additions & 0 deletions src/codespy/agents/review/scope/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from codespy.agents.review.scope.agent import MANIFEST_FILES, MANIFEST_GLOBS, ScopeResolver, derive_sparse_paths
from codespy.agents.review.scope.models import PackageManifest, ScopeResult, ScopeType
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,9 @@
from codespy.agents.context_safe import ContextSafe
from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus
from codespy.agents.memory.hippocampus.episode import submit_episode_save
from codespy.agents.reviewer.models import (
PackageManifest,
ReviewContext,
ScopeResult,
ScopeType,
)
from codespy.agents.reviewer.modules.manifest_parser import extract_package_name
from codespy.agents.review.models import ReviewContext
from codespy.agents.review.scope.manifest_parser import extract_package_name
from codespy.agents.review.scope.models import PackageManifest, ScopeResult, ScopeType
from codespy.config import get_settings
from codespy.config_memory import get_episode_store
from codespy.tools.git.client import get_client
Expand Down Expand Up @@ -87,28 +83,6 @@ def collect_skills(repo_path: Path, subroot: str) -> str | None:
return "\n\n".join(sections) if sections else None


def _deepest_common_folder(scopes: list[ScopeResult], repo_slug: str) -> str:
"""Compute the deepest common ancestor directory across all scope subroots.

Args:
scopes: List of scope results
repo_slug: Repository slug for fallback path

Returns:
Deepest common ancestor path (e.g., "/repo/scope/subroot/")
"""
subroots = [s.subroot for s in scopes]
if not subroots or any(sr in (".", "") for sr in subroots):
return f"/{repo_slug}/"
try:
common = os.path.commonpath(subroots)
except ValueError:
common = ""
if not common or common == ".":
return f"/{repo_slug}/"
return f"/{repo_slug}/{common.strip('/')}/"


# Exact filename matches -> package manager
MANIFEST_FILES: dict[str, str] = {
# Go
Expand Down Expand Up @@ -556,7 +530,7 @@ async def _ensure_repo(self, pr: PullRequest, repo_path: Path, is_local: bool) -
)
logger.info("Clone complete: %s", repo_path)

# Ensure manifest files are present at root and parent directories
# Ensure manifest files at root and parent directories are checked out
await self._ensure_manifests(repo_path, changed_file_paths)

async def _ensure_manifests(self, repo_path: Path, changed_files: list[str]) -> None:
Expand Down
Loading
Loading