Files
Aperant/apps/backend/runners/github/models.py
T
TamerineSky 6a6247bbf2 Fix Windows UTF-8 encoding errors across entire backend (251 instances) (#782)
* Fix UTF-8 encoding for Priorities 1-2 (Core & Agents - 18 instances)

Add encoding="utf-8" to file operations in:
- Priority 1: Core Infrastructure (8 instances)
  - core/progress.py (6 read operations)
  - core/debug.py (1 append operation)
  - core/workspace/setup.py (1 read operation)

- Priority 2: Agent System (10 instances)
  - agents/utils.py (1 read)
  - agents/tools_pkg/tools/subtask.py (1 read, 1 write)
  - agents/tools_pkg/tools/memory.py (2 read, 1 write, 1 append)
  - agents/tools_pkg/tools/qa.py (1 read, 1 write)
  - agents/tools_pkg/tools/progress.py (1 read)

All changes use double quotes for ruff format compliance.

* Fix UTF-8 encoding for Priorities 3-4 (Spec & Project - 26 instances)

Add encoding="utf-8" to file operations in:
- Priority 3: Spec Pipeline (21 instances)
  - spec/context.py (4: 2 read, 2 write)
  - spec/complexity.py (3: 2 read, 1 write)
  - spec/requirements.py (3: 2 read, 1 write)
  - spec/validator.py (3 write operations)
  - spec/writer.py (2: 1 read, 1 write)
  - spec/discovery.py (1 read)
  - spec/pipeline/orchestrator.py (2 read)
  - spec/phases/requirements_phases.py (1 write)
  - spec/validate_pkg/auto_fix.py (2: 1 read, 1 write)

- Priority 4: Project Analyzer (5 instances)
  - project/analyzer.py (2: 1 read, 1 write)
  - project/config_parser.py (2 read operations)
  - project/stack_detector.py (1 read)

All changes use double quotes for ruff format compliance.

* Fix UTF-8 encoding for Priorities 5-7 (Services, Analysis, Ideation - 43 instances)

Add encoding="utf-8" to file operations in:
- Priority 5: Services (12 instances)
  - services/recovery.py (8: 4 read, 4 write)
  - services/context.py (4 read operations)

- Priority 6: Analysis & QA (6 instances)
  - analysis/analyzers/__init__.py (2 write)
  - analysis/insight_extractor.py (1 read)
  - qa/criteria.py (2: 1 read, 1 write)
  - qa/report.py (1 read)

- Priority 7: Ideation & Roadmap (25 instances)
  - ideation/analyzer.py (3 read)
  - ideation/formatter.py (4 read, 1 write)
  - ideation/phase_executor.py (5: 3 read, 2 write)
  - ideation/runner.py (1 read)
  - runners/roadmap/competitor_analyzer.py (3: 1 read, 2 write)
  - runners/roadmap/graph_integration.py (3 write)
  - runners/roadmap/orchestrator.py (1 read)
  - runners/roadmap/phases.py (2 read)
  - runners/insights_runner.py (3 read)

All changes use double quotes for ruff format compliance.

* Fix UTF-8 encoding for Priorities 8-14 (All remaining - 85+ instances)

Add encoding="utf-8" to file operations across all remaining modules:

Priorities 8-10 (Merge, Memory, Integrations - 26 instances):
- merge/ (4 files)
- memory/ (3 files)
- context/ (3 files)
- integrations/ (4 files)

Priorities 11-14 (GitHub, GitLab, AI, Other - 59 instances):
- runners/github/ (19 files)
- runners/gitlab/ (3 files)
- runners/ai_analyzer/ (1 file)

All changes use double quotes for ruff format compliance.
Applied using Python regex script for efficiency.

* Fix UTF-8 encoding for missed instances (23 instances)

Fix remaining instances missed by batch script:
- cli/batch_commands.py (3 instances)
- cli/followup_commands.py (1 instance)
- core/client.py (1 instance)
- phase_config.py (1 instance)
- planner_lib/context.py (4 instances)
- prediction/main.py (1 instance)
- prediction/memory_loader.py (1 instance)
- prompts_pkg/prompts.py (2 instances)
- review/formatters.py (1 instance)
- review/state.py (2 instances)
- spec/phases/spec_phases.py (1 instance)
- spec/pipeline/models.py (1 instance)
- spec/validate_pkg/validators/context_validator.py (1 instance)
- spec/validate_pkg/validators/implementation_plan_validator.py (1 instance)
- ui/status.py (2 instances)

All encoding parameters use double quotes for ruff format compliance.
Verified: 0 instances without encoding remain in source code.

* Fix missed os.fdopen() calls and duplicate encoding bug

Thorough verification found 3 additional issues:
- runners/github/file_lock.py:462 - os.fdopen missing encoding
- runners/github/trust.py:442 - os.fdopen missing encoding
- runners/insights_runner.py:372 - duplicate encoding parameter

All fixed. Final count: 251 instances with encoding="utf-8"

* Fix missed Path.read_text() and Path.write_text() encoding (99 instances)

Gemini Code Assist review found instances we missed:
- Path.read_text() without encoding: 77 instances → fixed
- Path.write_text() without encoding: 22 instances → fixed

Total UTF-8 encoding fixes: 350 instances across codebase
- open() operations: 251 instances
- Path.read_text(): 98 instances
- Path.write_text(): 30 instances

All text file operations now explicitly use encoding="utf-8".

Addresses feedback from PR #782 review.

* Fix critical syntax errors from CodeRabbit review

- Fix os.getpid() syntax error in core/workspace/models.py (2 instances)
  Changed: os.getpid(, encoding="utf-8") -> str(os.getpid())

- Fix json.dumps invalid encoding parameter (3 instances)
  json.dumps() doesn't accept encoding parameter
  Changed: json.dumps(data, encoding="utf-8") -> json.dumps(data)
  Files: runners/ai_analyzer/cache_manager.py, runners/github/test_file_lock.py

- Fix tempfile.NamedTemporaryFile missing encoding
  Added encoding="utf-8" to spec/requirements.py:22

- Fix subprocess.run text=True to encoding
  Changed: text=True -> encoding="utf-8" in core/workspace/setup.py:375

All critical syntax errors from CodeRabbit review resolved.

* Fix critical syntax errors in test_context_gatherer.py

- Line 78: Move encoding="utf-8" outside of JS string content
  Changed: write_text("...encoding="utf-8"...")
  To: write_text("...", encoding="utf-8")

- Line 102: Move encoding="utf-8" outside of JS string content
  Changed: write_text("...encoding="utf-8"...")
  To: write_text("...", encoding="utf-8")

Fixes syntax errors where encoding parameter was incorrectly placed
inside the JavaScript code string instead of as write_text() parameter.

* Fix CodeRabbit issues: UnicodeDecodeError handling and trailing newlines

- Add UnicodeDecodeError to exception handling in agents/utils.py and spec/validate_pkg/auto_fix.py
- Fix trailing newline preservation in merge/file_merger.py (2 locations)
- Add encoding parameter to atomic_write() in runners/github/file_lock.py

These fixes ensure robust error handling for malformed UTF-8 files
and preserve file formatting during merge operations.

* Fix test fixture to use UTF-8 encoding consistently

Update spec_file fixture in tests/conftest.py to write spec file
with encoding="utf-8" to match how it's read in validators.

This ensures consistency between test fixtures and production code.

* Fix linting errors and security vulnerabilities from merge

- Remove unused tree-sitter methods in semantic_analyzer.py that caused F821 undefined name errors
- Fix regex injection vulnerability in bump-version.js by properly escaping all regex special characters
- Add escapeRegex() function to prevent security issues when version string is used in RegExp constructor

Resolves ruff linting failures and CodeQL security alerts.

* Fix code formatting for ruff compliance

Apply formatting fixes to meet line length requirements:
- context/builder.py: Split long line with array slicing
- planner_lib/context.py: Split long ternary expression
- spec/requirements.py: Split long tempfile.NamedTemporaryFile call

Resolves ruff format check failures.

* Fix missing UTF-8 encoding in init.py gitignore operations

Found by pre-commit hook testing in PR #795:
- Line 96: Path.read_text() without encoding
- Line 122: Path.write_text() without encoding

These handle .gitignore file operations and could fail on Windows
with special characters in gitignore comments or entries.

Total fixes in PR #782: 253 instances (was 251, +2 from init.py)

* Add pre-commit hook for UTF-8 encoding enforcement

1. Encoding Check Script (scripts/check_encoding.py):
   - Validates all file operations have encoding="utf-8"
   - Checks open(), Path.read_text(), Path.write_text()
   - Checks json.load/dump with open()
   - Allows binary mode without encoding
   - Windows-compatible emoji output with UTF-8 reconfiguration

2. Pre-commit Config (.pre-commit-config.yaml):
   - Added check-file-encoding hook for apps/backend/
   - Runs automatically before commits
   - Scoped to backend Python files only

3. Tests (tests/test_check_encoding.py):
   - Comprehensive test coverage (10 tests, all passing)
   - Tests detection of missing encoding
   - Tests allowlist for binary files
   - Tests multiple issues in single file
   - Tests file type filtering

Purpose:
- Prevent regression of 251 UTF-8 encoding fixes from PR #782
- Catch missing encoding in new code during development
- Fast feedback loop for developers

Implementation Notes:
- Hook scoped to apps/backend/ to avoid false positives in test code
- Uses simple regex matching for speed
- Compatible with existing pre-commit infrastructure
- Already caught 6 real issues in apps/backend/core/progress.py

Related: PR #782 - Fix Windows UTF-8 encoding errors

* Address CodeRabbit and Gemini review feedback

Fixes based on automated review comments:

1. Binary Mode Detection (Critical Fix):
   - Replaced brittle regex with robust pattern: r'["'][rwax+]*b[rwax+]*["']'
   - Now correctly detects all binary modes: rb, wb, ab, r+b, w+b, etc.
   - Prevents false positives on text mode 'w' without 'b'
   - Added comprehensive tests for wb, ab, and text w modes

2. Encoding Detection Robustness (Critical Fix):
   - Changed from 'encoding=' string match to word boundary regex: r'\bencoding\s*='
   - Now handles encoding with spaces: encoding = "utf-8"
   - Prevents false matches of substrings containing 'encoding='
   - Applied across all checks (open, read_text, write_text, json.load, json.dump)
   - Added test for spaces around equals sign

3. Test Coverage Improvements:
   - Added json.dump() with encoding test (passing case)
   - Added json.dump() without encoding test (failing case)
   - Fixed test assertions to match actual behavior (== 1 not == 2)
   - Added 6 new tests for improved binary/text mode coverage
   - Total tests increased from 10 to 16, all passing 

4. Code Cleanup:
   - Removed unused pytest import (CodeQL warning)
   - Simplified check_files() to remove unused variable tracking

All changes validated with comprehensive test suite (16/16 passing).

Related: PR #795 review feedback from CodeRabbit and Gemini Code Assist

* docs: Add UTF-8 encoding guidelines and Windows development guide

1. CONTRIBUTING.md:
   - Added concise file encoding section after Code Style
   - DO/DON'T examples for common file operations
   - Covers open(), Path methods, json operations
   - References PR #782 and windows-development.md

2. guides/windows-development.md (NEW):
   - Comprehensive Windows development guide
   - File encoding (cp1252 vs UTF-8 issue)
   - Line endings, path separators, shell commands
   - Development environment recommendations
   - Common pitfalls and solutions
   - Testing guidelines

3. .github/PULL_REQUEST_TEMPLATE.md:
   - Added encoding checklist item for Python PRs
   - Helps catch missing encoding during review

4. guides/README.md:
   - Added windows-development.md to guide index
   - Organized with CLI-USAGE and linux guides

Purpose: Educate developers about UTF-8 encoding requirements to prevent
regressions of the 251 encoding issues fixed in PR #782. Automated checking
via pre-commit hooks (PR #795) + developer education ensures long-term
Windows compatibility.

Related:
- PR #782: Fix Windows UTF-8 encoding errors (251 instances)
- PR #795: Add pre-commit hooks for encoding enforcement

* Address review comments from CodeRabbit and Gemini

1. Fix CONTRIBUTING.md markdown linting issues
   - Add blank lines around code blocks (MD031)
   - Add JSON write example with ensure_ascii=False (Gemini suggestion)

2. Fix guides/windows-development.md markdown linting (39 violations)
   - Rename duplicate headings: "The Problem"/"The Solution" → "Problem"/"Solution" (MD024)
   - Add blank lines around all code blocks (MD031)
   - Add language specifiers to code blocks (MD040)
   - Add blank lines before/after headings (MD022)
   - Wrap long lines to <=80 characters (MD013)
   - Add blank line before list (MD032)
   - Use Gemini's idiomatic line ending normalization pattern

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix additional UTF-8 encoding issues and improve encoding check script

- Add encoding="utf-8" to 5 files that were missing it:
  - cli/workspace_commands.py: read_text for worktree config
  - context/pattern_discovery.py: read_text with errors param
  - context/search.py: read_text with errors param
  - core/sentry.py: open for package.json version detection
  - core/workspace/setup.py: open for security profile JSON

- Improve check_encoding.py script to reduce false positives:
  - Use negative lookbehind to exclude os.open(), urlopen(), etc.
  - Handle nested parentheses correctly when checking args
  - Skip self.method.read_text() calls (custom methods, not Path)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix missing UTF-8 encoding in locked_write() function

Add encoding parameter to locked_write() async context manager and
use it in os.fdopen() call. This fixes HIGH priority issue from PR review
where locked_write() was missing UTF-8 encoding support, which could cause
encoding errors on Windows when writing files with non-ASCII content.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add UnicodeDecodeError handling for file loading resilience

Address CodeRabbit review feedback:
- runner.py: Add UnicodeDecodeError to exception handling when loading batch files
- trust.py: Add exception handling in get_state() and get_all_states() to
  gracefully handle corrupted state files instead of failing completely

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix atomic_write to handle binary mode correctly

The atomic_write function was unconditionally passing encoding to os.fdopen,
which would crash with ValueError if called with binary mode (e.g., 'wb').
Apply the same fix used in locked_write: only pass encoding for text modes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix run_git() call with invalid parameters in setup.py

Remove capture_output and encoding kwargs from run_git() call - these
parameters are already handled internally by run_git() and passing them
causes TypeError since the function doesn't accept them.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix CodeQL warnings and potential double-newline bug

- Remove unused is_path_call variables in check_encoding.py
- Remove unused failed_count variable in check_encoding.py
- Remove unused escapeRegex function in bump-version.js
- Fix potential double-newline when adding imports in file_merger.py
  (strip trailing newlines from content_after before inserting)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix Ruff formatting: wrap long line in file_merger.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add UnicodeDecodeError handling to all JSON file loading

Comprehensively add UnicodeDecodeError to exception handlers across
the codebase to handle legacy-encoded or corrupted files gracefully:

- 32+ locations now catch UnicodeDecodeError alongside OSError and
  json.JSONDecodeError
- context/builder.py: Regenerate index on decode failure
- planner_lib/context.py: Use empty dicts on decode failure
- check_encoding.py: Handle OSError for unreadable files
- cleanup.py: Handle decode errors in index pruning

This ensures the codebase is robust against non-UTF-8 files that may
exist from previous Windows runs with cp1252 encoding.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add explanatory comments to empty except clauses

Address CodeQL notices about empty except clauses with just 'pass'
by adding explanatory comments describing the intent.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix review issues from Andy's Auto Claude PR Review

1. [HIGH] Fix double-close bug in trust.py:449
   - Remove try/except around os.fdopen since it takes ownership of fd
   - The with statement handles closing, no need for explicit os.close()

2. [LOW] Fix dead code in file_merger.py:87,159
   - Simplify endswith check to just '\n' since content is already
     normalized to LF at that point

3. [LOW] Fix escaped backslash-n in test_context_gatherer.py:150
   - Change "\n" (literal backslash-n) to "\n" (actual newline)

4. [LOW] Fix coder.md examples missing encoding parameter
   - Add encoding="utf-8" to read_text() and open() calls in examples

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: TamerineSky <TamerineSky@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:22:55 +01:00

924 lines
33 KiB
Python

"""
GitHub Automation Data Models
=============================
Data structures for GitHub automation features.
Stored in .auto-claude/github/pr/ and .auto-claude/github/issues/
All save() operations use file locking to prevent corruption in concurrent scenarios.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
try:
from .file_lock import locked_json_update, locked_json_write
except (ImportError, ValueError, SystemError):
from file_lock import locked_json_update, locked_json_write
class ReviewSeverity(str, Enum):
"""Severity levels for PR review findings."""
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class ReviewCategory(str, Enum):
"""Categories for PR review findings."""
SECURITY = "security"
QUALITY = "quality"
STYLE = "style"
TEST = "test"
DOCS = "docs"
PATTERN = "pattern"
PERFORMANCE = "performance"
VERIFICATION_FAILED = "verification_failed" # NEW: Cannot verify requirements/paths
REDUNDANCY = "redundancy" # NEW: Duplicate code/logic detected
class ReviewPass(str, Enum):
"""Multi-pass review stages."""
QUICK_SCAN = "quick_scan"
SECURITY = "security"
QUALITY = "quality"
DEEP_ANALYSIS = "deep_analysis"
STRUCTURAL = "structural" # Feature creep, architecture, PR structure
AI_COMMENT_TRIAGE = "ai_comment_triage" # Verify other AI tool comments
class MergeVerdict(str, Enum):
"""Clear verdict for whether PR can be merged."""
READY_TO_MERGE = "ready_to_merge" # No blockers, good to go
MERGE_WITH_CHANGES = "merge_with_changes" # Minor issues, fix before merge
NEEDS_REVISION = "needs_revision" # Significant issues, needs rework
BLOCKED = "blocked" # Critical issues, cannot merge
# Constants for branch-behind messaging (DRY - used across multiple reviewers)
BRANCH_BEHIND_BLOCKER_MSG = (
"Branch Out of Date: PR branch is behind the base branch and needs to be updated"
)
BRANCH_BEHIND_REASONING = (
"Branch is out of date with base branch. Update branch first - "
"if no conflicts arise, you can merge. If merge conflicts arise, "
"resolve them and run follow-up review again."
)
class AICommentVerdict(str, Enum):
"""Verdict on AI tool comments (CodeRabbit, Cursor, Greptile, etc.)."""
CRITICAL = "critical" # Must be addressed before merge
IMPORTANT = "important" # Should be addressed
NICE_TO_HAVE = "nice_to_have" # Optional improvement
TRIVIAL = "trivial" # Can be ignored
FALSE_POSITIVE = "false_positive" # AI was wrong
ADDRESSED = "addressed" # Valid issue that was fixed in a subsequent commit
class TriageCategory(str, Enum):
"""Issue triage categories."""
BUG = "bug"
FEATURE = "feature"
DOCUMENTATION = "documentation"
QUESTION = "question"
DUPLICATE = "duplicate"
SPAM = "spam"
FEATURE_CREEP = "feature_creep"
class AutoFixStatus(str, Enum):
"""Status for auto-fix operations."""
# Initial states
PENDING = "pending"
ANALYZING = "analyzing"
# Spec creation states
CREATING_SPEC = "creating_spec"
WAITING_APPROVAL = "waiting_approval" # P1-3: Human review gate
# Build states
BUILDING = "building"
QA_REVIEW = "qa_review"
# PR states
PR_CREATED = "pr_created"
MERGE_CONFLICT = "merge_conflict" # P1-3: Conflict resolution needed
# Terminal states
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled" # P1-3: User cancelled
# Special states
STALE = "stale" # P1-3: Issue updated after spec creation
RATE_LIMITED = "rate_limited" # P1-3: Waiting for rate limit reset
@classmethod
def terminal_states(cls) -> set[AutoFixStatus]:
"""States that represent end of workflow."""
return {cls.COMPLETED, cls.FAILED, cls.CANCELLED}
@classmethod
def recoverable_states(cls) -> set[AutoFixStatus]:
"""States that can be recovered from."""
return {cls.FAILED, cls.STALE, cls.RATE_LIMITED, cls.MERGE_CONFLICT}
@classmethod
def active_states(cls) -> set[AutoFixStatus]:
"""States that indicate work in progress."""
return {
cls.PENDING,
cls.ANALYZING,
cls.CREATING_SPEC,
cls.BUILDING,
cls.QA_REVIEW,
cls.PR_CREATED,
}
def can_transition_to(self, new_state: AutoFixStatus) -> bool:
"""Check if transition to new_state is valid."""
valid_transitions = {
AutoFixStatus.PENDING: {
AutoFixStatus.ANALYZING,
AutoFixStatus.CANCELLED,
},
AutoFixStatus.ANALYZING: {
AutoFixStatus.CREATING_SPEC,
AutoFixStatus.FAILED,
AutoFixStatus.CANCELLED,
AutoFixStatus.RATE_LIMITED,
},
AutoFixStatus.CREATING_SPEC: {
AutoFixStatus.WAITING_APPROVAL,
AutoFixStatus.BUILDING,
AutoFixStatus.FAILED,
AutoFixStatus.CANCELLED,
AutoFixStatus.STALE,
},
AutoFixStatus.WAITING_APPROVAL: {
AutoFixStatus.BUILDING,
AutoFixStatus.CANCELLED,
AutoFixStatus.STALE,
},
AutoFixStatus.BUILDING: {
AutoFixStatus.QA_REVIEW,
AutoFixStatus.FAILED,
AutoFixStatus.CANCELLED,
AutoFixStatus.RATE_LIMITED,
},
AutoFixStatus.QA_REVIEW: {
AutoFixStatus.PR_CREATED,
AutoFixStatus.BUILDING, # Fix loop
AutoFixStatus.FAILED,
AutoFixStatus.CANCELLED,
},
AutoFixStatus.PR_CREATED: {
AutoFixStatus.COMPLETED,
AutoFixStatus.MERGE_CONFLICT,
AutoFixStatus.FAILED,
},
AutoFixStatus.MERGE_CONFLICT: {
AutoFixStatus.BUILDING, # Retry after conflict resolution
AutoFixStatus.FAILED,
AutoFixStatus.CANCELLED,
},
AutoFixStatus.STALE: {
AutoFixStatus.ANALYZING, # Re-analyze with new issue content
AutoFixStatus.CANCELLED,
},
AutoFixStatus.RATE_LIMITED: {
AutoFixStatus.PENDING, # Resume after rate limit
AutoFixStatus.CANCELLED,
},
# Terminal states - no transitions
AutoFixStatus.COMPLETED: set(),
AutoFixStatus.FAILED: {AutoFixStatus.PENDING}, # Allow retry
AutoFixStatus.CANCELLED: set(),
}
return new_state in valid_transitions.get(self, set())
@dataclass
class PRReviewFinding:
"""A single finding from a PR review."""
id: str
severity: ReviewSeverity
category: ReviewCategory
title: str
description: str
file: str
line: int
end_line: int | None = None
suggested_fix: str | None = None
fixable: bool = False
# Evidence-based validation: actual code proving the issue exists
evidence: str | None = None # Actual code snippet showing the issue
verification_note: str | None = (
None # What evidence is missing or couldn't be verified
)
redundant_with: str | None = None # Reference to duplicate code (file:line)
# Finding validation fields (from finding-validator re-investigation)
validation_status: str | None = (
None # confirmed_valid, dismissed_false_positive, needs_human_review
)
validation_evidence: str | None = None # Code snippet examined during validation
validation_explanation: str | None = None # Why finding was validated/dismissed
def to_dict(self) -> dict:
return {
"id": self.id,
"severity": self.severity.value,
"category": self.category.value,
"title": self.title,
"description": self.description,
"file": self.file,
"line": self.line,
"end_line": self.end_line,
"suggested_fix": self.suggested_fix,
"fixable": self.fixable,
# Evidence-based validation fields
"evidence": self.evidence,
"verification_note": self.verification_note,
"redundant_with": self.redundant_with,
# Validation fields
"validation_status": self.validation_status,
"validation_evidence": self.validation_evidence,
"validation_explanation": self.validation_explanation,
}
@classmethod
def from_dict(cls, data: dict) -> PRReviewFinding:
return cls(
id=data["id"],
severity=ReviewSeverity(data["severity"]),
category=ReviewCategory(data["category"]),
title=data["title"],
description=data["description"],
file=data["file"],
line=data["line"],
end_line=data.get("end_line"),
suggested_fix=data.get("suggested_fix"),
fixable=data.get("fixable", False),
# Evidence-based validation fields
evidence=data.get("evidence"),
verification_note=data.get("verification_note"),
redundant_with=data.get("redundant_with"),
# Validation fields
validation_status=data.get("validation_status"),
validation_evidence=data.get("validation_evidence"),
validation_explanation=data.get("validation_explanation"),
)
@dataclass
class AICommentTriage:
"""Triage result for an AI tool comment (CodeRabbit, Cursor, Greptile, etc.)."""
comment_id: int
tool_name: str # "CodeRabbit", "Cursor", "Greptile", etc.
original_comment: str
verdict: AICommentVerdict
reasoning: str
response_comment: str | None = None # Comment to post in reply
def to_dict(self) -> dict:
return {
"comment_id": self.comment_id,
"tool_name": self.tool_name,
"original_comment": self.original_comment,
"verdict": self.verdict.value,
"reasoning": self.reasoning,
"response_comment": self.response_comment,
}
@classmethod
def from_dict(cls, data: dict) -> AICommentTriage:
return cls(
comment_id=data["comment_id"],
tool_name=data["tool_name"],
original_comment=data["original_comment"],
verdict=AICommentVerdict(data["verdict"]),
reasoning=data["reasoning"],
response_comment=data.get("response_comment"),
)
@dataclass
class StructuralIssue:
"""Structural issue with the PR (feature creep, architecture, etc.)."""
id: str
issue_type: str # "feature_creep", "scope_creep", "architecture_violation", "poor_structure"
severity: ReviewSeverity
title: str
description: str
impact: str # Why this matters
suggestion: str # How to fix
def to_dict(self) -> dict:
return {
"id": self.id,
"issue_type": self.issue_type,
"severity": self.severity.value,
"title": self.title,
"description": self.description,
"impact": self.impact,
"suggestion": self.suggestion,
}
@classmethod
def from_dict(cls, data: dict) -> StructuralIssue:
return cls(
id=data["id"],
issue_type=data["issue_type"],
severity=ReviewSeverity(data["severity"]),
title=data["title"],
description=data["description"],
impact=data["impact"],
suggestion=data["suggestion"],
)
@dataclass
class PRReviewResult:
"""Complete result of a PR review."""
pr_number: int
repo: str
success: bool
findings: list[PRReviewFinding] = field(default_factory=list)
summary: str = ""
overall_status: str = "comment" # approve, request_changes, comment
review_id: int | None = None
reviewed_at: str = field(default_factory=lambda: datetime.now().isoformat())
error: str | None = None
# NEW: Enhanced verdict system
verdict: MergeVerdict = MergeVerdict.READY_TO_MERGE
verdict_reasoning: str = ""
blockers: list[str] = field(default_factory=list) # Issues that MUST be fixed
# NEW: Risk assessment
risk_assessment: dict = field(
default_factory=lambda: {
"complexity": "low", # low, medium, high
"security_impact": "none", # none, low, medium, critical
"scope_coherence": "good", # good, mixed, poor
}
)
# NEW: Structural issues and AI comment triages
structural_issues: list[StructuralIssue] = field(default_factory=list)
ai_comment_triages: list[AICommentTriage] = field(default_factory=list)
# NEW: Quick scan summary preserved
quick_scan_summary: dict = field(default_factory=dict)
# Follow-up review tracking
reviewed_commit_sha: str | None = None # HEAD SHA at time of review
reviewed_file_blobs: dict[str, str] = field(
default_factory=dict
) # filename → blob SHA at time of review (survives rebases)
is_followup_review: bool = False # True if this is a follow-up review
previous_review_id: int | None = None # Reference to the review this follows up on
resolved_findings: list[str] = field(default_factory=list) # Finding IDs now fixed
unresolved_findings: list[str] = field(
default_factory=list
) # Finding IDs still open
new_findings_since_last_review: list[str] = field(
default_factory=list
) # New issues in recent commits
# Posted findings tracking (for frontend state sync)
has_posted_findings: bool = False # True if any findings have been posted to GitHub
posted_finding_ids: list[str] = field(
default_factory=list
) # IDs of posted findings
posted_at: str | None = None # Timestamp when findings were posted
def to_dict(self) -> dict:
return {
"pr_number": self.pr_number,
"repo": self.repo,
"success": self.success,
"findings": [f.to_dict() for f in self.findings],
"summary": self.summary,
"overall_status": self.overall_status,
"review_id": self.review_id,
"reviewed_at": self.reviewed_at,
"error": self.error,
# NEW fields
"verdict": self.verdict.value,
"verdict_reasoning": self.verdict_reasoning,
"blockers": self.blockers,
"risk_assessment": self.risk_assessment,
"structural_issues": [s.to_dict() for s in self.structural_issues],
"ai_comment_triages": [t.to_dict() for t in self.ai_comment_triages],
"quick_scan_summary": self.quick_scan_summary,
# Follow-up review fields
"reviewed_commit_sha": self.reviewed_commit_sha,
"reviewed_file_blobs": self.reviewed_file_blobs,
"is_followup_review": self.is_followup_review,
"previous_review_id": self.previous_review_id,
"resolved_findings": self.resolved_findings,
"unresolved_findings": self.unresolved_findings,
"new_findings_since_last_review": self.new_findings_since_last_review,
# Posted findings tracking
"has_posted_findings": self.has_posted_findings,
"posted_finding_ids": self.posted_finding_ids,
"posted_at": self.posted_at,
}
@classmethod
def from_dict(cls, data: dict) -> PRReviewResult:
return cls(
pr_number=data["pr_number"],
repo=data["repo"],
success=data["success"],
findings=[PRReviewFinding.from_dict(f) for f in data.get("findings", [])],
summary=data.get("summary", ""),
overall_status=data.get("overall_status", "comment"),
review_id=data.get("review_id"),
reviewed_at=data.get("reviewed_at", datetime.now().isoformat()),
error=data.get("error"),
# NEW fields
verdict=MergeVerdict(data.get("verdict", "ready_to_merge")),
verdict_reasoning=data.get("verdict_reasoning", ""),
blockers=data.get("blockers", []),
risk_assessment=data.get(
"risk_assessment",
{
"complexity": "low",
"security_impact": "none",
"scope_coherence": "good",
},
),
structural_issues=[
StructuralIssue.from_dict(s) for s in data.get("structural_issues", [])
],
ai_comment_triages=[
AICommentTriage.from_dict(t) for t in data.get("ai_comment_triages", [])
],
quick_scan_summary=data.get("quick_scan_summary", {}),
# Follow-up review fields
reviewed_commit_sha=data.get("reviewed_commit_sha"),
reviewed_file_blobs=data.get("reviewed_file_blobs", {}),
is_followup_review=data.get("is_followup_review", False),
previous_review_id=data.get("previous_review_id"),
resolved_findings=data.get("resolved_findings", []),
unresolved_findings=data.get("unresolved_findings", []),
new_findings_since_last_review=data.get(
"new_findings_since_last_review", []
),
# Posted findings tracking
has_posted_findings=data.get("has_posted_findings", False),
posted_finding_ids=data.get("posted_finding_ids", []),
posted_at=data.get("posted_at"),
)
async def save(self, github_dir: Path) -> None:
"""Save review result to .auto-claude/github/pr/ with file locking."""
pr_dir = github_dir / "pr"
pr_dir.mkdir(parents=True, exist_ok=True)
review_file = pr_dir / f"review_{self.pr_number}.json"
# Atomic locked write
await locked_json_write(review_file, self.to_dict(), timeout=5.0)
# Update index with locking
await self._update_index(pr_dir)
async def _update_index(self, pr_dir: Path) -> None:
"""Update the PR review index with file locking."""
index_file = pr_dir / "index.json"
def update_index(current_data):
"""Update function for atomic index update."""
if current_data is None:
current_data = {"reviews": [], "last_updated": None}
# Update or add entry
reviews = current_data.get("reviews", [])
existing = next(
(r for r in reviews if r["pr_number"] == self.pr_number), None
)
entry = {
"pr_number": self.pr_number,
"repo": self.repo,
"overall_status": self.overall_status,
"findings_count": len(self.findings),
"reviewed_at": self.reviewed_at,
}
if existing:
reviews = [
entry if r["pr_number"] == self.pr_number else r for r in reviews
]
else:
reviews.append(entry)
current_data["reviews"] = reviews
current_data["last_updated"] = datetime.now().isoformat()
return current_data
# Atomic locked update
await locked_json_update(index_file, update_index, timeout=5.0)
@classmethod
def load(cls, github_dir: Path, pr_number: int) -> PRReviewResult | None:
"""Load a review result from disk."""
review_file = github_dir / "pr" / f"review_{pr_number}.json"
if not review_file.exists():
return None
with open(review_file, encoding="utf-8") as f:
return cls.from_dict(json.load(f))
@dataclass
class FollowupReviewContext:
"""Context for a follow-up review."""
pr_number: int
previous_review: PRReviewResult
previous_commit_sha: str
current_commit_sha: str
# Changes since last review
commits_since_review: list[dict] = field(default_factory=list)
files_changed_since_review: list[str] = field(default_factory=list)
diff_since_review: str = ""
# Comments since last review
contributor_comments_since_review: list[dict] = field(default_factory=list)
ai_bot_comments_since_review: list[dict] = field(default_factory=list)
# PR reviews since last review (formal review submissions from Cursor, CodeRabbit, etc.)
# These are different from comments - they're full review submissions with body text
pr_reviews_since_review: list[dict] = field(default_factory=list)
# Merge conflict status
has_merge_conflicts: bool = False # True if PR has conflicts with base branch
merge_state_status: str = (
"" # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
)
# CI status - passed to AI orchestrator so it can factor into verdict
# Dict with: passing, failing, pending, failed_checks, awaiting_approval
ci_status: dict = field(default_factory=dict)
# Error flag - if set, context gathering failed and data may be incomplete
error: str | None = None
@dataclass
class TriageResult:
"""Result of triaging a single issue."""
issue_number: int
repo: str
category: TriageCategory
confidence: float # 0.0 to 1.0
labels_to_add: list[str] = field(default_factory=list)
labels_to_remove: list[str] = field(default_factory=list)
is_duplicate: bool = False
duplicate_of: int | None = None
is_spam: bool = False
is_feature_creep: bool = False
suggested_breakdown: list[str] = field(default_factory=list)
priority: str = "medium" # high, medium, low
comment: str | None = None
triaged_at: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> dict:
return {
"issue_number": self.issue_number,
"repo": self.repo,
"category": self.category.value,
"confidence": self.confidence,
"labels_to_add": self.labels_to_add,
"labels_to_remove": self.labels_to_remove,
"is_duplicate": self.is_duplicate,
"duplicate_of": self.duplicate_of,
"is_spam": self.is_spam,
"is_feature_creep": self.is_feature_creep,
"suggested_breakdown": self.suggested_breakdown,
"priority": self.priority,
"comment": self.comment,
"triaged_at": self.triaged_at,
}
@classmethod
def from_dict(cls, data: dict) -> TriageResult:
return cls(
issue_number=data["issue_number"],
repo=data["repo"],
category=TriageCategory(data["category"]),
confidence=data["confidence"],
labels_to_add=data.get("labels_to_add", []),
labels_to_remove=data.get("labels_to_remove", []),
is_duplicate=data.get("is_duplicate", False),
duplicate_of=data.get("duplicate_of"),
is_spam=data.get("is_spam", False),
is_feature_creep=data.get("is_feature_creep", False),
suggested_breakdown=data.get("suggested_breakdown", []),
priority=data.get("priority", "medium"),
comment=data.get("comment"),
triaged_at=data.get("triaged_at", datetime.now().isoformat()),
)
async def save(self, github_dir: Path) -> None:
"""Save triage result to .auto-claude/github/issues/ with file locking."""
issues_dir = github_dir / "issues"
issues_dir.mkdir(parents=True, exist_ok=True)
triage_file = issues_dir / f"triage_{self.issue_number}.json"
# Atomic locked write
await locked_json_write(triage_file, self.to_dict(), timeout=5.0)
@classmethod
def load(cls, github_dir: Path, issue_number: int) -> TriageResult | None:
"""Load a triage result from disk."""
triage_file = github_dir / "issues" / f"triage_{issue_number}.json"
if not triage_file.exists():
return None
with open(triage_file, encoding="utf-8") as f:
return cls.from_dict(json.load(f))
@dataclass
class AutoFixState:
"""State tracking for auto-fix operations."""
issue_number: int
issue_url: str
repo: str
status: AutoFixStatus = AutoFixStatus.PENDING
spec_id: str | None = None
spec_dir: str | None = None
pr_number: int | None = None
pr_url: str | None = None
bot_comments: list[str] = field(default_factory=list)
error: str | None = None
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> dict:
return {
"issue_number": self.issue_number,
"issue_url": self.issue_url,
"repo": self.repo,
"status": self.status.value,
"spec_id": self.spec_id,
"spec_dir": self.spec_dir,
"pr_number": self.pr_number,
"pr_url": self.pr_url,
"bot_comments": self.bot_comments,
"error": self.error,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@classmethod
def from_dict(cls, data: dict) -> AutoFixState:
issue_number = data["issue_number"]
repo = data["repo"]
# Construct issue_url if missing (for backwards compatibility with old state files)
issue_url = (
data.get("issue_url") or f"https://github.com/{repo}/issues/{issue_number}"
)
return cls(
issue_number=issue_number,
issue_url=issue_url,
repo=repo,
status=AutoFixStatus(data.get("status", "pending")),
spec_id=data.get("spec_id"),
spec_dir=data.get("spec_dir"),
pr_number=data.get("pr_number"),
pr_url=data.get("pr_url"),
bot_comments=data.get("bot_comments", []),
error=data.get("error"),
created_at=data.get("created_at", datetime.now().isoformat()),
updated_at=data.get("updated_at", datetime.now().isoformat()),
)
def update_status(self, status: AutoFixStatus) -> None:
"""Update status and timestamp with transition validation."""
if not self.status.can_transition_to(status):
raise ValueError(
f"Invalid state transition: {self.status.value} -> {status.value}"
)
self.status = status
self.updated_at = datetime.now().isoformat()
async def save(self, github_dir: Path) -> None:
"""Save auto-fix state to .auto-claude/github/issues/ with file locking."""
issues_dir = github_dir / "issues"
issues_dir.mkdir(parents=True, exist_ok=True)
autofix_file = issues_dir / f"autofix_{self.issue_number}.json"
# Atomic locked write
await locked_json_write(autofix_file, self.to_dict(), timeout=5.0)
# Update index with locking
await self._update_index(issues_dir)
async def _update_index(self, issues_dir: Path) -> None:
"""Update the issues index with auto-fix queue using file locking."""
index_file = issues_dir / "index.json"
def update_index(current_data):
"""Update function for atomic index update."""
if current_data is None:
current_data = {
"triaged": [],
"auto_fix_queue": [],
"last_updated": None,
}
# Update auto-fix queue
queue = current_data.get("auto_fix_queue", [])
existing = next(
(q for q in queue if q["issue_number"] == self.issue_number), None
)
entry = {
"issue_number": self.issue_number,
"repo": self.repo,
"status": self.status.value,
"spec_id": self.spec_id,
"pr_number": self.pr_number,
"updated_at": self.updated_at,
}
if existing:
queue = [
entry if q["issue_number"] == self.issue_number else q
for q in queue
]
else:
queue.append(entry)
current_data["auto_fix_queue"] = queue
current_data["last_updated"] = datetime.now().isoformat()
return current_data
# Atomic locked update
await locked_json_update(index_file, update_index, timeout=5.0)
@classmethod
def load(cls, github_dir: Path, issue_number: int) -> AutoFixState | None:
"""Load an auto-fix state from disk."""
autofix_file = github_dir / "issues" / f"autofix_{issue_number}.json"
if not autofix_file.exists():
return None
with open(autofix_file, encoding="utf-8") as f:
return cls.from_dict(json.load(f))
@dataclass
class GitHubRunnerConfig:
"""Configuration for GitHub automation runners."""
# Authentication
token: str
repo: str # owner/repo format
bot_token: str | None = None # Separate bot account token
# Auto-fix settings
auto_fix_enabled: bool = False
auto_fix_labels: list[str] = field(default_factory=lambda: ["auto-fix"])
require_human_approval: bool = True
# Permission settings
auto_fix_allowed_roles: list[str] = field(
default_factory=lambda: ["OWNER", "MEMBER", "COLLABORATOR"]
)
allow_external_contributors: bool = False
# Triage settings
triage_enabled: bool = False
duplicate_threshold: float = 0.80
spam_threshold: float = 0.75
feature_creep_threshold: float = 0.70
enable_triage_comments: bool = False
# PR review settings
pr_review_enabled: bool = False
auto_post_reviews: bool = False
allow_fix_commits: bool = True
review_own_prs: bool = False # Whether bot can review its own PRs
use_orchestrator_review: bool = (
True # DEPRECATED: No longer used, kept for config compatibility
)
use_parallel_orchestrator: bool = (
True # Use SDK subagent parallel orchestrator (default)
)
# Model settings
# Note: Default uses shorthand "sonnet" which gets resolved via resolve_model_id()
# to respect environment variable overrides (e.g., ANTHROPIC_DEFAULT_SONNET_MODEL)
model: str = "sonnet"
thinking_level: str = "medium"
def to_dict(self) -> dict:
return {
"token": "***", # Never save token
"repo": self.repo,
"bot_token": "***" if self.bot_token else None,
"auto_fix_enabled": self.auto_fix_enabled,
"auto_fix_labels": self.auto_fix_labels,
"require_human_approval": self.require_human_approval,
"auto_fix_allowed_roles": self.auto_fix_allowed_roles,
"allow_external_contributors": self.allow_external_contributors,
"triage_enabled": self.triage_enabled,
"duplicate_threshold": self.duplicate_threshold,
"spam_threshold": self.spam_threshold,
"feature_creep_threshold": self.feature_creep_threshold,
"enable_triage_comments": self.enable_triage_comments,
"pr_review_enabled": self.pr_review_enabled,
"review_own_prs": self.review_own_prs,
"auto_post_reviews": self.auto_post_reviews,
"allow_fix_commits": self.allow_fix_commits,
"model": self.model,
"thinking_level": self.thinking_level,
}
def save_settings(self, github_dir: Path) -> None:
"""Save non-sensitive settings to config.json."""
github_dir.mkdir(parents=True, exist_ok=True)
config_file = github_dir / "config.json"
# Save without tokens
settings = self.to_dict()
settings.pop("token", None)
settings.pop("bot_token", None)
with open(config_file, "w", encoding="utf-8") as f:
json.dump(settings, f, indent=2)
@classmethod
def load_settings(
cls, github_dir: Path, token: str, repo: str, bot_token: str | None = None
) -> GitHubRunnerConfig:
"""Load settings from config.json, with tokens provided separately."""
config_file = github_dir / "config.json"
if config_file.exists():
with open(config_file, encoding="utf-8") as f:
settings = json.load(f)
else:
settings = {}
return cls(
token=token,
repo=repo,
bot_token=bot_token,
auto_fix_enabled=settings.get("auto_fix_enabled", False),
auto_fix_labels=settings.get("auto_fix_labels", ["auto-fix"]),
require_human_approval=settings.get("require_human_approval", True),
auto_fix_allowed_roles=settings.get(
"auto_fix_allowed_roles", ["OWNER", "MEMBER", "COLLABORATOR"]
),
allow_external_contributors=settings.get(
"allow_external_contributors", False
),
triage_enabled=settings.get("triage_enabled", False),
duplicate_threshold=settings.get("duplicate_threshold", 0.80),
spam_threshold=settings.get("spam_threshold", 0.75),
feature_creep_threshold=settings.get("feature_creep_threshold", 0.70),
enable_triage_comments=settings.get("enable_triage_comments", False),
pr_review_enabled=settings.get("pr_review_enabled", False),
review_own_prs=settings.get("review_own_prs", False),
auto_post_reviews=settings.get("auto_post_reviews", False),
allow_fix_commits=settings.get("allow_fix_commits", True),
# Note: model is stored as shorthand and resolved via resolve_model_id()
model=settings.get("model", "sonnet"),
thinking_level=settings.get("thinking_level", "medium"),
)