fix(merge): use git merge for diverged branches with progress tracking (#1605)
* fix(merge): use git merge instead of file copy for diverged branches
Replace direct file copy with proper git merge for worktree branches that
have diverged from develop but have no actual conflicts. This preserves
changes from both branches instead of overwriting develop-side changes.
Key changes:
- Use `git merge --no-commit --no-ff` when branches diverged but no conflicts
- Add real-time merge progress tracking with UI overlay
- Emit structured JSON progress events from Python to Electron via stdout
- Add stall detection (30s) and progress visualization in frontend
The previous approach used direct file copy as a fallback when rebase failed
due to worktree lock, which would overwrite any develop-side changes. Now we
properly detect "diverged but no conflicts" scenarios and let git handle the
merge correctly.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(merge): add conflict scenario detection for better UX messaging
Add intelligent detection of merge conflict scenarios to provide clearer
guidance when staging task changes:
- already_merged: Task changes identical to target branch - show "Mark as Done"
- superseded: Target has newer version - show "View Comparison" / "Discard"
- diverged: Both branches modified - standard AI merge flow
Backend: Add _detect_conflict_scenario() that compares file contents between
spec branch, base branch, and merge-base to classify the scenario.
Frontend: Add scenario-specific banners and action buttons that guide users
to the appropriate action instead of showing confusing "Branch Diverged" errors.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* auto-claude: subtask-1-1 - Add BILLING_FAILURE_PATTERNS regex array with patterns for credit/billing errors
Added comprehensive regex patterns to detect billing and credit failures:
- Credit balance patterns (insufficient, low, empty, exhausted)
- Billing error patterns (payment failed, subscription expired)
- Usage quota patterns (monthly limits, plan limits)
- API error patterns (billing_error, insufficient_credits, 402)
- Balance/funds patterns and add credits messages
* auto-claude: subtask-1-2 - Add BillingFailureDetectionResult interface parallel to AuthFailureDetectionResult
Add new TypeScript interface for billing failure detection that mirrors
the AuthFailureDetectionResult pattern with:
- isBillingFailure: boolean flag
- profileId: optional profile identifier
- failureType: specific billing failure types (insufficient_credits,
payment_required, subscription_inactive, unknown)
- message: user-friendly error message
- originalError: raw error from process output
* auto-claude: subtask-1-3 - Add classifyBillingFailureType() and getBillingFailureMessage() helpers
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* auto-claude: subtask-1-4 - Add detectBillingFailure() function to detect billing errors in output
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* auto-claude: subtask-2-1 - Add BillingFailureInfo interface to terminal.ts
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* auto-claude: subtask-3-1 - Add onBillingFailure callback to SubprocessOptions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* auto-claude: subtask-3-2 - Add checkBillingFailure helper function in runPythonSubprocess
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* auto-claude: subtask-3-3 - Integrate checkBillingFailure into stdout/stderr handlers and close handler
- Added checkBillingFailure(line) call after checkAuthFailure(line) in stdout handler
- Added checkBillingFailure(line) call after checkAuthFailure(line) in stderr handler
- Added killedDueToBillingFailure check in close handler with proper error message
- Follows the exact same pattern as auth failure detection integration
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: billing detection patterns and add unit tests (qa-requested)
Fixes:
- Fix regex for "credit balance is too low" by adding optional (too\s+)? group
- Add extra_usage pattern to BILLING_FAILURE_PATTERNS for Claude API errors
- Fix 402 false positive by requiring HTTP/status/code/error context prefix
- Add 31 unit tests for detectBillingFailure, isBillingFailureError, and
classifyBillingFailureType covering spec appendix messages, negative cases,
false positive checks, and cross-detection tests
Verified:
- All 84 rate-limit-detector tests pass (53 existing + 31 new)
- TypeScript compilation succeeds with zero errors
- Full test suite passes (2599/2599 + 6 skipped)
QA Fix Session: 1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: PR review feedback - async worktree listing and merge abort check
Issue 1 (HIGH): Convert synchronous git operations to async in
TASK_LIST_WORKTREES handler to prevent UI freezing:
- Use execFileAsync instead of execFileSync for git commands
- Use fsPromises.readdir/stat instead of readdirSync/statSync
- Process worktrees in parallel with Promise.all()
Issue 2 (MEDIUM): Add error check for git merge --abort:
- Check abort_result.returncode after merge --abort
- Log error and return None on failure to avoid inconsistent state
- Matches existing pattern from rebase --abort at line 870
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Revert popover removal and remove only the Claude.ai/code link
Partially reverts 8d18cc81a which removed the entire ClaudeCodeStatusBadge
popover. The intent was only to remove the "Learn more about Claude Code"
link that pointed to claude.ai/code.
Changes:
- Restore full popover functionality (version selector, installation
selector, update/rollback dialogs)
- Remove only the "Learn more about Claude Code" button that linked to
https://claude.ai/code
- Keep the Changelog link to GitHub
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: remove temporary debug file logging from PR review agents
Remove the _PRDebugLogger class and all file-based debug logging that was
writing to .auto-claude/github/pr/debug_logs/. This was temporary instrumentation
for measuring agent communication patterns.
Also adds circuit breaker protection (MAX_MESSAGE_COUNT=500) to prevent
runaway retry loops, and retry logic for the FindingValidator agent.
Changes:
- Remove _PRDebugLogger class (~220 lines)
- Remove all _dbg.* calls from process_sdk_stream
- Keep system_prompt/agent_definitions params for backwards compat (unused)
- Add circuit breaker to abort processing if msg_count > limit
- Add retry logic with MAX_VALIDATION_RETRIES=2 for FindingValidator
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(pr-review): remove programmatic file scanning, fix SDK tool concurrency
Architecture Changes:
- Remove legacy programmatic file scanning from PRContextGatherer
- LLM agents now discover relevant files via their tools (Glob/Grep/Read)
- This removes the 2000 file scan limit and lets agents use judgment
SDK Tool Concurrency Fix:
- Add retry logic with MAX_RETRIES=3 for tool use 400 errors
- Add _is_tool_concurrency_error() detection in sdk_utils.py
- Add prompt guidance for sequential tool execution
- Upgrade claude-agent-sdk to >=0.1.25
Bug Fixes:
- Add in-progress review tracking to BotDetector (30min timeout)
- Fix missing dict keys in workspace_commands.py error path
- Fix stuck loading state in ClaudeCodeStatusBadge.tsx
i18n:
- Replace 11 hardcoded strings in WorkspaceStatus.tsx with translation keys
- Add 13 new translation keys to en/fr taskReview.json
Tests:
- Update TestReverseDepDetection to reflect new LLM-driven architecture
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -536,6 +536,175 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
||||
cleanup_all_worktrees(project_dir, confirm=True)
|
||||
|
||||
|
||||
def _detect_conflict_scenario(
|
||||
project_dir: Path,
|
||||
conflicting_files: list[str],
|
||||
spec_branch: str,
|
||||
base_branch: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Analyze conflicting files to determine the conflict scenario.
|
||||
|
||||
This helps distinguish between:
|
||||
- 'already_merged': Task changes already identical in target branch
|
||||
- 'superseded': Target has newer version of same feature
|
||||
- 'diverged': Standard diverged branches (AI can resolve)
|
||||
- 'normal_conflict': Actual conflicting changes
|
||||
|
||||
Returns dict with:
|
||||
- scenario: 'already_merged' | 'superseded' | 'diverged' | 'normal_conflict'
|
||||
- already_merged_files: files identical in task and target
|
||||
- details: additional context
|
||||
"""
|
||||
if not conflicting_files:
|
||||
return {
|
||||
"scenario": "normal_conflict",
|
||||
"already_merged_files": [],
|
||||
"details": "No conflicting files to analyze",
|
||||
}
|
||||
|
||||
already_merged_files = []
|
||||
superseded_files = []
|
||||
diverged_files = []
|
||||
|
||||
try:
|
||||
# Get the merge-base commit
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if merge_base_result.returncode != 0:
|
||||
debug_warning(
|
||||
MODULE, "Could not find merge base for conflict scenario detection"
|
||||
)
|
||||
return {
|
||||
"scenario": "normal_conflict",
|
||||
"already_merged_files": [],
|
||||
"details": "Could not determine merge base",
|
||||
}
|
||||
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
for file_path in conflicting_files:
|
||||
try:
|
||||
# Get content from spec branch (task's changes)
|
||||
spec_content_result = subprocess.run(
|
||||
["git", "show", f"{spec_branch}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Get content from base branch (target)
|
||||
base_content_result = subprocess.run(
|
||||
["git", "show", f"{base_branch}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Get content from merge-base (original state)
|
||||
merge_base_content_result = subprocess.run(
|
||||
["git", "show", f"{merge_base}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Check file existence in each ref
|
||||
spec_exists = spec_content_result.returncode == 0
|
||||
base_exists = base_content_result.returncode == 0
|
||||
merge_base_exists = merge_base_content_result.returncode == 0
|
||||
|
||||
if spec_exists and base_exists:
|
||||
spec_content = spec_content_result.stdout
|
||||
base_content = base_content_result.stdout
|
||||
|
||||
# If contents are identical, the changes are already merged
|
||||
if spec_content == base_content:
|
||||
already_merged_files.append(file_path)
|
||||
debug(
|
||||
MODULE,
|
||||
f"File {file_path}: already merged (identical content)",
|
||||
)
|
||||
elif merge_base_exists:
|
||||
merge_base_content = merge_base_content_result.stdout
|
||||
# If base has changed from merge_base but spec matches merge_base,
|
||||
# the task's changes are superseded by newer changes
|
||||
if spec_content == merge_base_content:
|
||||
superseded_files.append(file_path)
|
||||
debug(
|
||||
MODULE,
|
||||
f"File {file_path}: superseded (base has newer changes)",
|
||||
)
|
||||
else:
|
||||
diverged_files.append(file_path)
|
||||
debug(
|
||||
MODULE,
|
||||
f"File {file_path}: diverged (both branches modified)",
|
||||
)
|
||||
else:
|
||||
diverged_files.append(file_path)
|
||||
else:
|
||||
diverged_files.append(file_path)
|
||||
|
||||
except Exception as e:
|
||||
debug_warning(
|
||||
MODULE, f"Error analyzing file {file_path} for scenario: {e}"
|
||||
)
|
||||
diverged_files.append(file_path)
|
||||
|
||||
# Determine overall scenario based on dominant pattern
|
||||
total_files = len(conflicting_files)
|
||||
|
||||
if len(already_merged_files) == total_files:
|
||||
scenario = "already_merged"
|
||||
details = "All conflicting files have identical content in both branches"
|
||||
elif len(already_merged_files) > total_files / 2:
|
||||
scenario = "already_merged"
|
||||
details = f"{len(already_merged_files)} of {total_files} files already have the same content"
|
||||
elif len(superseded_files) == total_files:
|
||||
scenario = "superseded"
|
||||
details = "All task changes have been superseded by newer changes in the target branch"
|
||||
elif len(superseded_files) > total_files / 2:
|
||||
scenario = "superseded"
|
||||
details = (
|
||||
f"{len(superseded_files)} of {total_files} files have been superseded"
|
||||
)
|
||||
elif diverged_files:
|
||||
scenario = "diverged"
|
||||
details = f"{len(diverged_files)} files have diverged and need AI merge"
|
||||
else:
|
||||
scenario = "normal_conflict"
|
||||
details = "Standard merge conflicts detected"
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
f"Conflict scenario: {scenario}",
|
||||
already_merged=len(already_merged_files),
|
||||
superseded=len(superseded_files),
|
||||
diverged=len(diverged_files),
|
||||
)
|
||||
|
||||
return {
|
||||
"scenario": scenario,
|
||||
"already_merged_files": already_merged_files,
|
||||
"superseded_files": superseded_files,
|
||||
"diverged_files": diverged_files,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Error detecting conflict scenario: {e}")
|
||||
return {
|
||||
"scenario": "normal_conflict",
|
||||
"already_merged_files": [],
|
||||
"superseded_files": [],
|
||||
"diverged_files": [],
|
||||
"details": f"Error during analysis: {e}",
|
||||
}
|
||||
|
||||
|
||||
def _check_git_merge_conflicts(
|
||||
project_dir: Path, spec_name: str, base_branch: str | None = None
|
||||
) -> dict:
|
||||
@@ -879,6 +1048,24 @@ def handle_merge_preview_command(
|
||||
f for f in git_conflicts.get("conflicting_files", []) if not is_lock_file(f)
|
||||
]
|
||||
|
||||
# Detect conflict scenario (already_merged, superseded, diverged, normal_conflict)
|
||||
# This helps the UI show appropriate messaging and actions
|
||||
conflict_scenario = None
|
||||
if non_lock_conflicting_files:
|
||||
conflict_scenario = _detect_conflict_scenario(
|
||||
project_dir,
|
||||
non_lock_conflicting_files,
|
||||
git_conflicts["spec_branch"],
|
||||
git_conflicts["base_branch"],
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Conflict scenario detected: {conflict_scenario.get('scenario')}",
|
||||
already_merged_files=len(
|
||||
conflict_scenario.get("already_merged_files", [])
|
||||
),
|
||||
)
|
||||
|
||||
# Use git diff file count as the authoritative totalFiles count
|
||||
# The semantic tracker may not track all files (e.g., test files, config files)
|
||||
# but we want to show the user all files that will be merged
|
||||
@@ -952,6 +1139,16 @@ def handle_merge_preview_command(
|
||||
# Path-mapped files that need AI merge due to renames
|
||||
"pathMappedAIMerges": path_mapped_ai_merges,
|
||||
"totalRenames": len(path_mappings),
|
||||
# Conflict scenario detection for better UX messaging
|
||||
"scenario": conflict_scenario.get("scenario")
|
||||
if conflict_scenario
|
||||
else None,
|
||||
"alreadyMergedFiles": conflict_scenario.get("already_merged_files", [])
|
||||
if conflict_scenario
|
||||
else [],
|
||||
"scenarioMessage": conflict_scenario.get("details")
|
||||
if conflict_scenario
|
||||
else None,
|
||||
},
|
||||
"summary": {
|
||||
# Use git diff count, not semantic tracker count
|
||||
|
||||
+192
-175
@@ -121,6 +121,7 @@ from merge import (
|
||||
FileTimelineTracker,
|
||||
MergeOrchestrator,
|
||||
)
|
||||
from merge.progress import MergeProgressCallback, MergeProgressStage, emit_progress
|
||||
|
||||
MODULE = "workspace"
|
||||
|
||||
@@ -145,6 +146,26 @@ MODULE = "workspace"
|
||||
# - _heuristic_merge
|
||||
|
||||
|
||||
def _create_merge_progress_callback() -> MergeProgressCallback | None:
|
||||
"""
|
||||
Create a progress callback for merge operations when running as a subprocess.
|
||||
|
||||
Returns emit_progress (writing JSON to stdout) only when stdout is piped
|
||||
(i.e., running as a subprocess from the Electron frontend). Returns None
|
||||
when running interactively in a terminal to avoid polluting CLI output.
|
||||
|
||||
This function must be called at runtime (not at import time) to ensure
|
||||
sys.stdout state is accurate.
|
||||
"""
|
||||
import sys
|
||||
|
||||
# Only emit progress JSON when stdout is piped (subprocess mode).
|
||||
# In interactive CLI mode (TTY), progress JSON would clutter the output.
|
||||
if not sys.stdout.isatty():
|
||||
return emit_progress
|
||||
return None
|
||||
|
||||
|
||||
def merge_existing_build(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
@@ -252,10 +273,11 @@ def merge_existing_build(
|
||||
had_conflicts = stats.get("conflicts_resolved", 0) > 0
|
||||
ai_assisted = stats.get("ai_assisted", 0) > 0
|
||||
direct_copy = stats.get("direct_copy", False)
|
||||
git_merge_used = stats.get("git_merge", False)
|
||||
|
||||
if had_conflicts or ai_assisted or direct_copy:
|
||||
# AI resolved conflicts, assisted with merges, or direct copy was used
|
||||
# Changes are already written and staged - no need for git merge
|
||||
if had_conflicts or ai_assisted or direct_copy or git_merge_used:
|
||||
# AI resolved conflicts, assisted with merges, git merge was used, or direct copy was used
|
||||
# Changes are already written and staged - no need for additional git merge
|
||||
_print_merge_success(
|
||||
no_commit, stats, spec_name=spec_name, keep_worktree=True
|
||||
)
|
||||
@@ -402,9 +424,20 @@ def _try_smart_merge_inner(
|
||||
no_commit=no_commit,
|
||||
)
|
||||
|
||||
# Create progress callback for subprocess mode (Electron frontend).
|
||||
# Only emits JSON to stdout when piped, not in interactive CLI.
|
||||
progress_callback = _create_merge_progress_callback()
|
||||
|
||||
try:
|
||||
print(muted(" Analyzing changes with intent-aware merge..."))
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.ANALYZING,
|
||||
0,
|
||||
"Starting merge analysis",
|
||||
)
|
||||
|
||||
# Capture worktree state in FileTimelineTracker before merge
|
||||
try:
|
||||
timeline_tracker = FileTimelineTracker(project_dir)
|
||||
@@ -440,6 +473,13 @@ def _try_smart_merge_inner(
|
||||
)
|
||||
|
||||
# Check for git-level conflicts first (branch divergence)
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.DETECTING_CONFLICTS,
|
||||
25,
|
||||
"Checking for git-level conflicts",
|
||||
)
|
||||
|
||||
debug(MODULE, "Checking for git-level conflicts")
|
||||
git_conflicts = _check_git_conflicts(project_dir, spec_name)
|
||||
|
||||
@@ -492,12 +532,11 @@ def _try_smart_merge_inner(
|
||||
# If rebase succeeded and now there are no conflicts,
|
||||
# the diverged_but_no_conflicts path will handle the merge
|
||||
else:
|
||||
# Rebase failed - continue with conflict resolution as before
|
||||
# The AI resolver will handle the conflicts
|
||||
print(
|
||||
warning(
|
||||
" Rebase encountered issues, using AI conflict resolution..."
|
||||
)
|
||||
# Rebase failed (likely due to worktree lock) - continue with merge
|
||||
# Git merge or AI resolver will handle it depending on conflict state
|
||||
debug(
|
||||
MODULE,
|
||||
"Rebase skipped or failed, continuing with merge flow",
|
||||
)
|
||||
|
||||
if git_conflicts.get("has_conflicts"):
|
||||
@@ -518,6 +557,18 @@ def _try_smart_merge_inner(
|
||||
num_conflicts=len(git_conflicts.get("conflicting_files", [])),
|
||||
)
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.RESOLVING,
|
||||
50,
|
||||
f"Resolving {len(git_conflicts.get('conflicting_files', []))} conflicting files with AI",
|
||||
{
|
||||
"conflicts_found": len(
|
||||
git_conflicts.get("conflicting_files", [])
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
# Try to resolve git conflicts with AI
|
||||
resolution_result = _resolve_git_conflicts_with_ai(
|
||||
project_dir,
|
||||
@@ -535,6 +586,19 @@ def _try_smart_merge_inner(
|
||||
resolved_files=resolution_result.get("resolved_files", []),
|
||||
stats=resolution_result.get("stats", {}),
|
||||
)
|
||||
|
||||
if progress_callback is not None:
|
||||
stats = resolution_result.get("stats", {})
|
||||
progress_callback(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
"Merge complete",
|
||||
{
|
||||
"conflicts_found": stats.get("conflicts_resolved", 0),
|
||||
"conflicts_resolved": stats.get("conflicts_resolved", 0),
|
||||
},
|
||||
)
|
||||
|
||||
return resolution_result
|
||||
else:
|
||||
# AI couldn't resolve all conflicts
|
||||
@@ -547,6 +611,19 @@ def _try_smart_merge_inner(
|
||||
resolved_files=resolution_result.get("resolved_files", []),
|
||||
error=resolution_result.get("error"),
|
||||
)
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.ERROR,
|
||||
0,
|
||||
"Some conflicts could not be resolved",
|
||||
{
|
||||
"conflicts_found": len(
|
||||
resolution_result.get("remaining_conflicts", [])
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"conflicts": resolution_result.get("remaining_conflicts", []),
|
||||
@@ -555,148 +632,81 @@ def _try_smart_merge_inner(
|
||||
"error": resolution_result.get("error"),
|
||||
}
|
||||
|
||||
# Check if branches diverged but no actual conflicts (can do direct copy)
|
||||
# Check if branches diverged but no actual conflicts (use git merge)
|
||||
if git_conflicts.get("diverged_but_no_conflicts"):
|
||||
debug(MODULE, "Branches diverged but no conflicts - doing direct file copy")
|
||||
debug(MODULE, "Branches diverged but no conflicts - using git merge")
|
||||
print(muted(" Branches diverged but no conflicts detected"))
|
||||
print(muted(" Copying changed files directly from worktree..."))
|
||||
print(muted(" Using git merge to combine changes..."))
|
||||
|
||||
# Get changed files from spec branch
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
base_branch = git_conflicts.get("base_branch", "main")
|
||||
|
||||
# Get merge-base for diff
|
||||
merge_base_result = run_git(
|
||||
["merge-base", base_branch, spec_branch],
|
||||
# Use git merge --no-commit to combine changes from both branches
|
||||
# Since merge-tree confirmed no conflicts, this should succeed cleanly
|
||||
merge_result = run_git(
|
||||
["merge", "--no-commit", "--no-ff", spec_branch],
|
||||
cwd=project_dir,
|
||||
)
|
||||
merge_base = (
|
||||
merge_base_result.stdout.strip()
|
||||
if merge_base_result.returncode == 0
|
||||
else None
|
||||
)
|
||||
|
||||
if merge_base:
|
||||
# Get list of changed files in spec branch
|
||||
changed_files = _get_changed_files_from_branch(
|
||||
project_dir, merge_base, spec_branch
|
||||
if merge_result.returncode == 0:
|
||||
# Merge succeeded - get list of files that were merged
|
||||
# Use git diff --cached to see what's staged
|
||||
diff_result = run_git(
|
||||
["diff", "--cached", "--name-only"],
|
||||
cwd=project_dir,
|
||||
)
|
||||
merged_files = [
|
||||
f.strip()
|
||||
for f in diff_result.stdout.splitlines()
|
||||
if f.strip() and not _is_auto_claude_file(f.strip())
|
||||
]
|
||||
|
||||
debug_success(
|
||||
MODULE,
|
||||
"Git merge succeeded",
|
||||
merged_files_count=len(merged_files),
|
||||
)
|
||||
|
||||
resolved_files = []
|
||||
skipped_files = [] # Track files that failed to copy
|
||||
files_to_stage = []
|
||||
for file_path, status in changed_files:
|
||||
if _is_auto_claude_file(file_path):
|
||||
continue
|
||||
for file_path in merged_files:
|
||||
print(success(f" ✓ {file_path}"))
|
||||
|
||||
try:
|
||||
target_path = project_dir / file_path
|
||||
|
||||
if status == "D":
|
||||
# Deleted in worktree
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
files_to_stage.append(file_path)
|
||||
resolved_files.append(file_path)
|
||||
print(success(f" ✓ {file_path} (deleted)"))
|
||||
else:
|
||||
# New or modified - copy from spec branch
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if _is_binary_file(file_path):
|
||||
binary_content = _get_binary_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if binary_content is not None:
|
||||
target_path.write_bytes(binary_content)
|
||||
files_to_stage.append(file_path)
|
||||
resolved_files.append(file_path)
|
||||
status_label = (
|
||||
"new file" if status == "A" else "updated"
|
||||
)
|
||||
print(
|
||||
success(f" ✓ {file_path} ({status_label})")
|
||||
)
|
||||
else:
|
||||
skipped_files.append(file_path)
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not retrieve binary content for {file_path}",
|
||||
)
|
||||
else:
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
files_to_stage.append(file_path)
|
||||
resolved_files.append(file_path)
|
||||
status_label = (
|
||||
"new file" if status == "A" else "updated"
|
||||
)
|
||||
print(
|
||||
success(f" ✓ {file_path} ({status_label})")
|
||||
)
|
||||
else:
|
||||
skipped_files.append(file_path)
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not retrieve content for {file_path}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
skipped_files.append(file_path)
|
||||
debug_warning(MODULE, f"Could not copy {file_path}: {e}")
|
||||
|
||||
# Stage all files in a single git add call for efficiency
|
||||
if files_to_stage:
|
||||
add_result = run_git(
|
||||
["add"] + files_to_stage,
|
||||
cwd=project_dir,
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
f"Git merge complete ({len(merged_files)} files)",
|
||||
)
|
||||
if add_result.returncode != 0:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Failed to stage files for direct copy: {add_result.stderr}",
|
||||
)
|
||||
# Return failure - files were written but not staged
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to stage files: {add_result.stderr}",
|
||||
"resolved_files": [],
|
||||
}
|
||||
|
||||
# Build result - check for skipped files to detect partial merges
|
||||
result = {
|
||||
"success": len(skipped_files) == 0,
|
||||
"resolved_files": resolved_files,
|
||||
return {
|
||||
"success": True,
|
||||
"resolved_files": merged_files,
|
||||
"stats": {
|
||||
"files_merged": len(resolved_files),
|
||||
"files_merged": len(merged_files),
|
||||
"conflicts_resolved": 0,
|
||||
"ai_assisted": 0,
|
||||
"auto_merged": len(resolved_files),
|
||||
"direct_copy": True, # Flag indicating direct copy was used
|
||||
"skipped_count": len(skipped_files),
|
||||
"auto_merged": len(merged_files),
|
||||
"git_merge": True, # Flag indicating git merge was used
|
||||
},
|
||||
}
|
||||
if skipped_files:
|
||||
result["skipped_files"] = skipped_files
|
||||
result["partial_success"] = len(resolved_files) > 0
|
||||
print()
|
||||
print(
|
||||
warning(
|
||||
f" ⚠ {len(skipped_files)} file(s) could not be retrieved:"
|
||||
)
|
||||
)
|
||||
for skipped_file in skipped_files:
|
||||
print(muted(f" - {skipped_file}"))
|
||||
print(muted(" These files may need manual review."))
|
||||
return result
|
||||
else:
|
||||
# merge-base failed - branches may not share history
|
||||
# Merge failed unexpectedly - abort and fall back to semantic analysis
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"Could not find merge-base between branches - falling back to semantic analysis",
|
||||
"Git merge failed unexpectedly despite no conflicts detected",
|
||||
stderr=merge_result.stderr[:500] if merge_result.stderr else "",
|
||||
)
|
||||
# Abort the merge to restore clean state
|
||||
abort_result = run_git(["merge", "--abort"], cwd=project_dir)
|
||||
if abort_result.returncode != 0:
|
||||
debug_error(
|
||||
MODULE,
|
||||
"Failed to abort merge - repo may be in inconsistent state",
|
||||
stderr=abort_result.stderr,
|
||||
)
|
||||
return None # Trigger fallback to avoid operating on inconsistent state
|
||||
print(
|
||||
warning(
|
||||
" Git merge failed unexpectedly, falling back to semantic analysis..."
|
||||
)
|
||||
)
|
||||
|
||||
# No git conflicts - proceed with semantic analysis
|
||||
@@ -725,6 +735,14 @@ def _try_smart_merge_inner(
|
||||
|
||||
# All conflicts can be auto-merged or no conflicts
|
||||
print(muted(" All changes compatible, proceeding with merge..."))
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
f"Analysis complete ({files_to_merge} files compatible)",
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"stats": {
|
||||
@@ -737,6 +755,13 @@ def _try_smart_merge_inner(
|
||||
# If smart merge fails, fall back to git
|
||||
import traceback
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.ERROR,
|
||||
0,
|
||||
f"Smart merge error: {e}",
|
||||
)
|
||||
|
||||
print(muted(f" Smart merge error: {e}"))
|
||||
traceback.print_exc()
|
||||
return None
|
||||
@@ -748,14 +773,11 @@ def _rebase_spec_branch(
|
||||
base_branch: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Rebase the spec branch onto the latest base branch.
|
||||
Attempt to rebase the spec branch onto the latest base branch.
|
||||
|
||||
This performs an automatic rebase of the spec branch onto the current
|
||||
base branch (main/develop) to bring it up to date before merging.
|
||||
If conflicts occur during rebase, the function aborts and returns False
|
||||
so that the caller can fall back to AI conflict resolution.
|
||||
|
||||
The function preserves the current HEAD by restoring it after completion.
|
||||
NOTE: This will fail if the spec branch is checked out in a worktree,
|
||||
which is the normal case. The caller should handle failure gracefully
|
||||
by falling back to git merge or AI conflict resolution.
|
||||
|
||||
Args:
|
||||
project_dir: The project directory
|
||||
@@ -764,22 +786,36 @@ def _rebase_spec_branch(
|
||||
|
||||
Returns:
|
||||
True if rebase succeeded cleanly or branch was already up-to-date,
|
||||
False if rebase failed due to conflicts or other errors (aborted, no ref movement)
|
||||
False if rebase failed (worktree lock, conflicts, or other errors)
|
||||
"""
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
"Rebasing spec branch",
|
||||
"Attempting to rebase spec branch",
|
||||
spec_branch=spec_branch,
|
||||
base_branch=base_branch,
|
||||
)
|
||||
|
||||
# Save original branch to restore after rebase (HIGH: prevents leaving repo on spec branch)
|
||||
# Check if spec branch is used by a worktree (common case)
|
||||
# In this case, we can't checkout/rebase from the main repo
|
||||
worktree_list_result = run_git(["worktree", "list", "--porcelain"], cwd=project_dir)
|
||||
if worktree_list_result.returncode == 0:
|
||||
# Check if spec_branch is in use by a worktree
|
||||
output = worktree_list_result.stdout
|
||||
if f"branch refs/heads/{spec_branch}" in output:
|
||||
debug(
|
||||
MODULE,
|
||||
"Spec branch is checked out in a worktree - skipping rebase",
|
||||
spec_branch=spec_branch,
|
||||
)
|
||||
# This is expected - return False to let caller use git merge instead
|
||||
return False
|
||||
|
||||
# Save original branch to restore after rebase
|
||||
original_branch_result = run_git(
|
||||
["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir
|
||||
)
|
||||
# Check returncode and validate stdout before using original_branch
|
||||
if original_branch_result.returncode != 0:
|
||||
debug_error(
|
||||
MODULE,
|
||||
@@ -795,7 +831,6 @@ def _rebase_spec_branch(
|
||||
)
|
||||
return False
|
||||
|
||||
# Save current state for recovery
|
||||
# Get the current commit of spec_branch before rebase
|
||||
before_commit_result = run_git(["rev-parse", spec_branch], cwd=project_dir)
|
||||
if before_commit_result.returncode != 0:
|
||||
@@ -804,8 +839,6 @@ def _rebase_spec_branch(
|
||||
"Could not get spec branch commit before rebase",
|
||||
stderr=before_commit_result.stderr,
|
||||
)
|
||||
# Restore original branch before returning
|
||||
run_git(["checkout", original_branch], cwd=project_dir)
|
||||
return False
|
||||
before_commit = before_commit_result.stdout.strip()
|
||||
|
||||
@@ -813,22 +846,18 @@ def _rebase_spec_branch(
|
||||
print(muted(f" Rebasing {spec_branch} onto {base_branch}..."))
|
||||
|
||||
try:
|
||||
# Perform the rebase using safe/standard invocation:
|
||||
# 1. Checkout the spec branch first
|
||||
# 2. Run standard rebase (no strategy options - let conflicts stop the rebase)
|
||||
# If conflicts occur, we'll abort and let AI handle them during merge
|
||||
# Try to checkout the spec branch
|
||||
checkout_result = run_git(["checkout", spec_branch], cwd=project_dir)
|
||||
if checkout_result.returncode != 0:
|
||||
debug_error(
|
||||
# Checkout failed - likely due to worktree lock
|
||||
debug(
|
||||
MODULE,
|
||||
"Could not checkout spec branch for rebase",
|
||||
stderr=checkout_result.stderr,
|
||||
"Could not checkout spec branch for rebase (likely worktree lock)",
|
||||
stderr=checkout_result.stderr[:200] if checkout_result.stderr else "",
|
||||
)
|
||||
return False
|
||||
|
||||
# Run standard rebase - will stop on conflicts so we can detect them
|
||||
# Git syntax: git rebase [options] <upstream>
|
||||
# where <upstream> is the branch to rebase onto
|
||||
# Run standard rebase
|
||||
rebase_result = run_git(
|
||||
["rebase", base_branch],
|
||||
cwd=project_dir,
|
||||
@@ -838,9 +867,6 @@ def _rebase_spec_branch(
|
||||
# Rebase failed - check if it was due to conflicts
|
||||
status_result = run_git(["status", "--porcelain"], cwd=project_dir)
|
||||
|
||||
# MEDIUM: Properly parse git status output for conflict markers
|
||||
# Git status --porcelain uses two-character status codes:
|
||||
# UU = both modified, AA = both added, DD = both deleted, etc.
|
||||
has_unmerged = any(
|
||||
line[:2] in ("UU", "AA", "DD", "AU", "UA", "DU", "UD")
|
||||
for line in status_result.stdout.splitlines()
|
||||
@@ -848,7 +874,6 @@ def _rebase_spec_branch(
|
||||
)
|
||||
|
||||
# Abort the rebase to return to clean state
|
||||
# NEW-002: If abort fails, immediately return False (repo in bad state)
|
||||
abort_result = run_git(["rebase", "--abort"], cwd=project_dir)
|
||||
if abort_result.returncode != 0:
|
||||
debug_error(
|
||||
@@ -856,19 +881,16 @@ def _rebase_spec_branch(
|
||||
"Failed to abort rebase - repo may be in inconsistent state",
|
||||
stderr=abort_result.stderr,
|
||||
)
|
||||
return False # Abort failed - cannot safely continue
|
||||
|
||||
if has_unmerged:
|
||||
# Rebase failed due to conflicts - we aborted, so no ref movement happened
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"Rebase encountered conflicts - aborted, will use AI conflict resolution",
|
||||
stderr=rebase_result.stderr[:200] if rebase_result.stderr else "",
|
||||
)
|
||||
# Return False since we aborted - no rebase occurred, caller should use AI
|
||||
return False
|
||||
|
||||
# Other error (not conflict-related)
|
||||
if has_unmerged:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"Rebase encountered conflicts - aborted, will use alternative merge",
|
||||
stderr=rebase_result.stderr[:200] if rebase_result.stderr else "",
|
||||
)
|
||||
return False
|
||||
|
||||
debug_error(
|
||||
MODULE,
|
||||
"Rebase failed with unexpected error",
|
||||
@@ -882,9 +904,7 @@ def _rebase_spec_branch(
|
||||
if after_commit_result.returncode == 0:
|
||||
after_commit_hash = after_commit_result.stdout.strip()
|
||||
|
||||
# Verify the branch actually moved (commit changed)
|
||||
if before_commit == after_commit_hash:
|
||||
# MEDIUM: Branch already up-to-date is a success condition, not failure
|
||||
debug(
|
||||
MODULE,
|
||||
"Branch already up-to-date, no rebase needed",
|
||||
@@ -904,8 +924,7 @@ def _rebase_spec_branch(
|
||||
debug_error(MODULE, "Could not verify spec branch commit after rebase")
|
||||
return False
|
||||
finally:
|
||||
# HIGH: Always restore original branch, even on error/exception
|
||||
# NEW-001: Log restoration failure (cannot modify return from finally block)
|
||||
# Always restore original branch
|
||||
if original_branch:
|
||||
restore_result = run_git(["checkout", original_branch], cwd=project_dir)
|
||||
if restore_result.returncode != 0:
|
||||
@@ -914,8 +933,6 @@ def _rebase_spec_branch(
|
||||
f"Failed to restore original branch '{original_branch}'",
|
||||
stderr=restore_result.stderr,
|
||||
)
|
||||
# Note: Cannot modify return value from finally block,
|
||||
# but restoration failure is rare and non-critical (user can manually switch back)
|
||||
|
||||
|
||||
def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
|
||||
@@ -17,6 +17,7 @@ import logging
|
||||
from .ai_resolver import AIResolver
|
||||
from .auto_merger import AutoMerger, MergeContext
|
||||
from .file_merger import apply_ai_merge, extract_location_content
|
||||
from .progress import MergeProgressCallback, MergeProgressStage
|
||||
from .types import (
|
||||
ConflictRegion,
|
||||
ConflictSeverity,
|
||||
@@ -60,6 +61,7 @@ class ConflictResolver:
|
||||
baseline_content: str,
|
||||
task_snapshots: list[TaskSnapshot],
|
||||
conflicts: list[ConflictRegion],
|
||||
progress_callback: MergeProgressCallback | None = None,
|
||||
) -> MergeResult:
|
||||
"""
|
||||
Resolve conflicts using AutoMerger and AIResolver.
|
||||
@@ -69,6 +71,8 @@ class ConflictResolver:
|
||||
baseline_content: Original file content
|
||||
task_snapshots: Snapshots from all tasks modifying this file
|
||||
conflicts: List of detected conflicts
|
||||
progress_callback: Optional callback for emitting per-conflict
|
||||
resolution progress with details about current file and conflict count
|
||||
|
||||
Returns:
|
||||
MergeResult with resolution details
|
||||
@@ -78,8 +82,22 @@ class ConflictResolver:
|
||||
remaining: list[ConflictRegion] = []
|
||||
ai_calls = 0
|
||||
tokens_used = 0
|
||||
total_conflicts = len(conflicts)
|
||||
|
||||
for conflict in conflicts:
|
||||
for idx, conflict in enumerate(conflicts):
|
||||
if progress_callback:
|
||||
# Emit per-conflict progress within the resolving stage (50-75%)
|
||||
conflict_percent = 50 + int((idx / max(total_conflicts, 1)) * 25)
|
||||
progress_callback(
|
||||
stage=MergeProgressStage.RESOLVING,
|
||||
percent=conflict_percent,
|
||||
message=f"Resolving conflict {idx + 1}/{total_conflicts} in {file_path}",
|
||||
details={
|
||||
"current_file": file_path,
|
||||
"conflicts_found": total_conflicts,
|
||||
"conflicts_resolved": len(resolved),
|
||||
},
|
||||
)
|
||||
# Try auto-merge first
|
||||
if conflict.can_auto_merge and conflict.merge_strategy:
|
||||
context = MergeContext(
|
||||
|
||||
@@ -18,6 +18,7 @@ import logging
|
||||
from .conflict_detector import ConflictDetector
|
||||
from .conflict_resolver import ConflictResolver
|
||||
from .file_merger import apply_single_task_changes, combine_non_conflicting_changes
|
||||
from .progress import MergeProgressCallback, MergeProgressStage
|
||||
from .types import (
|
||||
ChangeType,
|
||||
FileAnalysis,
|
||||
@@ -57,6 +58,7 @@ class MergePipeline:
|
||||
file_path: str,
|
||||
baseline_content: str,
|
||||
task_snapshots: list[TaskSnapshot],
|
||||
progress_callback: MergeProgressCallback | None = None,
|
||||
) -> MergeResult:
|
||||
"""
|
||||
Merge changes from multiple tasks for a single file.
|
||||
@@ -65,6 +67,8 @@ class MergePipeline:
|
||||
file_path: Path to the file
|
||||
baseline_content: Original baseline content
|
||||
task_snapshots: Snapshots from tasks that modified this file
|
||||
progress_callback: Optional callback for emitting per-file progress
|
||||
within the 'resolving' stage (50-75% range)
|
||||
|
||||
Returns:
|
||||
MergeResult with merged content or conflict info
|
||||
@@ -72,6 +76,14 @@ class MergePipeline:
|
||||
task_ids = [s.task_id for s in task_snapshots]
|
||||
logger.info(f"Merging {file_path} with {len(task_snapshots)} task(s)")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
stage=MergeProgressStage.RESOLVING,
|
||||
percent=50,
|
||||
message=f"Merging file: {file_path}",
|
||||
details={"current_file": file_path},
|
||||
)
|
||||
|
||||
# If only one task modified the file, no conflict possible
|
||||
if len(task_snapshots) == 1:
|
||||
snapshot = task_snapshots[0]
|
||||
@@ -119,6 +131,7 @@ class MergePipeline:
|
||||
baseline_content=baseline_content,
|
||||
task_snapshots=task_snapshots,
|
||||
conflicts=conflicts,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
def _build_task_analyses(
|
||||
|
||||
@@ -33,6 +33,7 @@ from .merge_pipeline import MergePipeline
|
||||
|
||||
# Re-export models for backwards compatibility
|
||||
from .models import MergeReport, MergeStats, TaskMergeRequest
|
||||
from .progress import MergeProgressCallback, MergeProgressStage
|
||||
from .semantic_analyzer import SemanticAnalyzer
|
||||
from .types import (
|
||||
ConflictRegion,
|
||||
@@ -260,6 +261,7 @@ class MergeOrchestrator:
|
||||
task_id: str,
|
||||
worktree_path: Path | None = None,
|
||||
target_branch: str = "main",
|
||||
progress_callback: MergeProgressCallback | None = None,
|
||||
) -> MergeReport:
|
||||
"""
|
||||
Merge a single task's changes into the target branch.
|
||||
@@ -268,6 +270,8 @@ class MergeOrchestrator:
|
||||
task_id: The task identifier
|
||||
worktree_path: Path to the task's worktree (auto-detected if not provided)
|
||||
target_branch: Branch to merge into
|
||||
progress_callback: Optional callback for progress updates.
|
||||
Called with (stage, percent, message, details) at key pipeline stages.
|
||||
|
||||
Returns:
|
||||
MergeReport with results
|
||||
@@ -284,7 +288,20 @@ class MergeOrchestrator:
|
||||
report = MergeReport(started_at=datetime.now(), tasks_merged=[task_id])
|
||||
start_time = datetime.now()
|
||||
|
||||
def _emit(
|
||||
stage: MergeProgressStage,
|
||||
percent: int,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Emit progress if a callback is provided."""
|
||||
if progress_callback is not None:
|
||||
progress_callback(stage, percent, message, details)
|
||||
|
||||
try:
|
||||
# --- ANALYZING stage (0-25%) ---
|
||||
_emit(MergeProgressStage.ANALYZING, 0, "Starting merge analysis")
|
||||
|
||||
# Find worktree if not provided
|
||||
if worktree_path is None:
|
||||
debug_detailed(MODULE, "Auto-detecting worktree path...")
|
||||
@@ -293,16 +310,23 @@ class MergeOrchestrator:
|
||||
debug_error(MODULE, f"Could not find worktree for task {task_id}")
|
||||
report.success = False
|
||||
report.error = f"Could not find worktree for task {task_id}"
|
||||
_emit(
|
||||
MergeProgressStage.ERROR,
|
||||
0,
|
||||
f"Could not find worktree for task {task_id}",
|
||||
)
|
||||
return report
|
||||
debug_detailed(MODULE, f"Found worktree: {worktree_path}")
|
||||
|
||||
# Ensure evolution data is up to date
|
||||
_emit(MergeProgressStage.ANALYZING, 5, "Loading file evolution data")
|
||||
debug(MODULE, "Refreshing evolution data from git...")
|
||||
self.evolution_tracker.refresh_from_git(
|
||||
task_id, worktree_path, target_branch=target_branch
|
||||
)
|
||||
|
||||
# Get files modified by this task
|
||||
_emit(MergeProgressStage.ANALYZING, 15, "Running semantic analysis")
|
||||
modifications = self.evolution_tracker.get_task_modifications(task_id)
|
||||
debug(
|
||||
MODULE,
|
||||
@@ -312,11 +336,38 @@ class MergeOrchestrator:
|
||||
if not modifications:
|
||||
debug_warning(MODULE, f"No modifications found for task {task_id}")
|
||||
logger.info(f"No modifications found for task {task_id}")
|
||||
_emit(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
"No modifications found",
|
||||
)
|
||||
report.completed_at = datetime.now()
|
||||
return report
|
||||
|
||||
# Process each modified file
|
||||
for file_path, snapshot in modifications:
|
||||
_emit(
|
||||
MergeProgressStage.ANALYZING,
|
||||
25,
|
||||
f"Found {len(modifications)} modified files",
|
||||
)
|
||||
|
||||
# --- DETECTING_CONFLICTS stage (25-50%) ---
|
||||
_emit(
|
||||
MergeProgressStage.DETECTING_CONFLICTS,
|
||||
25,
|
||||
"Detecting conflicts",
|
||||
)
|
||||
|
||||
# --- RESOLVING stage (50-75%) ---
|
||||
total_files = len(modifications)
|
||||
for idx, (file_path, snapshot) in enumerate(modifications):
|
||||
file_percent = 50 + int((idx / max(total_files, 1)) * 25)
|
||||
_emit(
|
||||
MergeProgressStage.RESOLVING,
|
||||
file_percent,
|
||||
f"Merging file {idx + 1}/{total_files}",
|
||||
{"current_file": file_path},
|
||||
)
|
||||
|
||||
debug_detailed(
|
||||
MODULE,
|
||||
f"Processing file: {file_path}",
|
||||
@@ -349,13 +400,31 @@ class MergeOrchestrator:
|
||||
file=file_path,
|
||||
)
|
||||
|
||||
# --- VALIDATING stage (75-100%) ---
|
||||
_emit(
|
||||
MergeProgressStage.VALIDATING,
|
||||
75,
|
||||
"Validating merge results",
|
||||
{
|
||||
"conflicts_found": report.stats.conflicts_detected,
|
||||
"conflicts_resolved": report.stats.conflicts_auto_resolved,
|
||||
},
|
||||
)
|
||||
|
||||
report.success = report.stats.files_failed == 0
|
||||
|
||||
_emit(
|
||||
MergeProgressStage.VALIDATING,
|
||||
90,
|
||||
"Validation complete",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Merge failed for task {task_id}", error=str(e))
|
||||
logger.exception(f"Merge failed for task {task_id}")
|
||||
report.success = False
|
||||
report.error = str(e)
|
||||
_emit(MergeProgressStage.ERROR, 0, f"Merge failed: {e}")
|
||||
|
||||
report.completed_at = datetime.now()
|
||||
report.stats.duration_seconds = (
|
||||
@@ -366,6 +435,18 @@ class MergeOrchestrator:
|
||||
if not self.dry_run:
|
||||
self._save_report(report, task_id)
|
||||
|
||||
# --- COMPLETE stage (100%) ---
|
||||
if report.success:
|
||||
_emit(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
f"Merge complete for {task_id}",
|
||||
{
|
||||
"conflicts_found": report.stats.conflicts_detected,
|
||||
"conflicts_resolved": report.stats.conflicts_auto_resolved,
|
||||
},
|
||||
)
|
||||
|
||||
debug_success(
|
||||
MODULE,
|
||||
f"Merge complete for {task_id}",
|
||||
@@ -382,6 +463,7 @@ class MergeOrchestrator:
|
||||
self,
|
||||
requests: list[TaskMergeRequest],
|
||||
target_branch: str = "main",
|
||||
progress_callback: MergeProgressCallback | None = None,
|
||||
) -> MergeReport:
|
||||
"""
|
||||
Merge multiple tasks' changes.
|
||||
@@ -392,6 +474,8 @@ class MergeOrchestrator:
|
||||
Args:
|
||||
requests: List of merge requests (one per task)
|
||||
target_branch: Branch to merge into
|
||||
progress_callback: Optional callback for progress updates.
|
||||
Called with (stage, percent, message, details) at key pipeline stages.
|
||||
|
||||
Returns:
|
||||
MergeReport with combined results
|
||||
@@ -402,11 +486,33 @@ class MergeOrchestrator:
|
||||
)
|
||||
start_time = datetime.now()
|
||||
|
||||
def _emit(
|
||||
stage: MergeProgressStage,
|
||||
percent: int,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Emit progress if a callback is provided."""
|
||||
if progress_callback is not None:
|
||||
progress_callback(stage, percent, message, details)
|
||||
|
||||
try:
|
||||
# --- ANALYZING stage (0-25%) ---
|
||||
_emit(
|
||||
MergeProgressStage.ANALYZING,
|
||||
0,
|
||||
f"Starting merge analysis for {len(requests)} tasks",
|
||||
)
|
||||
|
||||
# Sort by priority (higher first)
|
||||
requests = sorted(requests, key=lambda r: -r.priority)
|
||||
|
||||
# Refresh evolution data for all tasks
|
||||
_emit(
|
||||
MergeProgressStage.ANALYZING,
|
||||
5,
|
||||
"Loading file evolution data",
|
||||
)
|
||||
for request in requests:
|
||||
if request.worktree_path and request.worktree_path.exists():
|
||||
self.evolution_tracker.refresh_from_git(
|
||||
@@ -416,11 +522,38 @@ class MergeOrchestrator:
|
||||
)
|
||||
|
||||
# Find all files modified by any task
|
||||
_emit(
|
||||
MergeProgressStage.ANALYZING,
|
||||
15,
|
||||
"Running semantic analysis",
|
||||
)
|
||||
task_ids = [r.task_id for r in requests]
|
||||
file_tasks = self.evolution_tracker.get_files_modified_by_tasks(task_ids)
|
||||
|
||||
# Process each file
|
||||
for file_path, modifying_tasks in file_tasks.items():
|
||||
_emit(
|
||||
MergeProgressStage.ANALYZING,
|
||||
25,
|
||||
f"Found {len(file_tasks)} files to merge",
|
||||
)
|
||||
|
||||
# --- DETECTING_CONFLICTS stage (25-50%) ---
|
||||
_emit(
|
||||
MergeProgressStage.DETECTING_CONFLICTS,
|
||||
25,
|
||||
"Detecting conflicts across tasks",
|
||||
)
|
||||
|
||||
# --- RESOLVING stage (50-75%) ---
|
||||
total_files = len(file_tasks)
|
||||
for idx, (file_path, modifying_tasks) in enumerate(file_tasks.items()):
|
||||
file_percent = 50 + int((idx / max(total_files, 1)) * 25)
|
||||
_emit(
|
||||
MergeProgressStage.RESOLVING,
|
||||
file_percent,
|
||||
f"Merging file {idx + 1}/{total_files}",
|
||||
{"current_file": file_path},
|
||||
)
|
||||
|
||||
# Get snapshots from all tasks that modified this file
|
||||
evolution = self.evolution_tracker.get_file_evolution(file_path)
|
||||
if not evolution:
|
||||
@@ -466,8 +599,25 @@ class MergeOrchestrator:
|
||||
report.file_results[file_path] = result
|
||||
self._update_stats(report.stats, result)
|
||||
|
||||
# --- VALIDATING stage (75-100%) ---
|
||||
_emit(
|
||||
MergeProgressStage.VALIDATING,
|
||||
75,
|
||||
"Validating merge results",
|
||||
{
|
||||
"conflicts_found": report.stats.conflicts_detected,
|
||||
"conflicts_resolved": report.stats.conflicts_auto_resolved,
|
||||
},
|
||||
)
|
||||
|
||||
report.success = report.stats.files_failed == 0
|
||||
|
||||
_emit(
|
||||
MergeProgressStage.VALIDATING,
|
||||
90,
|
||||
"Validation complete",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_error(
|
||||
MODULE,
|
||||
@@ -478,6 +628,7 @@ class MergeOrchestrator:
|
||||
logger.exception("Merge failed")
|
||||
report.success = False
|
||||
report.error = str(e)
|
||||
_emit(MergeProgressStage.ERROR, 0, f"Merge failed: {e}")
|
||||
|
||||
report.completed_at = datetime.now()
|
||||
report.stats.duration_seconds = (
|
||||
@@ -489,6 +640,18 @@ class MergeOrchestrator:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self._save_report(report, f"multi_{timestamp}")
|
||||
|
||||
# --- COMPLETE stage (100%) ---
|
||||
if report.success:
|
||||
_emit(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
f"Merge complete for {len(requests)} tasks",
|
||||
{
|
||||
"conflicts_found": report.stats.conflicts_detected,
|
||||
"conflicts_resolved": report.stats.conflicts_auto_resolved,
|
||||
},
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
def _merge_file(
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Merge Progress Emission
|
||||
=======================
|
||||
|
||||
Structured progress event emission for the merge pipeline.
|
||||
|
||||
This module provides the progress reporting infrastructure used by the
|
||||
merge orchestrator to communicate real-time status updates to the
|
||||
Electron frontend via stdout JSON lines.
|
||||
|
||||
Progress events are emitted as JSON lines to stdout with type='progress',
|
||||
allowing the frontend to parse them separately from the final merge result.
|
||||
|
||||
Components:
|
||||
- MergeProgressStage: Enum of pipeline stages
|
||||
- MergeProgressCallback: Protocol for type-safe callback threading
|
||||
- emit_progress: Function to emit structured progress events to stdout
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class MergeProgressStage(Enum):
|
||||
"""
|
||||
Stages of the merge pipeline.
|
||||
|
||||
Each stage corresponds to a phase of the merge process and maps
|
||||
to a percentage range for progress reporting:
|
||||
- ANALYZING: 0-25% — Loading file evolution, running semantic analysis
|
||||
- DETECTING_CONFLICTS: 25-50% — Conflict detection and compatibility checks
|
||||
- RESOLVING: 50-75% — Auto-merge and AI resolution of conflicts
|
||||
- VALIDATING: 75-100% — Final validation of merged results
|
||||
- COMPLETE: 100% — Merge finished successfully
|
||||
- ERROR: N/A — Merge failed with an error
|
||||
"""
|
||||
|
||||
ANALYZING = "analyzing"
|
||||
DETECTING_CONFLICTS = "detecting_conflicts"
|
||||
RESOLVING = "resolving"
|
||||
VALIDATING = "validating"
|
||||
COMPLETE = "complete"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class MergeProgressCallback(Protocol):
|
||||
"""
|
||||
Protocol for type-safe progress callback threading.
|
||||
|
||||
Implementations receive structured progress updates from the merge
|
||||
pipeline stages and can forward them to any output channel.
|
||||
|
||||
Args:
|
||||
stage: Current pipeline stage
|
||||
percent: Progress percentage (0-100)
|
||||
message: Human-readable status message
|
||||
details: Optional additional context (conflicts_found, conflicts_resolved, current_file)
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
stage: MergeProgressStage,
|
||||
percent: int,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
def emit_progress(
|
||||
stage: MergeProgressStage,
|
||||
percent: int,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Emit a progress event as a JSON line to stdout.
|
||||
|
||||
The Electron main process parses these JSON lines from the merge
|
||||
subprocess stdout and forwards them to the renderer via IPC.
|
||||
|
||||
Args:
|
||||
stage: Current pipeline stage
|
||||
percent: Progress percentage (0-100), clamped to valid range
|
||||
message: Human-readable status message
|
||||
details: Optional dict with additional context. Supported keys:
|
||||
- conflicts_found (int): Number of conflicts detected
|
||||
- conflicts_resolved (int): Number of conflicts resolved so far
|
||||
- current_file (str): File currently being processed
|
||||
"""
|
||||
percent = max(0, min(100, percent))
|
||||
|
||||
event: dict[str, Any] = {
|
||||
"type": "progress",
|
||||
"stage": stage.value,
|
||||
"percent": percent,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if details:
|
||||
event["details"] = details
|
||||
|
||||
print(json.dumps(event), flush=True)
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
You are an expert PR reviewer orchestrating a comprehensive, parallel code review. Your role is to analyze the PR, delegate to specialized review agents, and synthesize their findings into a final verdict.
|
||||
|
||||
## CRITICAL: Tool Execution Strategy
|
||||
|
||||
**IMPORTANT: Execute tool calls ONE AT A TIME, waiting for each result before making the next call.**
|
||||
|
||||
When you need to use multiple tools (Read, Grep, Glob, Task):
|
||||
- ✅ Make ONE tool call, wait for the result
|
||||
- ✅ Process the result, then make the NEXT tool call
|
||||
- ❌ Do NOT make multiple tool calls in a single response
|
||||
|
||||
**Why this matters:** Parallel tool execution can cause API errors when some tools fail while others succeed. Sequential execution ensures reliable operation and proper error handling.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**YOU decide which agents to invoke based on YOUR analysis of the PR.** There are no programmatic rules - you evaluate the PR's content, complexity, and risk areas, then delegate to the appropriate specialists.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Auto-Build Framework Dependencies
|
||||
# SDK 0.1.22+ required for custom subagent support (bundles CLI 2.1.19+)
|
||||
# Earlier versions bundled CLI 2.1.9 which didn't properly register --agents flag
|
||||
claude-agent-sdk>=0.1.22
|
||||
# SDK 0.1.25+ required for improved tool use concurrency handling
|
||||
# Earlier versions had 400 errors when tool_use blocks had partial failures
|
||||
claude-agent-sdk>=0.1.25
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# TOML parsing fallback for Python < 3.11
|
||||
|
||||
@@ -10,6 +10,8 @@ Key Features:
|
||||
- Skips re-reviewing bot commits
|
||||
- Implements "cooling off" period to prevent rapid re-reviews
|
||||
- Tracks reviewed commits to avoid duplicate reviews
|
||||
- In-progress tracking to prevent concurrent reviews
|
||||
- Stale review detection with automatic cleanup
|
||||
|
||||
Usage:
|
||||
detector = BotDetector(bot_token="ghp_...")
|
||||
@@ -20,8 +22,16 @@ Usage:
|
||||
print(f"Skipping PR: {reason}")
|
||||
return
|
||||
|
||||
# Mark review as started (prevents concurrent reviews)
|
||||
detector.mark_review_started(pr_number)
|
||||
|
||||
# Perform review...
|
||||
|
||||
# After successful review, mark as reviewed
|
||||
detector.mark_reviewed(pr_number, head_sha)
|
||||
|
||||
# Or if review failed:
|
||||
detector.mark_review_finished(pr_number, success=False)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -55,11 +65,15 @@ class BotDetectionState:
|
||||
# PR number -> last review timestamp (ISO format)
|
||||
last_review_times: dict[int, str] = field(default_factory=dict)
|
||||
|
||||
# PR number -> in-progress review start time (ISO format)
|
||||
in_progress_reviews: dict[int, str] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"reviewed_commits": self.reviewed_commits,
|
||||
"last_review_times": self.last_review_times,
|
||||
"in_progress_reviews": self.in_progress_reviews,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -68,6 +82,7 @@ class BotDetectionState:
|
||||
return cls(
|
||||
reviewed_commits=data.get("reviewed_commits", {}),
|
||||
last_review_times=data.get("last_review_times", {}),
|
||||
in_progress_reviews=data.get("in_progress_reviews", {}),
|
||||
)
|
||||
|
||||
def save(self, state_dir: Path) -> None:
|
||||
@@ -104,11 +119,16 @@ class BotDetector:
|
||||
- 1-minute cooling off period between reviews of same PR (for testing)
|
||||
- Tracks reviewed commit SHAs to avoid duplicate reviews
|
||||
- Identifies bot user from token to skip bot-authored content
|
||||
- In-progress tracking to prevent concurrent reviews
|
||||
- Stale review detection (30-minute timeout)
|
||||
"""
|
||||
|
||||
# Cooling off period in minutes (reduced to 1 for testing large PRs)
|
||||
COOLING_OFF_MINUTES = 1
|
||||
|
||||
# Timeout for in-progress reviews in minutes (after this, review is considered stale/crashed)
|
||||
IN_PROGRESS_TIMEOUT_MINUTES = 30
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_dir: Path,
|
||||
@@ -301,6 +321,104 @@ class BotDetector:
|
||||
reviewed = self.state.reviewed_commits.get(str(pr_number), [])
|
||||
return commit_sha in reviewed
|
||||
|
||||
def is_review_in_progress(self, pr_number: int) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if a review is currently in progress for this PR.
|
||||
|
||||
Also detects stale reviews (started > IN_PROGRESS_TIMEOUT_MINUTES ago).
|
||||
|
||||
Args:
|
||||
pr_number: The PR number
|
||||
|
||||
Returns:
|
||||
Tuple of (is_in_progress, reason_message)
|
||||
"""
|
||||
pr_key = str(pr_number)
|
||||
start_time_str = self.state.in_progress_reviews.get(pr_key)
|
||||
|
||||
if not start_time_str:
|
||||
return False, ""
|
||||
|
||||
try:
|
||||
start_time = datetime.fromisoformat(start_time_str)
|
||||
time_elapsed = datetime.now() - start_time
|
||||
|
||||
# Check if review is stale (timeout exceeded)
|
||||
if time_elapsed > timedelta(minutes=self.IN_PROGRESS_TIMEOUT_MINUTES):
|
||||
# Mark as stale and clear the in-progress state
|
||||
print(
|
||||
f"[BotDetector] Review for PR #{pr_number} is stale "
|
||||
f"(started {int(time_elapsed.total_seconds() / 60)}m ago, "
|
||||
f"timeout: {self.IN_PROGRESS_TIMEOUT_MINUTES}m) - clearing in-progress state",
|
||||
file=sys.stderr,
|
||||
)
|
||||
self.mark_review_finished(pr_number, success=False)
|
||||
return False, ""
|
||||
|
||||
# Review is actively in progress
|
||||
minutes_elapsed = int(time_elapsed.total_seconds() / 60)
|
||||
reason = f"Review already in progress (started {minutes_elapsed}m ago)"
|
||||
print(f"[BotDetector] PR #{pr_number}: {reason}", file=sys.stderr)
|
||||
return True, reason
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
print(
|
||||
f"[BotDetector] Error parsing in-progress start time: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Clear invalid state
|
||||
self.mark_review_finished(pr_number, success=False)
|
||||
return False, ""
|
||||
|
||||
def mark_review_started(self, pr_number: int) -> None:
|
||||
"""
|
||||
Mark a review as started for this PR.
|
||||
|
||||
This should be called when beginning a review to prevent concurrent reviews.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number
|
||||
"""
|
||||
pr_key = str(pr_number)
|
||||
|
||||
# Record start time
|
||||
self.state.in_progress_reviews[pr_key] = datetime.now().isoformat()
|
||||
|
||||
# Save state
|
||||
self.state.save(self.state_dir)
|
||||
|
||||
logger.info(f"[BotDetector] Marked PR #{pr_number} review as started")
|
||||
print(f"[BotDetector] Started review for PR #{pr_number}", file=sys.stderr)
|
||||
|
||||
def mark_review_finished(self, pr_number: int, success: bool = True) -> None:
|
||||
"""
|
||||
Mark a review as finished for this PR.
|
||||
|
||||
This clears the in-progress state. Should be called when review completes
|
||||
(successfully or with error) or when detected as stale.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number
|
||||
success: Whether the review completed successfully
|
||||
"""
|
||||
pr_key = str(pr_number)
|
||||
|
||||
# Clear in-progress state
|
||||
if pr_key in self.state.in_progress_reviews:
|
||||
del self.state.in_progress_reviews[pr_key]
|
||||
|
||||
# Save state
|
||||
self.state.save(self.state_dir)
|
||||
|
||||
status = "successfully" if success else "with error/timeout"
|
||||
logger.info(
|
||||
f"[BotDetector] Marked PR #{pr_number} review as finished ({status})"
|
||||
)
|
||||
print(
|
||||
f"[BotDetector] Finished review for PR #{pr_number} ({status})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
def should_skip_pr_review(
|
||||
self,
|
||||
pr_number: int,
|
||||
@@ -335,13 +453,19 @@ class BotDetector:
|
||||
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
|
||||
return True, reason
|
||||
|
||||
# Check 3: Are we in the cooling off period?
|
||||
# Check 3: Is a review already in progress?
|
||||
is_in_progress, reason = self.is_review_in_progress(pr_number)
|
||||
if is_in_progress:
|
||||
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
|
||||
return True, reason
|
||||
|
||||
# Check 4: Are we in the cooling off period?
|
||||
is_cooling, reason = self.is_within_cooling_off(pr_number)
|
||||
if is_cooling:
|
||||
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
|
||||
return True, reason
|
||||
|
||||
# Check 4: Have we already reviewed this exact commit?
|
||||
# Check 5: Have we already reviewed this exact commit?
|
||||
head_sha = self.get_last_commit_sha(commits) if commits else None
|
||||
if head_sha and self.has_reviewed_commit(pr_number, head_sha):
|
||||
reason = f"Already reviewed commit {head_sha[:8]}"
|
||||
@@ -357,6 +481,7 @@ class BotDetector:
|
||||
Mark a PR as reviewed at a specific commit.
|
||||
|
||||
This should be called after successfully posting a review.
|
||||
Also clears the in-progress state.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number
|
||||
@@ -374,6 +499,10 @@ class BotDetector:
|
||||
# Update last review time
|
||||
self.state.last_review_times[pr_key] = datetime.now().isoformat()
|
||||
|
||||
# Clear in-progress state
|
||||
if pr_key in self.state.in_progress_reviews:
|
||||
del self.state.in_progress_reviews[pr_key]
|
||||
|
||||
# Save state
|
||||
self.state.save(self.state_dir)
|
||||
|
||||
@@ -397,6 +526,9 @@ class BotDetector:
|
||||
if pr_key in self.state.last_review_times:
|
||||
del self.state.last_review_times[pr_key]
|
||||
|
||||
if pr_key in self.state.in_progress_reviews:
|
||||
del self.state.in_progress_reviews[pr_key]
|
||||
|
||||
self.state.save(self.state_dir)
|
||||
|
||||
print(f"[BotDetector] Cleared state for PR #{pr_number}")
|
||||
@@ -412,13 +544,16 @@ class BotDetector:
|
||||
total_reviews = sum(
|
||||
len(commits) for commits in self.state.reviewed_commits.values()
|
||||
)
|
||||
in_progress_count = len(self.state.in_progress_reviews)
|
||||
|
||||
return {
|
||||
"bot_username": self.bot_username,
|
||||
"review_own_prs": self.review_own_prs,
|
||||
"total_prs_tracked": total_prs,
|
||||
"total_reviews_performed": total_reviews,
|
||||
"in_progress_reviews": in_progress_count,
|
||||
"cooling_off_minutes": self.COOLING_OFF_MINUTES,
|
||||
"in_progress_timeout_minutes": self.IN_PROGRESS_TIMEOUT_MINUTES,
|
||||
}
|
||||
|
||||
def cleanup_stale_prs(self, max_age_days: int = 30) -> int:
|
||||
@@ -428,6 +563,9 @@ class BotDetector:
|
||||
This prevents unbounded growth of the state file by cleaning up
|
||||
entries for PRs that are likely closed/merged.
|
||||
|
||||
Also cleans up stale in-progress reviews (reviews that have been
|
||||
in progress for longer than IN_PROGRESS_TIMEOUT_MINUTES).
|
||||
|
||||
Args:
|
||||
max_age_days: Remove PRs not reviewed in this many days (default: 30)
|
||||
|
||||
@@ -435,8 +573,13 @@ class BotDetector:
|
||||
Number of PRs cleaned up
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=max_age_days)
|
||||
in_progress_cutoff = datetime.now() - timedelta(
|
||||
minutes=self.IN_PROGRESS_TIMEOUT_MINUTES
|
||||
)
|
||||
prs_to_remove: list[str] = []
|
||||
stale_in_progress: list[str] = []
|
||||
|
||||
# Find stale reviewed PRs
|
||||
for pr_key, last_review_str in self.state.last_review_times.items():
|
||||
try:
|
||||
last_review = datetime.fromisoformat(last_review_str)
|
||||
@@ -446,18 +589,43 @@ class BotDetector:
|
||||
# Invalid timestamp - mark for removal
|
||||
prs_to_remove.append(pr_key)
|
||||
|
||||
# Find stale in-progress reviews
|
||||
for pr_key, start_time_str in self.state.in_progress_reviews.items():
|
||||
try:
|
||||
start_time = datetime.fromisoformat(start_time_str)
|
||||
if start_time < in_progress_cutoff:
|
||||
stale_in_progress.append(pr_key)
|
||||
except (ValueError, TypeError):
|
||||
# Invalid timestamp - mark for removal
|
||||
stale_in_progress.append(pr_key)
|
||||
|
||||
# Remove stale PRs
|
||||
for pr_key in prs_to_remove:
|
||||
if pr_key in self.state.reviewed_commits:
|
||||
del self.state.reviewed_commits[pr_key]
|
||||
if pr_key in self.state.last_review_times:
|
||||
del self.state.last_review_times[pr_key]
|
||||
if pr_key in self.state.in_progress_reviews:
|
||||
del self.state.in_progress_reviews[pr_key]
|
||||
|
||||
if prs_to_remove:
|
||||
# Remove stale in-progress reviews
|
||||
for pr_key in stale_in_progress:
|
||||
if pr_key in self.state.in_progress_reviews:
|
||||
del self.state.in_progress_reviews[pr_key]
|
||||
|
||||
total_cleaned = len(prs_to_remove) + len(stale_in_progress)
|
||||
|
||||
if total_cleaned > 0:
|
||||
self.state.save(self.state_dir)
|
||||
print(
|
||||
f"[BotDetector] Cleaned up {len(prs_to_remove)} stale PRs "
|
||||
f"(older than {max_age_days} days)"
|
||||
)
|
||||
if prs_to_remove:
|
||||
print(
|
||||
f"[BotDetector] Cleaned up {len(prs_to_remove)} stale PRs "
|
||||
f"(older than {max_age_days} days)"
|
||||
)
|
||||
if stale_in_progress:
|
||||
print(
|
||||
f"[BotDetector] Cleaned up {len(stale_in_progress)} stale in-progress reviews "
|
||||
f"(older than {self.IN_PROGRESS_TIMEOUT_MINUTES} minutes)"
|
||||
)
|
||||
|
||||
return len(prs_to_remove)
|
||||
return total_cleaned
|
||||
|
||||
@@ -19,7 +19,6 @@ from __future__ import annotations
|
||||
import ast
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -825,41 +824,11 @@ class PRContextGatherer:
|
||||
"""
|
||||
Find files related to the changes.
|
||||
|
||||
This includes:
|
||||
- Test files for changed source files
|
||||
- Imported modules and dependencies
|
||||
- Configuration files in the same directory
|
||||
- Related type definition files
|
||||
- Reverse dependencies (files that import changed files)
|
||||
DEPRECATED: LLM agents now discover related files themselves using Read, Grep, and Glob tools.
|
||||
This method returns an empty list - agents have domain expertise to find what's relevant.
|
||||
"""
|
||||
related = set()
|
||||
|
||||
for changed_file in changed_files:
|
||||
path = Path(changed_file.path)
|
||||
|
||||
# Find test files
|
||||
related.update(self._find_test_files(path))
|
||||
|
||||
# Find imported files (for supported languages)
|
||||
if path.suffix in [".ts", ".tsx", ".js", ".jsx", ".py"]:
|
||||
related.update(self._find_imports(changed_file.content, path))
|
||||
|
||||
# Find config files in same directory
|
||||
related.update(self._find_config_files(path.parent))
|
||||
|
||||
# Find type definition files
|
||||
if path.suffix in [".ts", ".tsx"]:
|
||||
related.update(self._find_type_definitions(path))
|
||||
|
||||
# Find reverse dependencies (files that import this file)
|
||||
related.update(self._find_dependents(changed_file.path))
|
||||
|
||||
# Remove files that are already in changed_files
|
||||
changed_paths = {cf.path for cf in changed_files}
|
||||
related = {r for r in related if r not in changed_paths}
|
||||
|
||||
# Use smart prioritization with increased limit (50 instead of 20)
|
||||
return self._prioritize_related_files(related, limit=50)
|
||||
# Return empty list - LLM agents will discover files via their tools
|
||||
return []
|
||||
|
||||
def _find_test_files(self, source_path: Path) -> set[str]:
|
||||
"""Find test files related to a source file."""
|
||||
@@ -1071,170 +1040,35 @@ class PRContextGatherer:
|
||||
"""
|
||||
Find files that import the given file (reverse dependencies).
|
||||
|
||||
Uses pure Python to search for import statements referencing this file.
|
||||
Cross-platform compatible (Windows, macOS, Linux).
|
||||
Limited to prevent performance issues on large codebases.
|
||||
DEPRECATED: LLM agents now discover reverse dependencies themselves using Grep and Read tools.
|
||||
Returns empty set - agents can search the codebase with their domain expertise.
|
||||
|
||||
Args:
|
||||
file_path: Path of the file to find dependents for
|
||||
max_results: Maximum number of dependents to return
|
||||
|
||||
Returns:
|
||||
Set of file paths that import this file.
|
||||
Empty set - LLM agents will discover dependents via Grep tool.
|
||||
"""
|
||||
dependents: set[str] = set()
|
||||
path_obj = Path(file_path)
|
||||
stem = path_obj.stem # e.g., 'helpers' from 'utils/helpers.ts'
|
||||
|
||||
# NOTE: We no longer skip generic filenames like "utils", "types", "index".
|
||||
# The LLM-driven exploration system decides what's relevant based on the PR context.
|
||||
# Widely-used utilities are often the MOST important files to track dependents for.
|
||||
|
||||
# Build regex patterns and file extensions based on file type
|
||||
pattern = None
|
||||
file_extensions = []
|
||||
|
||||
if path_obj.suffix in [".ts", ".tsx", ".js", ".jsx"]:
|
||||
# Match various import styles for JS/TS
|
||||
# from './helpers', from '../utils/helpers', from '@/utils/helpers'
|
||||
# Escape stem for regex safety
|
||||
escaped_stem = re.escape(stem)
|
||||
pattern = re.compile(rf"['\"].*{escaped_stem}['\"]")
|
||||
file_extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
elif path_obj.suffix == ".py":
|
||||
# Match Python imports: from .helpers import, import helpers
|
||||
escaped_stem = re.escape(stem)
|
||||
pattern = re.compile(rf"(from.*{escaped_stem}|import.*{escaped_stem})")
|
||||
file_extensions = [".py"]
|
||||
else:
|
||||
return dependents
|
||||
|
||||
# Directories to exclude
|
||||
exclude_dirs = {
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
}
|
||||
|
||||
# Walk the project directory
|
||||
project_path = Path(self.project_dir)
|
||||
files_checked = 0
|
||||
max_files_to_check = 2000 # Prevent infinite scanning on large codebases
|
||||
|
||||
try:
|
||||
for root, dirs, files in os.walk(project_path):
|
||||
# Modify dirs in-place to exclude certain directories
|
||||
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
||||
|
||||
for filename in files:
|
||||
# Check if we've hit the file limit
|
||||
if files_checked >= max_files_to_check:
|
||||
safe_print(
|
||||
f"[Context] File scan limit ({max_files_to_check}) reached for {file_path}. "
|
||||
f"Found {len(dependents)} dependents. "
|
||||
f"LLM agents can explore additional callers if needed via Read/Grep tools."
|
||||
)
|
||||
return dependents
|
||||
|
||||
# Check if file has the right extension
|
||||
if not any(filename.endswith(ext) for ext in file_extensions):
|
||||
continue
|
||||
|
||||
file_full_path = Path(root) / filename
|
||||
files_checked += 1
|
||||
|
||||
# Get relative path from project root
|
||||
try:
|
||||
relative_path = file_full_path.relative_to(project_path)
|
||||
relative_path_str = str(relative_path).replace("\\", "/")
|
||||
|
||||
# Don't include the file itself
|
||||
if relative_path_str == file_path:
|
||||
continue
|
||||
|
||||
# Search for the pattern in the file
|
||||
try:
|
||||
with open(
|
||||
file_full_path, encoding="utf-8", errors="ignore"
|
||||
) as f:
|
||||
content = f.read()
|
||||
if pattern.search(content):
|
||||
dependents.add(relative_path_str)
|
||||
if len(dependents) >= max_results:
|
||||
return dependents
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# Skip files that can't be read
|
||||
continue
|
||||
|
||||
except ValueError:
|
||||
# File is not relative to project_path, skip it
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
safe_print(f"[Context] Error finding dependents: {e}")
|
||||
|
||||
return dependents
|
||||
# Return empty set - LLM agents will use Grep to find importers when needed
|
||||
return set()
|
||||
|
||||
def _prioritize_related_files(self, files: set[str], limit: int = 50) -> list[str]:
|
||||
"""
|
||||
Prioritize related files by relevance.
|
||||
|
||||
Priority order:
|
||||
1. Test files (most important for review context)
|
||||
2. Type definition files (.d.ts)
|
||||
3. Configuration files
|
||||
4. Direct imports/dependents
|
||||
5. Other files
|
||||
DEPRECATED: LLM agents now prioritize exploration based on their domain expertise.
|
||||
Returns empty list since _find_related_files no longer populates files.
|
||||
|
||||
Args:
|
||||
files: Set of file paths to prioritize
|
||||
limit: Maximum number of files to return
|
||||
|
||||
Returns:
|
||||
List of files sorted by priority, limited to `limit`.
|
||||
Empty list - LLM agents handle prioritization via their tools.
|
||||
"""
|
||||
test_files = []
|
||||
type_files = []
|
||||
config_files = []
|
||||
other_files = []
|
||||
|
||||
for f in files:
|
||||
path = Path(f)
|
||||
name_lower = path.name.lower()
|
||||
|
||||
# Test files
|
||||
if (
|
||||
".test." in name_lower
|
||||
or ".spec." in name_lower
|
||||
or name_lower.startswith("test_")
|
||||
or name_lower.endswith("_test.py")
|
||||
or "__tests__" in f
|
||||
):
|
||||
test_files.append(f)
|
||||
# Type definition files
|
||||
elif name_lower.endswith(".d.ts") or "types" in name_lower:
|
||||
type_files.append(f)
|
||||
# Config files
|
||||
elif name_lower in [
|
||||
n.lower() for n in CONFIG_FILE_NAMES
|
||||
] or name_lower.endswith((".config.js", ".config.ts", "rc", "rc.json")):
|
||||
config_files.append(f)
|
||||
else:
|
||||
other_files.append(f)
|
||||
|
||||
# Sort within each category alphabetically for consistency, then combine
|
||||
prioritized = (
|
||||
sorted(test_files)
|
||||
+ sorted(type_files)
|
||||
+ sorted(config_files)
|
||||
+ sorted(other_files)
|
||||
)
|
||||
|
||||
return prioritized[:limit]
|
||||
# Return empty list - LLM agents will prioritize exploration themselves
|
||||
return []
|
||||
|
||||
def _load_json_safe(self, filename: str) -> dict | None:
|
||||
"""
|
||||
@@ -1462,59 +1296,18 @@ class PRContextGatherer:
|
||||
"""
|
||||
Find files related to the changes using a specific project root.
|
||||
|
||||
This static method allows finding related files AFTER a worktree
|
||||
has been created, ensuring files exist in the worktree filesystem.
|
||||
DEPRECATED: LLM agents now discover related files themselves using Read, Grep, and Glob tools.
|
||||
This method returns an empty list - agents have domain expertise to find what's relevant.
|
||||
|
||||
Args:
|
||||
changed_files: List of changed files from the PR
|
||||
project_root: Path to search for related files (e.g., worktree path)
|
||||
|
||||
Returns:
|
||||
List of related file paths (relative to project root)
|
||||
Empty list - LLM agents will discover files via their tools.
|
||||
"""
|
||||
related: set[str] = set()
|
||||
|
||||
for changed_file in changed_files:
|
||||
path = Path(changed_file.path)
|
||||
|
||||
# Find test files
|
||||
test_patterns = [
|
||||
# Jest/Vitest patterns
|
||||
path.parent / f"{path.stem}.test{path.suffix}",
|
||||
path.parent / f"{path.stem}.spec{path.suffix}",
|
||||
path.parent / "__tests__" / f"{path.name}",
|
||||
# Python patterns
|
||||
path.parent / f"test_{path.stem}.py",
|
||||
path.parent / f"{path.stem}_test.py",
|
||||
# Go patterns
|
||||
path.parent / f"{path.stem}_test.go",
|
||||
]
|
||||
|
||||
for test_path in test_patterns:
|
||||
full_path = project_root / test_path
|
||||
if full_path.exists() and full_path.is_file():
|
||||
related.add(str(test_path))
|
||||
|
||||
# Find config files in same directory
|
||||
for name in CONFIG_FILE_NAMES:
|
||||
config_path = path.parent / name
|
||||
full_path = project_root / config_path
|
||||
if full_path.exists() and full_path.is_file():
|
||||
related.add(str(config_path))
|
||||
|
||||
# Find type definition files
|
||||
if path.suffix in [".ts", ".tsx"]:
|
||||
type_def = path.parent / f"{path.stem}.d.ts"
|
||||
full_path = project_root / type_def
|
||||
if full_path.exists() and full_path.is_file():
|
||||
related.add(str(type_def))
|
||||
|
||||
# Remove files that are already in changed_files
|
||||
changed_paths = {cf.path for cf in changed_files}
|
||||
related = {r for r in related if r not in changed_paths}
|
||||
|
||||
# Limit to 50 most relevant files (increased from 20)
|
||||
return sorted(related)[:50]
|
||||
# Return empty list - LLM agents will discover files via their tools
|
||||
return []
|
||||
|
||||
|
||||
class FollowupContextGatherer:
|
||||
|
||||
@@ -396,9 +396,15 @@ class GitHubOrchestrator:
|
||||
# No existing review found, create skip result
|
||||
return await self._create_skip_result(pr_number, skip_reason)
|
||||
else:
|
||||
# For other skip reasons (bot-authored, cooling off), create a skip result
|
||||
# For other skip reasons (bot-authored, cooling off, in-progress), create a skip result
|
||||
return await self._create_skip_result(pr_number, skip_reason)
|
||||
|
||||
# Mark review as started (prevents concurrent reviews)
|
||||
self.bot_detector.mark_review_started(pr_number)
|
||||
safe_print(
|
||||
f"[BOT DETECTION] Marked PR #{pr_number} review as started", flush=True
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"analyzing", 30, "Running multi-pass review...", pr_number=pr_number
|
||||
)
|
||||
@@ -572,6 +578,13 @@ class GitHubOrchestrator:
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
# Mark review as finished with error
|
||||
self.bot_detector.mark_review_finished(pr_number, success=False)
|
||||
safe_print(
|
||||
f"[BOT DETECTION] Marked PR #{pr_number} review as finished (error)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Log full exception details for debugging
|
||||
error_details = f"{type(e).__name__}: {e}"
|
||||
full_traceback = traceback.format_exc()
|
||||
@@ -634,6 +647,13 @@ class GitHubOrchestrator:
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# Mark review as started (prevents concurrent reviews)
|
||||
self.bot_detector.mark_review_started(pr_number)
|
||||
safe_print(
|
||||
f"[BOT DETECTION] Marked PR #{pr_number} follow-up review as started",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Import here to avoid circular imports at module level
|
||||
try:
|
||||
@@ -956,6 +976,13 @@ class GitHubOrchestrator:
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Mark review as finished with error
|
||||
self.bot_detector.mark_review_finished(pr_number, success=False)
|
||||
safe_print(
|
||||
f"[BOT DETECTION] Marked PR #{pr_number} follow-up review as finished (error)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
result = PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
repo=self.config.repo,
|
||||
|
||||
@@ -29,7 +29,7 @@ from claude_agent_sdk import AgentDefinition
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget, resolve_model_id
|
||||
from ..context_gatherer import PRContext, PRContextGatherer, _validate_git_ref
|
||||
from ..context_gatherer import PRContext, _validate_git_ref
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
BRANCH_BEHIND_BLOCKER_MSG,
|
||||
@@ -51,7 +51,7 @@ try:
|
||||
)
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import PRContext, PRContextGatherer, _validate_git_ref
|
||||
from context_gatherer import PRContext, _validate_git_ref
|
||||
from core.client import create_client
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
@@ -255,9 +255,8 @@ class ParallelOrchestratorReviewer:
|
||||
"Security specialist. Use for OWASP Top 10, authentication, "
|
||||
"injection, cryptographic issues, and sensitive data exposure. "
|
||||
"Invoke when PR touches auth, API endpoints, user input, database queries, "
|
||||
"or file operations. IMPORTANT: Also check related files listed in the "
|
||||
"PR context - callers may be affected by security changes, and tests "
|
||||
"should verify security behavior."
|
||||
"or file operations. Use Read, Grep, and Glob tools to explore related files, "
|
||||
"callers, and tests as needed."
|
||||
),
|
||||
prompt=with_working_dir(
|
||||
security_prompt, "You are a security expert. Find vulnerabilities."
|
||||
@@ -269,9 +268,8 @@ class ParallelOrchestratorReviewer:
|
||||
description=(
|
||||
"Code quality expert. Use for complexity, duplication, error handling, "
|
||||
"maintainability, and pattern adherence. Invoke when PR has complex logic, "
|
||||
"large functions, or significant business logic changes. IMPORTANT: Check "
|
||||
"related files for pattern consistency - if a pattern is changed, similar "
|
||||
"code elsewhere should be updated too."
|
||||
"large functions, or significant business logic changes. Use Grep to search "
|
||||
"for similar patterns across the codebase for consistency checks."
|
||||
),
|
||||
prompt=with_working_dir(
|
||||
quality_prompt,
|
||||
@@ -285,8 +283,7 @@ class ParallelOrchestratorReviewer:
|
||||
"Logic and correctness specialist. Use for algorithm verification, "
|
||||
"edge cases, state management, and race conditions. Invoke when PR has "
|
||||
"algorithmic changes, data transformations, concurrent operations, or bug fixes. "
|
||||
"IMPORTANT: Check callers and dependents in related files - logic changes "
|
||||
"may break assumptions made by code that uses this file."
|
||||
"Use Grep to find callers and dependents that may be affected by logic changes."
|
||||
),
|
||||
prompt=with_working_dir(
|
||||
logic_prompt, "You are a logic expert. Find correctness issues."
|
||||
@@ -299,8 +296,7 @@ class ParallelOrchestratorReviewer:
|
||||
"Codebase consistency expert. Use for naming conventions, ecosystem fit, "
|
||||
"architectural alignment, and avoiding reinvention. Invoke when PR introduces "
|
||||
"new patterns, large additions, or code that might duplicate existing functionality. "
|
||||
"IMPORTANT: Use related files to understand existing patterns - new code "
|
||||
"should match established conventions in the codebase."
|
||||
"Use Grep and Glob to explore existing patterns and conventions in the codebase."
|
||||
),
|
||||
prompt=with_working_dir(
|
||||
codebase_fit_prompt,
|
||||
@@ -329,7 +325,7 @@ class ParallelOrchestratorReviewer:
|
||||
"Reads the ACTUAL CODE at the finding location with fresh eyes. "
|
||||
"CRITICAL: Invoke for ALL findings after specialist agents complete. "
|
||||
"Can confirm findings as valid OR dismiss them as false positives. "
|
||||
"Check related files for mitigations the original agent missed."
|
||||
"Use Read, Grep, and Glob to check for mitigations the original agent missed."
|
||||
),
|
||||
prompt=with_working_dir(
|
||||
validator_prompt, "You validate whether findings are real issues."
|
||||
@@ -397,80 +393,10 @@ Found {len(context.ai_bot_comments)} comments from AI tools.
|
||||
{chr(10).join(commits_list)}
|
||||
"""
|
||||
|
||||
# Build related files section (CONTEXT-02)
|
||||
# Removed: Related files and import graph sections
|
||||
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
|
||||
related_files_section = ""
|
||||
if context.related_files:
|
||||
# Categorize by type
|
||||
tests = [
|
||||
f
|
||||
for f in context.related_files
|
||||
if ".test." in f
|
||||
or "_test." in f
|
||||
or f.startswith("test")
|
||||
or "/tests/" in f
|
||||
or "\\tests\\" in f
|
||||
]
|
||||
deps = [f for f in context.related_files if f not in tests]
|
||||
|
||||
# Limit to avoid context overflow
|
||||
tests = tests[:15]
|
||||
deps = deps[:15]
|
||||
|
||||
tests_str = ", ".join(f"`{t}`" for t in tests) if tests else "None found"
|
||||
deps_str = ", ".join(f"`{d}`" for d in deps) if deps else "None found"
|
||||
|
||||
related_files_section = f"""
|
||||
### Related Files to Investigate
|
||||
These files are related to the changes (imports, tests, dependents). **Pass relevant files to specialists when delegating.**
|
||||
|
||||
**Tests** ({len(tests)} files): {tests_str}
|
||||
**Dependencies/Callers** ({len(deps)} files): {deps_str}
|
||||
|
||||
**When delegating to specialists, include relevant files:**
|
||||
- **security-reviewer**: Mention files that handle the same data flow
|
||||
- **logic-reviewer**: Mention callers that depend on changed function signatures
|
||||
- **quality-reviewer**: Mention files with similar patterns for consistency check
|
||||
- **codebase-fit-reviewer**: Mention existing implementations of similar features
|
||||
|
||||
Example delegation: "Review the auth changes in login.ts. Also check auth_middleware.ts and auth.test.ts which use this module."
|
||||
"""
|
||||
|
||||
# Build import graph summary (CONTEXT-03)
|
||||
import_graph_section = ""
|
||||
import_entries = []
|
||||
changed_paths = {f.path for f in context.changed_files}
|
||||
|
||||
for file in context.changed_files[:10]: # Limit to 10 files
|
||||
# Find what this file imports (look for related files it references)
|
||||
imports_this = [
|
||||
r
|
||||
for r in context.related_files
|
||||
if r in (file.content or "") and r not in changed_paths
|
||||
][:5]
|
||||
# Find what imports this file (reverse deps in related_files)
|
||||
# Match by filename stem to catch imports without extension
|
||||
file_stem = file.path.split("/")[-1].split(".")[0]
|
||||
imported_by = [
|
||||
r
|
||||
for r in context.related_files
|
||||
if file_stem in r and r not in changed_paths
|
||||
][:5]
|
||||
|
||||
if imports_this or imported_by:
|
||||
entry = f"**{file.path}**"
|
||||
if imports_this:
|
||||
entry += f"\n - Imports: {', '.join(imports_this)}"
|
||||
if imported_by:
|
||||
entry += f"\n - Imported by: {', '.join(imported_by)}"
|
||||
import_entries.append(entry)
|
||||
|
||||
if import_entries:
|
||||
import_graph_section = f"""
|
||||
### Import Relationships
|
||||
How the changed files connect to the codebase:
|
||||
|
||||
{chr(10).join(import_entries[:20])}
|
||||
"""
|
||||
|
||||
pr_context = f"""
|
||||
---
|
||||
@@ -790,25 +716,9 @@ The SDK will run invoked agents in parallel automatically.
|
||||
else self.project_dir
|
||||
)
|
||||
|
||||
# Rescan for related files using the worktree/project root
|
||||
# This fixes the issue where related files were 0 because context gathering
|
||||
# happened BEFORE the worktree was created (PR files didn't exist locally)
|
||||
if context.changed_files:
|
||||
new_related_files = PRContextGatherer.find_related_files_for_root(
|
||||
context.changed_files,
|
||||
project_root,
|
||||
)
|
||||
# Always log rescan result (not gated by DEBUG_MODE)
|
||||
if new_related_files:
|
||||
context.related_files = new_related_files
|
||||
safe_print(
|
||||
f"[PRReview] Rescanned in worktree: found {len(new_related_files)} related files"
|
||||
)
|
||||
else:
|
||||
safe_print(
|
||||
f"[PRReview] Rescanned in worktree: found 0 related files "
|
||||
f"(initial scan found {len(context.related_files)})"
|
||||
)
|
||||
# Removed: Related files rescanning
|
||||
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
|
||||
# No need to pre-scan the codebase programmatically
|
||||
|
||||
# Build orchestrator prompt AFTER worktree creation and related files rescan
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
@@ -839,36 +749,110 @@ The SDK will run invoked agents in parallel automatically.
|
||||
)
|
||||
|
||||
# Run orchestrator session using shared SDK stream processor
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
# Retry logic for tool use concurrency errors
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 2.0 # seconds between retries
|
||||
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Running orchestrator ({model})...",
|
||||
flush=True,
|
||||
)
|
||||
result_text = ""
|
||||
structured_output = None
|
||||
agents_invoked = []
|
||||
msg_count = 0
|
||||
last_error = None
|
||||
|
||||
# Process SDK stream with shared utility
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name="ParallelOrchestrator",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
agent_definitions=agent_defs,
|
||||
)
|
||||
|
||||
# Check for stream processing errors
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[ParallelOrchestrator] SDK stream failed: {stream_result['error']}"
|
||||
for attempt in range(MAX_RETRIES):
|
||||
if attempt > 0:
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Retry attempt {attempt}/{MAX_RETRIES - 1} "
|
||||
f"after tool concurrency error"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_result['error']}"
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Retry {attempt}/{MAX_RETRIES - 1} "
|
||||
f"(tool concurrency error detected)"
|
||||
)
|
||||
# Small delay before retry
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
|
||||
# Recreate client for retry (fresh session)
|
||||
client = self._create_sdk_client(
|
||||
project_root, model, thinking_budget
|
||||
)
|
||||
|
||||
result_text = stream_result["result_text"]
|
||||
structured_output = stream_result["structured_output"]
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
try:
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Running orchestrator ({model})...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Process SDK stream with shared utility
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name="ParallelOrchestrator",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
agent_definitions=agent_defs,
|
||||
)
|
||||
|
||||
error = stream_result.get("error")
|
||||
|
||||
# Check for tool concurrency error specifically
|
||||
if (
|
||||
error == "tool_use_concurrency_error"
|
||||
and attempt < MAX_RETRIES - 1
|
||||
):
|
||||
logger.warning(
|
||||
f"[ParallelOrchestrator] Tool concurrency error on attempt {attempt + 1}, "
|
||||
f"will retry..."
|
||||
)
|
||||
last_error = error
|
||||
continue # Retry
|
||||
|
||||
# Check for other stream processing errors
|
||||
if error:
|
||||
logger.error(
|
||||
f"[ParallelOrchestrator] SDK stream failed: {error}"
|
||||
)
|
||||
raise RuntimeError(f"SDK stream processing failed: {error}")
|
||||
|
||||
# Success - extract results and break retry loop
|
||||
result_text = stream_result["result_text"]
|
||||
structured_output = stream_result["structured_output"]
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
break # Success, exit retry loop
|
||||
|
||||
except Exception as e:
|
||||
# Check if this is a retryable error
|
||||
error_str = str(e).lower()
|
||||
is_retryable = (
|
||||
"400" in error_str
|
||||
or "concurrency" in error_str
|
||||
or "tool_use" in error_str
|
||||
)
|
||||
|
||||
if is_retryable and attempt < MAX_RETRIES - 1:
|
||||
logger.warning(
|
||||
f"[ParallelOrchestrator] Retryable error on attempt {attempt + 1}: {e}"
|
||||
)
|
||||
last_error = str(e)
|
||||
continue # Retry
|
||||
|
||||
# Not retryable or out of retries - re-raise
|
||||
raise
|
||||
else:
|
||||
# All retries exhausted
|
||||
logger.error(
|
||||
f"[ParallelOrchestrator] Failed after {MAX_RETRIES} attempts. "
|
||||
f"Last error: {last_error}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Orchestrator failed after {MAX_RETRIES} retry attempts. "
|
||||
f"Last error: {last_error}"
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"finalizing",
|
||||
@@ -1427,6 +1411,10 @@ The SDK will run invoked agents in parallel automatically.
|
||||
if not findings:
|
||||
return []
|
||||
|
||||
# Retry configuration for API errors
|
||||
MAX_VALIDATION_RETRIES = 2
|
||||
VALIDATOR_MAX_MESSAGES = 200 # Lower limit for validator (simpler task)
|
||||
|
||||
# Build validation prompt with all findings
|
||||
findings_json = []
|
||||
for f in findings:
|
||||
@@ -1466,47 +1454,101 @@ For EACH finding above:
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
|
||||
# Create validator client (inherits worktree filesystem access)
|
||||
try:
|
||||
validator_client = create_client(
|
||||
project_dir=worktree_path,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_finding_validator",
|
||||
max_thinking_tokens=get_thinking_budget("medium"),
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": FindingValidationResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[PRReview] Failed to create validator client: {e}")
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
|
||||
# Run validation
|
||||
try:
|
||||
async with validator_client:
|
||||
await validator_client.query(prompt)
|
||||
|
||||
stream_result = await process_sdk_stream(
|
||||
client=validator_client,
|
||||
context_name="FindingValidator",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
# Retry loop for transient API errors
|
||||
last_error = None
|
||||
for attempt in range(MAX_VALIDATION_RETRIES + 1):
|
||||
if attempt > 0:
|
||||
logger.info(
|
||||
f"[PRReview] Validation retry {attempt}/{MAX_VALIDATION_RETRIES}"
|
||||
)
|
||||
safe_print(
|
||||
f"[FindingValidator] Retry attempt {attempt}/{MAX_VALIDATION_RETRIES}"
|
||||
)
|
||||
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[PRReview] Validation failed: {stream_result['error']}"
|
||||
# Create validator client (inherits worktree filesystem access)
|
||||
try:
|
||||
validator_client = create_client(
|
||||
project_dir=worktree_path,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_finding_validator",
|
||||
max_thinking_tokens=get_thinking_budget("medium"),
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": FindingValidationResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[PRReview] Failed to create validator client: {e}")
|
||||
last_error = e
|
||||
continue # Try again
|
||||
|
||||
# Run validation
|
||||
try:
|
||||
async with validator_client:
|
||||
await validator_client.query(prompt)
|
||||
|
||||
stream_result = await process_sdk_stream(
|
||||
client=validator_client,
|
||||
context_name="FindingValidator",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
max_messages=VALIDATOR_MAX_MESSAGES,
|
||||
)
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
|
||||
structured_output = stream_result.get("structured_output")
|
||||
error = stream_result.get("error")
|
||||
if error:
|
||||
# Check for specific error types that warrant retry
|
||||
error_str = str(error).lower()
|
||||
is_retryable = (
|
||||
"400" in error_str
|
||||
or "concurrency" in error_str
|
||||
or "circuit breaker" in error_str
|
||||
or "tool_use" in error_str
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[PRReview] Validation stream error: {e}")
|
||||
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
|
||||
logger.warning(
|
||||
f"[PRReview] Retryable validation error: {error}"
|
||||
)
|
||||
last_error = Exception(error)
|
||||
continue # Retry
|
||||
|
||||
logger.error(f"[PRReview] Validation failed: {error}")
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
|
||||
structured_output = stream_result.get("structured_output")
|
||||
|
||||
# Success - break out of retry loop
|
||||
if structured_output:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
is_retryable = (
|
||||
"400" in error_str
|
||||
or "concurrency" in error_str
|
||||
or "rate" in error_str
|
||||
)
|
||||
|
||||
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
|
||||
logger.warning(f"[PRReview] Retryable stream error: {e}")
|
||||
last_error = e
|
||||
continue # Retry
|
||||
|
||||
logger.error(f"[PRReview] Validation stream error: {e}")
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
else:
|
||||
# All retries exhausted
|
||||
logger.error(
|
||||
f"[PRReview] Validation failed after {MAX_VALIDATION_RETRIES} retries. "
|
||||
f"Last error: {last_error}"
|
||||
)
|
||||
safe_print(
|
||||
f"[FindingValidator] ERROR: Validation failed after {MAX_VALIDATION_RETRIES} retries"
|
||||
)
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
|
||||
|
||||
@@ -139,18 +139,9 @@ class PRReviewEngine:
|
||||
files_list.append(f"- ... and {len(context.changed_files) - 20} more files")
|
||||
files_str = "\n".join(files_list)
|
||||
|
||||
# NEW: Format related files (imports, tests, etc.)
|
||||
# Removed: Related files section
|
||||
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
|
||||
related_files_str = ""
|
||||
if context.related_files:
|
||||
related_files_list = [f"- `{f}`" for f in context.related_files[:10]]
|
||||
if len(context.related_files) > 10:
|
||||
related_files_list.append(
|
||||
f"- ... and {len(context.related_files) - 10} more"
|
||||
)
|
||||
related_files_str = f"""
|
||||
### Related Files (imports, tests, configs)
|
||||
{chr(10).join(related_files_list)}
|
||||
"""
|
||||
|
||||
# NEW: Format commits for context
|
||||
commits_str = ""
|
||||
|
||||
@@ -25,243 +25,6 @@ logger = logging.getLogger(__name__)
|
||||
# Check if debug mode is enabled
|
||||
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
|
||||
|
||||
# ── TEMPORARY: Per-PR full agent communication logger (v2) ────────────
|
||||
# Writes every message to .auto-claude/github/pr/debug_logs/<context>_<ts>.log
|
||||
# Remove after measurement phase is complete.
|
||||
import datetime as _dt
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
|
||||
# Derive project root dynamically from this file's location
|
||||
# sdk_utils.py is at: apps/backend/runners/github/services/sdk_utils.py
|
||||
# So project root is 5 levels up
|
||||
_PROJECT_ROOT = _Path(__file__).resolve().parent.parent.parent.parent.parent
|
||||
_PR_LOG_DIR = _PROJECT_ROOT / ".auto-claude" / "github" / "pr" / "debug_logs"
|
||||
|
||||
|
||||
class _PRDebugLogger:
|
||||
"""Writes full agent communication to a log file for review.
|
||||
|
||||
Improvements (v2):
|
||||
- System prompt and agent definitions logged at session start
|
||||
- No truncation on thinking, text, tool input, or tool results
|
||||
- No duplicate logging (single structured dump per message)
|
||||
- Empty/whitespace content shown via repr()
|
||||
- Agent attribution via subagent_tool_ids mapping
|
||||
"""
|
||||
|
||||
def __init__(self, context_name: str, model: str | None = None):
|
||||
self._f = None
|
||||
self._subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name
|
||||
|
||||
try:
|
||||
_PR_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self.path = _PR_LOG_DIR / f"{context_name}_{ts}.log"
|
||||
self._f = open(self.path, "w", encoding="utf-8")
|
||||
self._write(
|
||||
f"=== {context_name} Session Started at {_dt.datetime.now().isoformat()} ==="
|
||||
)
|
||||
if model:
|
||||
self._write(f"Model: {model}")
|
||||
self._write("")
|
||||
except OSError as e:
|
||||
# Failed to create directory or open file - logging disabled
|
||||
logger.warning(f"PR debug logger disabled: {e}")
|
||||
self.path = None
|
||||
|
||||
def _write(self, text: str):
|
||||
# Skip logging if file handle was not created successfully
|
||||
if self._f is None:
|
||||
return
|
||||
try:
|
||||
self._f.write(text + "\n")
|
||||
self._f.flush()
|
||||
except (OSError, ValueError) as e:
|
||||
# File write failed (file closed, disk full, etc.) - disable logging
|
||||
logger.warning(f"PR debug logger write failed: {e}")
|
||||
self._f = None
|
||||
|
||||
# ── Session preamble loggers ──────────────────────────────────────
|
||||
|
||||
def log_system_prompt(self, prompt: str):
|
||||
"""Log the full system prompt (no truncation)."""
|
||||
self._write(f"\n{'#' * 80}")
|
||||
self._write("# SYSTEM PROMPT (full orchestrator instructions + PR context)")
|
||||
self._write(f"# Length: {len(prompt)} chars")
|
||||
self._write(f"{'#' * 80}")
|
||||
self._write(prompt)
|
||||
self._write(f"{'#' * 80}\n")
|
||||
|
||||
def log_agent_definitions(self, agents: dict):
|
||||
"""Log all specialist agent definitions (prompts, tools, descriptions)."""
|
||||
self._write(f"\n{'#' * 80}")
|
||||
self._write(f"# AGENT DEFINITIONS ({len(agents)} specialists)")
|
||||
self._write(f"{'#' * 80}")
|
||||
for name, defn in agents.items():
|
||||
self._write(f"\n--- Agent: {name} ---")
|
||||
self._write(f" description: {getattr(defn, 'description', 'N/A')}")
|
||||
self._write(f" model: {getattr(defn, 'model', 'N/A')}")
|
||||
self._write(f" tools: {getattr(defn, 'tools', 'N/A')}")
|
||||
prompt = getattr(defn, "prompt", "")
|
||||
self._write(f" prompt ({len(prompt)} chars):")
|
||||
self._write(prompt)
|
||||
self._write(f"{'#' * 80}\n")
|
||||
|
||||
# ── Agent attribution ─────────────────────────────────────────────
|
||||
|
||||
def set_subagent_mapping(self, mapping: dict[str, str]):
|
||||
"""Update the tool_id -> agent_name mapping for attribution."""
|
||||
self._subagent_tool_ids = mapping
|
||||
|
||||
def _get_agent_label(self, tool_id: str) -> str:
|
||||
"""Return agent label if this tool_id belongs to a known subagent."""
|
||||
agent = self._subagent_tool_ids.get(tool_id)
|
||||
return f" [Agent:{agent}]" if agent else ""
|
||||
|
||||
# ── Per-message logger (single structured dump) ───────────────────
|
||||
|
||||
def log_message(self, msg_count: int, msg_type: str, msg: object):
|
||||
self._write(f"\n{'=' * 80}")
|
||||
self._write(f"--- Message #{msg_count} [{msg_type}] ---")
|
||||
self._write(f"{'=' * 80}")
|
||||
self._dump_raw(msg)
|
||||
|
||||
def _dump_raw(self, msg: object, indent: int = 0):
|
||||
"""Dump full raw message content recursively — NO truncation."""
|
||||
prefix = " " * indent
|
||||
# Content blocks
|
||||
if hasattr(msg, "content"):
|
||||
content = msg.content
|
||||
if isinstance(content, list):
|
||||
self._write(f"{prefix}[content] ({len(content)} blocks):")
|
||||
for i, block in enumerate(content):
|
||||
block_type = type(block).__name__
|
||||
self._write(f"{prefix} [{i}] {block_type}:")
|
||||
self._dump_block(block, indent + 2)
|
||||
elif isinstance(content, str):
|
||||
if not content or content.isspace():
|
||||
self._write(
|
||||
f"{prefix}[content] (string, {len(content)} chars): {repr(content)}"
|
||||
)
|
||||
else:
|
||||
self._write(f"{prefix}[content] (string, {len(content)} chars):")
|
||||
self._write(content)
|
||||
else:
|
||||
self._write(f"{prefix}[content] ({type(content).__name__}):")
|
||||
self._write(f"{prefix} {str(content)}")
|
||||
|
||||
# Role / type
|
||||
if hasattr(msg, "role"):
|
||||
self._write(f"{prefix}[role] {msg.role}")
|
||||
if hasattr(msg, "type") and not hasattr(msg, "content"):
|
||||
self._write(f"{prefix}[type] {msg.type}")
|
||||
|
||||
# Structured output
|
||||
if hasattr(msg, "structured_output") and msg.structured_output:
|
||||
self._write(f"{prefix}[structured_output]:")
|
||||
try:
|
||||
self._write(_json.dumps(msg.structured_output, indent=2, default=str))
|
||||
except Exception:
|
||||
self._write(f"{prefix} {str(msg.structured_output)}")
|
||||
|
||||
# Result message fields
|
||||
if hasattr(msg, "subtype"):
|
||||
self._write(f"{prefix}[subtype] {msg.subtype}")
|
||||
if hasattr(msg, "is_error"):
|
||||
self._write(f"{prefix}[is_error] {msg.is_error}")
|
||||
if hasattr(msg, "duration_ms"):
|
||||
self._write(f"{prefix}[duration_ms] {msg.duration_ms}")
|
||||
if hasattr(msg, "session_id"):
|
||||
self._write(f"{prefix}[session_id] {msg.session_id}")
|
||||
|
||||
# Catch-all for messages without content blocks
|
||||
for attr in ("text", "thinking", "name", "id", "input", "tool_use_id"):
|
||||
if hasattr(msg, attr) and not hasattr(msg, "content"):
|
||||
val = getattr(msg, attr)
|
||||
if val is not None:
|
||||
self._write(f"{prefix}[{attr}] {str(val)}")
|
||||
|
||||
def _dump_block(self, block: object, indent: int = 0):
|
||||
"""Dump a single content block — NO truncation."""
|
||||
prefix = " " * indent
|
||||
block_type = getattr(block, "type", type(block).__name__)
|
||||
|
||||
if block_type in ("text", "TextBlock") and hasattr(block, "text"):
|
||||
text = block.text
|
||||
if not text or text.isspace():
|
||||
self._write(f"{prefix}[text] ({len(text)} chars): {repr(text)}")
|
||||
else:
|
||||
self._write(f"{prefix}[text] ({len(text)} chars):")
|
||||
self._write(text)
|
||||
|
||||
elif block_type in ("thinking", "ThinkingBlock") and hasattr(block, "thinking"):
|
||||
text = block.thinking or getattr(block, "text", "")
|
||||
self._write(f"{prefix}[thinking] ({len(text)} chars):")
|
||||
self._write(text)
|
||||
|
||||
elif block_type in ("tool_use", "ToolUseBlock"):
|
||||
tool_name = getattr(block, "name", "unknown")
|
||||
tool_id = getattr(block, "id", "unknown")
|
||||
tool_input = getattr(block, "input", {})
|
||||
agent_label = self._get_agent_label(tool_id)
|
||||
self._write(f"{prefix}[tool_use] {tool_name} (id={tool_id}){agent_label}")
|
||||
try:
|
||||
self._write(_json.dumps(tool_input, indent=2, default=str))
|
||||
except Exception:
|
||||
self._write(str(tool_input))
|
||||
|
||||
elif block_type in ("tool_result", "ToolResultBlock"):
|
||||
tool_id = getattr(block, "tool_use_id", "unknown")
|
||||
is_error = getattr(block, "is_error", False)
|
||||
result = getattr(block, "content", "")
|
||||
if isinstance(result, list):
|
||||
result = " ".join(str(getattr(c, "text", c)) for c in result)
|
||||
status = "ERROR" if is_error else "OK"
|
||||
agent_label = self._get_agent_label(tool_id)
|
||||
self._write(
|
||||
f"{prefix}[tool_result] (tool_id={tool_id}) {status}{agent_label}"
|
||||
)
|
||||
self._write(str(result))
|
||||
|
||||
else:
|
||||
# Unknown block type — dump everything we can
|
||||
self._write(f"{prefix}[{block_type}] (raw dump):")
|
||||
for attr in dir(block):
|
||||
if not attr.startswith("_"):
|
||||
try:
|
||||
val = getattr(block, attr)
|
||||
if not callable(val):
|
||||
self._write(f"{prefix} {attr}: {str(val)}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Structured output (standalone, for final result) ──────────────
|
||||
|
||||
def log_structured_output(self, output: dict):
|
||||
self._write("[STRUCTURED_OUTPUT]")
|
||||
try:
|
||||
self._write(_json.dumps(output, indent=2, default=str))
|
||||
except Exception:
|
||||
self._write(str(output))
|
||||
|
||||
# ── Session close ─────────────────────────────────────────────────
|
||||
|
||||
def close(self, summary: dict):
|
||||
self._write("\n=== Session Ended ===")
|
||||
self._write(f"Messages: {summary.get('msg_count', '?')}")
|
||||
self._write(f"Agents invoked: {summary.get('agents_invoked', [])}")
|
||||
self._write(f"Error: {summary.get('error')}")
|
||||
self._write(f"Log file: {self.path}")
|
||||
if self._f is not None:
|
||||
try:
|
||||
self._f.close()
|
||||
except OSError as e:
|
||||
logger.warning(f"PR debug logger close failed: {e}")
|
||||
|
||||
|
||||
# ── END TEMPORARY ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _short_model_name(model: str | None) -> str:
|
||||
"""Convert full model name to a short display name for logs.
|
||||
@@ -361,6 +124,49 @@ def _get_tool_detail(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||
return f"Using tool: {tool_name}"
|
||||
|
||||
|
||||
# Circuit breaker threshold - abort if message count exceeds this
|
||||
# Prevents runaway retry loops from consuming unbounded resources
|
||||
MAX_MESSAGE_COUNT = 500
|
||||
|
||||
|
||||
def _is_tool_concurrency_error(text: str) -> bool:
|
||||
"""
|
||||
Detect the specific tool use concurrency error pattern.
|
||||
|
||||
This error occurs when Claude makes multiple parallel tool_use blocks
|
||||
and some fail, corrupting the tool_use/tool_result message pairing.
|
||||
|
||||
Args:
|
||||
text: Text to check for error pattern
|
||||
|
||||
Returns:
|
||||
True if this is the tool concurrency error, False otherwise
|
||||
"""
|
||||
text_lower = text.lower()
|
||||
# Check for the specific error message pattern
|
||||
# Pattern 1: Explicit concurrency or tool_use errors with 400
|
||||
has_400 = "400" in text_lower
|
||||
has_tool = "tool" in text_lower
|
||||
|
||||
if has_400 and has_tool:
|
||||
# Look for specific keywords indicating tool concurrency issues
|
||||
error_keywords = [
|
||||
"concurrency",
|
||||
"tool_use",
|
||||
"tool use",
|
||||
"tool_result",
|
||||
"tool result",
|
||||
]
|
||||
if any(keyword in text_lower for keyword in error_keywords):
|
||||
return True
|
||||
|
||||
# Pattern 2: API error with 400 and tool mention
|
||||
if "api error" in text_lower and has_400 and has_tool:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def process_sdk_stream(
|
||||
client: Any,
|
||||
on_thinking: Callable[[str], None] | None = None,
|
||||
@@ -370,8 +176,10 @@ async def process_sdk_stream(
|
||||
on_structured_output: Callable[[dict[str, Any]], None] | None = None,
|
||||
context_name: str = "SDK",
|
||||
model: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
agent_definitions: dict | None = None,
|
||||
max_messages: int | None = None,
|
||||
# Deprecated parameters (kept for backwards compatibility, no longer used)
|
||||
system_prompt: str | None = None, # noqa: ARG001
|
||||
agent_definitions: dict | None = None, # noqa: ARG001
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process SDK response stream with customizable callbacks.
|
||||
@@ -392,8 +200,7 @@ async def process_sdk_stream(
|
||||
on_structured_output: Callback for structured output - receives dict
|
||||
context_name: Name for logging (e.g., "ParallelOrchestrator", "ParallelFollowup")
|
||||
model: Model name for logging (e.g., "claude-sonnet-4-5-20250929")
|
||||
system_prompt: Full system prompt sent to the agent (logged at session start)
|
||||
agent_definitions: Dict of agent name -> AgentDefinition (logged at session start)
|
||||
max_messages: Optional override for max message count circuit breaker (default: MAX_MESSAGE_COUNT)
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
@@ -412,15 +219,11 @@ async def process_sdk_stream(
|
||||
# Track subagent tool IDs to log their results
|
||||
subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name
|
||||
completed_agent_tool_ids: set[str] = set() # tool_ids of completed agents
|
||||
# Track tool concurrency errors for retry logic
|
||||
detected_concurrency_error = False
|
||||
|
||||
# TEMPORARY: per-PR debug file logger
|
||||
_dbg = _PRDebugLogger(context_name, model=model)
|
||||
|
||||
# Log session preamble: system prompt and agent definitions
|
||||
if system_prompt:
|
||||
_dbg.log_system_prompt(system_prompt)
|
||||
if agent_definitions:
|
||||
_dbg.log_agent_definitions(agent_definitions)
|
||||
# Circuit breaker: max messages before aborting
|
||||
message_limit = max_messages if max_messages is not None else MAX_MESSAGE_COUNT
|
||||
|
||||
safe_print(f"[{context_name}] Processing SDK stream...")
|
||||
if DEBUG_MODE:
|
||||
@@ -435,7 +238,17 @@ async def process_sdk_stream(
|
||||
try:
|
||||
msg_type = type(msg).__name__
|
||||
msg_count += 1
|
||||
_dbg.log_message(msg_count, msg_type, msg)
|
||||
|
||||
# CIRCUIT BREAKER: Abort if message count exceeds threshold
|
||||
# This prevents runaway retry loops (e.g., 400 errors causing infinite retries)
|
||||
if msg_count > message_limit:
|
||||
stream_error = (
|
||||
f"Circuit breaker triggered: message count ({msg_count}) "
|
||||
f"exceeded limit ({message_limit}). Possible retry loop detected."
|
||||
)
|
||||
logger.error(f"[{context_name}] {stream_error}")
|
||||
safe_print(f"[{context_name}] ERROR: {stream_error}")
|
||||
break
|
||||
|
||||
# Log progress periodically so user knows AI is working
|
||||
if msg_count - last_progress_log >= PROGRESS_LOG_INTERVAL:
|
||||
@@ -504,7 +317,6 @@ async def process_sdk_stream(
|
||||
agents_invoked.append(agent_name)
|
||||
# Track this tool ID to log its result later
|
||||
subagent_tool_ids[tool_id] = agent_name
|
||||
_dbg.set_subagent_mapping(subagent_tool_ids)
|
||||
# Log with model info if available
|
||||
model_info = f" [{_short_model_name(model)}]" if model else ""
|
||||
safe_print(
|
||||
@@ -611,6 +423,15 @@ async def process_sdk_stream(
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
# Check for tool concurrency error pattern in text output
|
||||
if _is_tool_concurrency_error(block.text):
|
||||
detected_concurrency_error = True
|
||||
logger.warning(
|
||||
f"[{context_name}] Detected tool use concurrency error in response"
|
||||
)
|
||||
safe_print(
|
||||
f"[{context_name}] WARNING: Tool concurrency error detected"
|
||||
)
|
||||
# Always print text content preview (not just in DEBUG_MODE)
|
||||
text_preview = block.text[:500].replace("\n", " ").strip()
|
||||
if text_preview:
|
||||
@@ -655,7 +476,6 @@ async def process_sdk_stream(
|
||||
# Only capture if we don't already have it (avoid duplicates)
|
||||
if structured_output is None:
|
||||
structured_output = msg.structured_output
|
||||
_dbg.log_structured_output(msg.structured_output)
|
||||
safe_print(f"[{context_name}] Received structured output")
|
||||
if on_structured_output:
|
||||
on_structured_output(msg.structured_output)
|
||||
@@ -728,7 +548,14 @@ async def process_sdk_stream(
|
||||
|
||||
safe_print(f"[{context_name}] Session ended. Total messages: {msg_count}")
|
||||
|
||||
result = {
|
||||
# Set error flag if tool concurrency error was detected
|
||||
if detected_concurrency_error and not stream_error:
|
||||
stream_error = "tool_use_concurrency_error"
|
||||
logger.warning(
|
||||
f"[{context_name}] Tool use concurrency error detected - caller should retry"
|
||||
)
|
||||
|
||||
return {
|
||||
"result_text": result_text,
|
||||
"structured_output": structured_output,
|
||||
"agents_invoked": agents_invoked,
|
||||
@@ -736,6 +563,3 @@ async def process_sdk_stream(
|
||||
"subagent_tool_ids": subagent_tool_ids,
|
||||
"error": stream_error,
|
||||
}
|
||||
_dbg.close(result)
|
||||
safe_print(f"[{context_name}] Full debug log: {_dbg.path}")
|
||||
return result
|
||||
|
||||
@@ -532,5 +532,176 @@ class TestGhExecutableDetection:
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestInProgressTracking:
|
||||
"""Test in-progress review tracking."""
|
||||
|
||||
def test_mark_review_started(self, mock_bot_detector, temp_state_dir):
|
||||
"""Test marking review as started."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
|
||||
# Check state
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
start_time_str = mock_bot_detector.state.in_progress_reviews["123"]
|
||||
start_time = datetime.fromisoformat(start_time_str)
|
||||
|
||||
# Should be very recent (within last 5 seconds)
|
||||
time_diff = datetime.now() - start_time
|
||||
assert time_diff.total_seconds() < 5
|
||||
|
||||
# Check persistence
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
assert "123" in loaded.in_progress_reviews
|
||||
|
||||
def test_mark_review_finished_success(self, mock_bot_detector, temp_state_dir):
|
||||
"""Test marking review as finished successfully."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
mock_bot_detector.mark_review_finished(123, success=True)
|
||||
|
||||
# In-progress state should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
# Check persistence
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
assert "123" not in loaded.in_progress_reviews
|
||||
|
||||
def test_mark_review_finished_error(self, mock_bot_detector):
|
||||
"""Test marking review as finished with error."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
mock_bot_detector.mark_review_finished(123, success=False)
|
||||
|
||||
# In-progress state should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
def test_is_review_in_progress_active(self, mock_bot_detector):
|
||||
"""Test detecting active in-progress review."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
|
||||
|
||||
assert is_in_progress is True
|
||||
assert "already in progress" in reason.lower()
|
||||
|
||||
def test_is_review_in_progress_not_started(self, mock_bot_detector):
|
||||
"""Test checking in-progress when review not started."""
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(999)
|
||||
|
||||
assert is_in_progress is False
|
||||
assert reason == ""
|
||||
|
||||
def test_is_review_in_progress_stale(self, mock_bot_detector):
|
||||
"""Test detecting stale in-progress review."""
|
||||
# Set review start time to 31 minutes ago (past timeout)
|
||||
stale_time = datetime.now() - timedelta(minutes=31)
|
||||
mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat()
|
||||
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
|
||||
|
||||
# Should detect as stale and clear it
|
||||
assert is_in_progress is False
|
||||
assert reason == ""
|
||||
# Should be removed from state
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
def test_is_review_in_progress_invalid_timestamp(self, mock_bot_detector):
|
||||
"""Test handling invalid timestamp in in-progress state."""
|
||||
mock_bot_detector.state.in_progress_reviews["123"] = "invalid-timestamp"
|
||||
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
|
||||
|
||||
# Should clear invalid state
|
||||
assert is_in_progress is False
|
||||
assert reason == ""
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
def test_should_skip_review_in_progress(self, mock_bot_detector):
|
||||
"""Test skipping PR when review is in progress."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "already in progress" in reason.lower()
|
||||
|
||||
def test_mark_reviewed_clears_in_progress(self, mock_bot_detector):
|
||||
"""Test that mark_reviewed also clears in-progress state."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
|
||||
# In-progress should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
# Reviewed state should be set
|
||||
assert "123" in mock_bot_detector.state.reviewed_commits
|
||||
assert "abc123" in mock_bot_detector.state.reviewed_commits["123"]
|
||||
|
||||
def test_clear_pr_state_clears_in_progress(self, mock_bot_detector):
|
||||
"""Test that clear_pr_state also clears in-progress state."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
|
||||
assert (
|
||||
"123" in mock_bot_detector.state.in_progress_reviews or True
|
||||
) # May be cleared by mark_reviewed
|
||||
assert "123" in mock_bot_detector.state.reviewed_commits
|
||||
|
||||
# Start another review
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
mock_bot_detector.clear_pr_state(123)
|
||||
|
||||
# Everything should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
assert "123" not in mock_bot_detector.state.reviewed_commits
|
||||
assert "123" not in mock_bot_detector.state.last_review_times
|
||||
|
||||
def test_get_stats_includes_in_progress(self, mock_bot_detector):
|
||||
"""Test that get_stats includes in-progress count."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
mock_bot_detector.mark_review_started(456)
|
||||
mock_bot_detector.mark_reviewed(789, "abc123")
|
||||
|
||||
stats = mock_bot_detector.get_stats()
|
||||
|
||||
assert stats["in_progress_reviews"] == 2
|
||||
assert stats["total_prs_tracked"] == 1 # Only 789 is tracked as reviewed
|
||||
assert stats["in_progress_timeout_minutes"] == 30
|
||||
|
||||
def test_cleanup_stale_prs_removes_stale_in_progress(self, mock_bot_detector):
|
||||
"""Test that cleanup_stale_prs removes stale in-progress reviews."""
|
||||
# Add a stale in-progress review (32 minutes ago)
|
||||
stale_time = datetime.now() - timedelta(minutes=32)
|
||||
mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat()
|
||||
|
||||
# Add an active in-progress review (5 minutes ago)
|
||||
active_time = datetime.now() - timedelta(minutes=5)
|
||||
mock_bot_detector.state.in_progress_reviews["456"] = active_time.isoformat()
|
||||
|
||||
# Add a stale reviewed PR (40 days ago)
|
||||
stale_review_time = datetime.now() - timedelta(days=40)
|
||||
mock_bot_detector.state.reviewed_commits["789"] = ["abc123"]
|
||||
mock_bot_detector.state.last_review_times["789"] = stale_review_time.isoformat()
|
||||
|
||||
cleaned = mock_bot_detector.cleanup_stale_prs(max_age_days=30)
|
||||
|
||||
# Should remove stale in-progress and stale reviewed PR
|
||||
assert cleaned == 2 # 1 stale in-progress + 1 stale reviewed
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
assert (
|
||||
"456" in mock_bot_detector.state.in_progress_reviews
|
||||
) # Active one remains
|
||||
assert "789" not in mock_bot_detector.state.reviewed_commits
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -38,7 +38,7 @@ async def bash_security_hook(
|
||||
context: Optional context
|
||||
|
||||
Returns:
|
||||
Empty dict to allow, or {"decision": "block", "reason": "..."} to block
|
||||
Empty dict to allow, or hookSpecificOutput with permissionDecision "deny" to block
|
||||
"""
|
||||
if input_data.get("tool_name") != "Bash":
|
||||
return {}
|
||||
@@ -49,15 +49,21 @@ async def bash_security_hook(
|
||||
# Check if tool_input is None (malformed tool call)
|
||||
if tool_input is None:
|
||||
return {
|
||||
"decision": "block",
|
||||
"reason": "Bash tool_input is None - malformed tool call from SDK",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "Bash tool_input is None - malformed tool call from SDK",
|
||||
}
|
||||
}
|
||||
|
||||
# Check if tool_input is a dict
|
||||
if not isinstance(tool_input, dict):
|
||||
return {
|
||||
"decision": "block",
|
||||
"reason": f"Bash tool_input must be dict, got {type(tool_input).__name__}",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": f"Bash tool_input must be dict, got {type(tool_input).__name__}",
|
||||
}
|
||||
}
|
||||
|
||||
# Now safe to access command
|
||||
@@ -97,8 +103,11 @@ async def bash_security_hook(
|
||||
if not commands:
|
||||
# Could not parse - fail safe by blocking
|
||||
return {
|
||||
"decision": "block",
|
||||
"reason": f"Could not parse command for security validation: {command}",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": f"Could not parse command for security validation: {command}",
|
||||
}
|
||||
}
|
||||
|
||||
# Split into segments for per-command validation
|
||||
@@ -114,8 +123,11 @@ async def bash_security_hook(
|
||||
|
||||
if not is_allowed:
|
||||
return {
|
||||
"decision": "block",
|
||||
"reason": reason,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason,
|
||||
}
|
||||
}
|
||||
|
||||
# Additional validation for sensitive commands
|
||||
@@ -127,7 +139,13 @@ async def bash_security_hook(
|
||||
validator = VALIDATORS[cmd]
|
||||
allowed, reason = validator(cmd_segment)
|
||||
if not allowed:
|
||||
return {"decision": "block", "reason": reason}
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_M
|
||||
import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem, WorktreeCreatePROptions, WorktreeCreatePRResult, SupportedIDE, SupportedTerminal, AppSettings } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
||||
import { existsSync, readdirSync, statSync, readFileSync, promises as fsPromises } from 'fs';
|
||||
import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { projectStore } from '../../project-store';
|
||||
@@ -2009,6 +2009,18 @@ export function registerWorktreeHandlers(
|
||||
debug('TIMEOUT: Merge process exceeded', MERGE_TIMEOUT_MS, 'ms, killing...');
|
||||
resolved = true;
|
||||
|
||||
// Send timeout error progress event to the renderer
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_MERGE_PROGRESS, taskId, {
|
||||
type: 'progress',
|
||||
stage: 'error',
|
||||
percent: 0,
|
||||
message: 'Merge process timed out after 10 minutes',
|
||||
details: {}
|
||||
});
|
||||
}
|
||||
|
||||
// Platform-specific process termination with fallback
|
||||
killProcessGracefully(mergeProcess, {
|
||||
debugPrefix: '[MERGE]',
|
||||
@@ -2042,10 +2054,40 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
}, MERGE_TIMEOUT_MS);
|
||||
|
||||
let lineBuffer = ''; // Buffer for partial JSON lines spanning data chunks
|
||||
|
||||
mergeProcess.stdout.on('data', (data: Buffer) => {
|
||||
const chunk = data.toString('utf-8');
|
||||
stdout += chunk;
|
||||
debug('STDOUT:', chunk);
|
||||
|
||||
// Prepend any buffered partial line from previous chunk
|
||||
const combined = lineBuffer + chunk;
|
||||
const lines = combined.split('\n');
|
||||
|
||||
// Last element may be a partial line - buffer it for next chunk
|
||||
lineBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && parsed.type === 'progress') {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_MERGE_PROGRESS, taskId, parsed);
|
||||
}
|
||||
// Don't accumulate progress lines in stdout - they are not part of the final result
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON - treat as regular output
|
||||
}
|
||||
|
||||
// Accumulate non-progress lines for final result parsing
|
||||
stdout += line + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
mergeProcess.stderr.on('data', (data: Buffer) => {
|
||||
@@ -2060,6 +2102,24 @@ export function registerWorktreeHandlers(
|
||||
resolved = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
// Flush any remaining buffered line
|
||||
if (lineBuffer.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(lineBuffer.trim());
|
||||
if (parsed && parsed.type === 'progress') {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_MERGE_PROGRESS, taskId, parsed);
|
||||
}
|
||||
} else {
|
||||
stdout += lineBuffer;
|
||||
}
|
||||
} catch {
|
||||
stdout += lineBuffer;
|
||||
}
|
||||
lineBuffer = '';
|
||||
}
|
||||
|
||||
debug('Process exited with code:', code, 'signal:', signal);
|
||||
debug('Full stdout:', stdout);
|
||||
debug('Full stderr:', stderr);
|
||||
@@ -2353,6 +2413,19 @@ export function registerWorktreeHandlers(
|
||||
resolved = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
console.error('[MERGE] Process spawn error:', err);
|
||||
|
||||
// Send error progress event to the renderer
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_MERGE_PROGRESS, taskId, {
|
||||
type: 'progress',
|
||||
stage: 'error',
|
||||
percent: 0,
|
||||
message: `Merge process crashed: ${err.message}`,
|
||||
details: {}
|
||||
});
|
||||
}
|
||||
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Failed to run merge: ${err.message}`
|
||||
@@ -2657,154 +2730,115 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Promisified execFile for async git operations
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* List all spec worktrees for a project
|
||||
* Per-spec architecture: Each spec has its own worktree at .auto-claude/worktrees/tasks/{spec-name}/
|
||||
*
|
||||
* Options:
|
||||
* - includeStats: When true, fetches commit count, files changed, additions, deletions per worktree.
|
||||
* When false (default), only fetches branch and base branch info for faster listing.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_LIST_WORKTREES,
|
||||
async (_, projectId: string, options?: { includeStats?: boolean }): Promise<IPCResult<WorktreeListResult>> => {
|
||||
async (_, projectId: string): Promise<IPCResult<WorktreeListResult>> => {
|
||||
try {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const includeStats = options?.includeStats ?? false;
|
||||
const worktreesDir = getTaskWorktreeDir(project.path);
|
||||
const gitPath = getToolPath('git');
|
||||
|
||||
// Detect the project's default branch once (main/master) instead of per-worktree
|
||||
let projectDefaultBranch: string | undefined;
|
||||
if (!project.settings?.mainBranch || !GIT_BRANCH_REGEX.test(project.settings.mainBranch)) {
|
||||
for (const branch of ['main', 'master']) {
|
||||
try {
|
||||
await execFileAsync(gitPath, ['rev-parse', '--verify', branch], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
projectDefaultBranch = branch;
|
||||
break;
|
||||
} catch {
|
||||
// Branch doesn't exist, try next
|
||||
}
|
||||
}
|
||||
if (!projectDefaultBranch) {
|
||||
projectDefaultBranch = 'main'; // fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get effective base branch using cached project default
|
||||
const getBaseBranchForEntry = (entry: string): string => {
|
||||
// 1. Try task metadata baseBranch
|
||||
const specDir = path.join(project.path, '.auto-claude', 'specs', entry);
|
||||
const taskBaseBranch = getTaskBaseBranch(specDir);
|
||||
if (taskBaseBranch) return taskBaseBranch;
|
||||
|
||||
// 2. Try project settings mainBranch
|
||||
if (project.settings?.mainBranch && GIT_BRANCH_REGEX.test(project.settings.mainBranch)) {
|
||||
return project.settings.mainBranch;
|
||||
}
|
||||
|
||||
// 3. Use pre-detected project default branch
|
||||
return projectDefaultBranch || 'main';
|
||||
};
|
||||
|
||||
// Async helper to process a single worktree entry
|
||||
const processWorktreeEntryAsync = async (entry: string, entryPath: string): Promise<WorktreeListItem | null> => {
|
||||
// Helper to process a single worktree entry (async)
|
||||
const processWorktreeEntry = async (entry: string, entryPath: string): Promise<WorktreeListItem | null> => {
|
||||
try {
|
||||
// Get branch info (always needed)
|
||||
const { stdout: branchOutput } = await execFileAsync(gitPath, ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
// Get branch info (async)
|
||||
const branchResult = await execFileAsync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: entryPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
const branch = branchOutput.trim();
|
||||
const branch = (branchResult.stdout as string).trim();
|
||||
|
||||
const baseBranch = getBaseBranchForEntry(entry);
|
||||
// Get base branch using proper fallback chain:
|
||||
// 1. Task metadata baseBranch, 2. Project settings mainBranch, 3. main/master detection
|
||||
// Note: We do NOT use current HEAD as that may be a feature branch
|
||||
const baseBranch = getEffectiveBaseBranch(project.path, entry, project.settings?.mainBranch);
|
||||
|
||||
const item: WorktreeListItem = {
|
||||
// Get commit count (async, cross-platform - no shell syntax)
|
||||
let commitCount = 0;
|
||||
try {
|
||||
const countResult = await execFileAsync(getToolPath('git'), ['rev-list', '--count', `${baseBranch}..HEAD`], {
|
||||
cwd: entryPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
commitCount = parseInt((countResult.stdout as string).trim(), 10) || 0;
|
||||
} catch {
|
||||
commitCount = 0;
|
||||
}
|
||||
|
||||
// Get diff stats (async, cross-platform - no shell syntax)
|
||||
let filesChanged = 0;
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
try {
|
||||
const diffResult = await execFileAsync(getToolPath('git'), ['diff', '--shortstat', `${baseBranch}...HEAD`], {
|
||||
cwd: entryPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
const diffStat = (diffResult.stdout as string).trim();
|
||||
|
||||
const filesMatch = diffStat.match(/(\d+) files? changed/);
|
||||
const addMatch = diffStat.match(/(\d+) insertions?/);
|
||||
const delMatch = diffStat.match(/(\d+) deletions?/);
|
||||
|
||||
if (filesMatch) filesChanged = parseInt(filesMatch[1], 10) || 0;
|
||||
if (addMatch) additions = parseInt(addMatch[1], 10) || 0;
|
||||
if (delMatch) deletions = parseInt(delMatch[1], 10) || 0;
|
||||
} catch {
|
||||
// Ignore diff errors
|
||||
}
|
||||
|
||||
return {
|
||||
specName: entry,
|
||||
path: entryPath,
|
||||
branch,
|
||||
baseBranch,
|
||||
commitCount,
|
||||
filesChanged,
|
||||
additions,
|
||||
deletions
|
||||
};
|
||||
|
||||
// Only fetch stats when requested
|
||||
if (includeStats) {
|
||||
// Run commit count and diff stats in parallel
|
||||
const [countResult, diffResult] = await Promise.allSettled([
|
||||
execFileAsync(gitPath, ['rev-list', '--count', `${baseBranch}..HEAD`], {
|
||||
cwd: entryPath,
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
execFileAsync(gitPath, ['diff', '--shortstat', `${baseBranch}...HEAD`], {
|
||||
cwd: entryPath,
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
]);
|
||||
|
||||
if (countResult.status === 'fulfilled') {
|
||||
item.commitCount = parseInt(countResult.value.stdout.trim(), 10) || 0;
|
||||
} else {
|
||||
item.commitCount = 0;
|
||||
}
|
||||
|
||||
if (diffResult.status === 'fulfilled') {
|
||||
const diffStat = diffResult.value.stdout.trim();
|
||||
const filesMatch = diffStat.match(/(\d+) files? changed/);
|
||||
const addMatch = diffStat.match(/(\d+) insertions?/);
|
||||
const delMatch = diffStat.match(/(\d+) deletions?/);
|
||||
|
||||
item.filesChanged = filesMatch ? parseInt(filesMatch[1], 10) || 0 : 0;
|
||||
item.additions = addMatch ? parseInt(addMatch[1], 10) || 0 : 0;
|
||||
item.deletions = delMatch ? parseInt(delMatch[1], 10) || 0 : 0;
|
||||
} else {
|
||||
item.filesChanged = 0;
|
||||
item.additions = 0;
|
||||
item.deletions = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
} catch (gitError) {
|
||||
console.error(`Error getting info for worktree ${entry}:`, gitError);
|
||||
// Skip this worktree if we can't get git info
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Scan worktrees directory and process all entries in parallel
|
||||
let worktrees: WorktreeListItem[] = [];
|
||||
if (existsSync(worktreesDir)) {
|
||||
const entries = readdirSync(worktreesDir);
|
||||
const dirEntries: Array<{ entry: string; entryPath: string }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(worktreesDir, entry);
|
||||
try {
|
||||
const stat = statSync(entryPath);
|
||||
if (stat.isDirectory()) {
|
||||
dirEntries.push({ entry, entryPath });
|
||||
}
|
||||
} catch {
|
||||
// Skip entries that can't be stat'd
|
||||
}
|
||||
}
|
||||
|
||||
// Process all worktrees in parallel
|
||||
const results = await Promise.allSettled(
|
||||
dirEntries.map(({ entry, entryPath }) => processWorktreeEntryAsync(entry, entryPath))
|
||||
);
|
||||
|
||||
worktrees = results
|
||||
.filter((r): r is PromiseFulfilledResult<WorktreeListItem | null> => r.status === 'fulfilled')
|
||||
.map(r => r.value)
|
||||
.filter((item): item is WorktreeListItem => item !== null);
|
||||
// Scan worktrees directory (async)
|
||||
if (!existsSync(worktreesDir)) {
|
||||
return { success: true, data: { worktrees: [] } };
|
||||
}
|
||||
|
||||
const entries = await fsPromises.readdir(worktreesDir);
|
||||
|
||||
// Process all worktrees in parallel for better performance
|
||||
const worktreePromises = entries.map(async (entry) => {
|
||||
const entryPath = path.join(worktreesDir, entry);
|
||||
try {
|
||||
const stat = await fsPromises.stat(entryPath);
|
||||
if (stat.isDirectory()) {
|
||||
return processWorktreeEntry(entry, entryPath);
|
||||
}
|
||||
} catch {
|
||||
// Skip entries that can't be stat'd
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const results = await Promise.all(worktreePromises);
|
||||
const worktrees = results.filter((w): w is WorktreeListItem => w !== null);
|
||||
|
||||
return { success: true, data: { worktrees } };
|
||||
} catch (error) {
|
||||
console.error('Failed to list worktrees:', error);
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
TaskMetadata,
|
||||
TaskLogs,
|
||||
TaskLogStreamChunk,
|
||||
MergeProgress,
|
||||
SupportedIDE,
|
||||
SupportedTerminal,
|
||||
WorktreeCreatePROptions,
|
||||
@@ -84,6 +85,9 @@ export interface TaskAPI {
|
||||
unwatchTaskLogs: (specId: string) => Promise<IPCResult>;
|
||||
onTaskLogsChanged: (callback: (specId: string, logs: TaskLogs) => void) => () => void;
|
||||
onTaskLogsStream: (callback: (specId: string, chunk: TaskLogStreamChunk) => void) => () => void;
|
||||
|
||||
// Merge Progress Events
|
||||
onMergeProgress: (callback: (taskId: string, progress: MergeProgress) => void) => () => void;
|
||||
}
|
||||
|
||||
export const createTaskAPI = (): TaskAPI => ({
|
||||
@@ -308,5 +312,22 @@ export const createTaskAPI = (): TaskAPI => ({
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOGS_STREAM, handler);
|
||||
};
|
||||
},
|
||||
|
||||
// Merge Progress Events
|
||||
onMergeProgress: (
|
||||
callback: (taskId: string, progress: MergeProgress) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
taskId: string,
|
||||
progress: MergeProgress
|
||||
): void => {
|
||||
callback(taskId, progress);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TASK_MERGE_PROGRESS, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TASK_MERGE_PROGRESS, handler);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,10 +6,33 @@ import {
|
||||
AlertTriangle,
|
||||
X,
|
||||
Loader2,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "./ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { ClaudeCodeVersionInfo } from "../../shared/types/cli";
|
||||
import type { ClaudeCodeVersionInfo, ClaudeInstallationInfo } from "../../shared/types/cli";
|
||||
|
||||
interface ClaudeCodeStatusBadgeProps {
|
||||
className?: string;
|
||||
@@ -19,15 +42,36 @@ type StatusType = "loading" | "installed" | "outdated" | "not-found" | "error";
|
||||
|
||||
// Check every 24 hours
|
||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
// Delay before re-checking version after install/update
|
||||
const VERSION_RECHECK_DELAY_MS = 5000;
|
||||
|
||||
/**
|
||||
* Claude Code CLI status badge for the sidebar.
|
||||
* Shows installation status with a tooltip on hover.
|
||||
* Shows installation status and provides quick access to install/update.
|
||||
*/
|
||||
export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) {
|
||||
const { t } = useTranslation(["common", "navigation"]);
|
||||
const [status, setStatus] = useState<StatusType>("loading");
|
||||
const [versionInfo, setVersionInfo] = useState<ClaudeCodeVersionInfo | null>(null);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [lastChecked, setLastChecked] = useState<Date | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showUpdateWarning, setShowUpdateWarning] = useState(false);
|
||||
|
||||
// Version rollback state
|
||||
const [availableVersions, setAvailableVersions] = useState<string[]>([]);
|
||||
const [isLoadingVersions, setIsLoadingVersions] = useState(false);
|
||||
const [versionsError, setVersionsError] = useState<string | null>(null);
|
||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||
const [showRollbackWarning, setShowRollbackWarning] = useState(false);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
|
||||
// CLI path selection state
|
||||
const [installations, setInstallations] = useState<ClaudeInstallationInfo[]>([]);
|
||||
const [isLoadingInstallations, setIsLoadingInstallations] = useState(false);
|
||||
const [installationsError, setInstallationsError] = useState<string | null>(null);
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<string | null>(null);
|
||||
const [showPathChangeWarning, setShowPathChangeWarning] = useState(false);
|
||||
|
||||
// Check Claude Code version
|
||||
const checkVersion = useCallback(async () => {
|
||||
@@ -41,6 +85,7 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
|
||||
|
||||
if (result.success && result.data) {
|
||||
setVersionInfo(result.data);
|
||||
setLastChecked(new Date());
|
||||
|
||||
if (!result.data.installed) {
|
||||
setStatus("not-found");
|
||||
@@ -52,11 +97,60 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error("Failed to check Claude Code version:", err);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch available versions
|
||||
const fetchVersions = useCallback(async () => {
|
||||
if (!window.electronAPI?.getClaudeCodeVersions) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingVersions(true);
|
||||
setVersionsError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.getClaudeCodeVersions();
|
||||
if (result.success && result.data) {
|
||||
setAvailableVersions(result.data.versions);
|
||||
} else {
|
||||
setVersionsError(result.error || "Failed to load versions");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch versions:", err);
|
||||
setVersionsError("Failed to load versions");
|
||||
} finally {
|
||||
setIsLoadingVersions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch CLI installations
|
||||
const fetchInstallations = useCallback(async () => {
|
||||
if (!window.electronAPI?.getClaudeCodeInstallations) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingInstallations(true);
|
||||
setInstallationsError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.getClaudeCodeInstallations();
|
||||
if (result.success && result.data) {
|
||||
setInstallations(result.data.installations);
|
||||
} else {
|
||||
setInstallationsError(result.error || "Failed to load installations");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch installations:", err);
|
||||
setInstallationsError("Failed to load installations");
|
||||
} finally {
|
||||
setIsLoadingInstallations(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial check and periodic re-check
|
||||
useEffect(() => {
|
||||
checkVersion();
|
||||
@@ -69,6 +163,156 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
|
||||
return () => clearInterval(interval);
|
||||
}, [checkVersion]);
|
||||
|
||||
// Fetch versions when popover opens and Claude is installed
|
||||
useEffect(() => {
|
||||
if (isOpen && versionInfo?.installed && availableVersions.length === 0) {
|
||||
fetchVersions();
|
||||
}
|
||||
}, [isOpen, versionInfo?.installed, availableVersions.length, fetchVersions]);
|
||||
|
||||
// Fetch installations when popover opens
|
||||
useEffect(() => {
|
||||
if (isOpen && installations.length === 0) {
|
||||
fetchInstallations();
|
||||
}
|
||||
}, [isOpen, installations.length, fetchInstallations]);
|
||||
|
||||
// Perform the actual install/update
|
||||
const performInstall = async () => {
|
||||
setIsInstalling(true);
|
||||
setShowUpdateWarning(false);
|
||||
setInstallError(null);
|
||||
try {
|
||||
if (!window.electronAPI?.installClaudeCode) {
|
||||
setInstallError("Installation not available");
|
||||
setIsInstalling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.installClaudeCode();
|
||||
|
||||
if (result.success) {
|
||||
// Re-check after a delay
|
||||
setTimeout(() => {
|
||||
checkVersion();
|
||||
}, VERSION_RECHECK_DELAY_MS);
|
||||
} else {
|
||||
setInstallError(result.error || "Installation failed");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to install Claude Code:", err);
|
||||
setInstallError(err instanceof Error ? err.message : "Installation failed");
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Perform version rollback/switch
|
||||
const performVersionSwitch = async () => {
|
||||
if (!selectedVersion) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
setShowRollbackWarning(false);
|
||||
setInstallError(null);
|
||||
|
||||
try {
|
||||
if (!window.electronAPI?.installClaudeCodeVersion) {
|
||||
setInstallError("Version switching not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.installClaudeCodeVersion(selectedVersion);
|
||||
|
||||
if (result.success) {
|
||||
// Re-check after a delay
|
||||
setTimeout(() => {
|
||||
checkVersion();
|
||||
}, VERSION_RECHECK_DELAY_MS);
|
||||
} else {
|
||||
setInstallError(result.error || "Failed to switch version");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to switch Claude Code version:", err);
|
||||
setInstallError(err instanceof Error ? err.message : "Failed to switch version");
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
setSelectedVersion(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Perform CLI path switch
|
||||
const performPathSwitch = async () => {
|
||||
if (!selectedInstallation) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
setShowPathChangeWarning(false);
|
||||
setInstallError(null);
|
||||
|
||||
try {
|
||||
if (!window.electronAPI?.setClaudeCodeActivePath) {
|
||||
setInstallError("Path switching not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.setClaudeCodeActivePath(selectedInstallation);
|
||||
|
||||
if (result.success) {
|
||||
// Re-check version and refresh installations
|
||||
setTimeout(() => {
|
||||
checkVersion();
|
||||
fetchInstallations();
|
||||
}, VERSION_RECHECK_DELAY_MS);
|
||||
} else {
|
||||
setInstallError(result.error || "Failed to switch CLI path");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to switch Claude CLI path:", err);
|
||||
setInstallError(err instanceof Error ? err.message : "Failed to switch CLI path");
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
setSelectedInstallation(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle install/update button click
|
||||
const handleInstall = () => {
|
||||
if (status === "outdated") {
|
||||
// Show warning for updates since it will close running Claude sessions
|
||||
setShowUpdateWarning(true);
|
||||
} else {
|
||||
// Fresh install - no warning needed
|
||||
performInstall();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle installation selection
|
||||
const handleInstallationSelect = (cliPath: string) => {
|
||||
// Don't do anything if it's the currently active installation
|
||||
const installation = installations.find(i => i.path === cliPath);
|
||||
if (installation?.isActive) {
|
||||
return;
|
||||
}
|
||||
setInstallError(null);
|
||||
setSelectedInstallation(cliPath);
|
||||
setShowPathChangeWarning(true);
|
||||
};
|
||||
|
||||
// Normalize version string by removing 'v' prefix for comparison
|
||||
const normalizeVersion = (v: string) => v.replace(/^v/, '');
|
||||
|
||||
// Handle version selection
|
||||
const handleVersionSelect = (version: string) => {
|
||||
// Don't do anything if it's the currently installed version (normalize both for comparison)
|
||||
const normalizedSelected = normalizeVersion(version);
|
||||
const normalizedInstalled = versionInfo?.installed ? normalizeVersion(versionInfo.installed) : '';
|
||||
if (normalizedSelected === normalizedInstalled) {
|
||||
return;
|
||||
}
|
||||
setInstallError(null);
|
||||
setSelectedVersion(version);
|
||||
setShowRollbackWarning(true);
|
||||
};
|
||||
|
||||
// Get status indicator color
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
@@ -84,27 +328,7 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
|
||||
}
|
||||
};
|
||||
|
||||
// Get tooltip text
|
||||
const getTooltipText = () => {
|
||||
switch (status) {
|
||||
case "loading":
|
||||
return t("navigation:claudeCode.checking", "Checking Claude Code...");
|
||||
case "installed":
|
||||
return versionInfo?.installed
|
||||
? t("navigation:claudeCode.upToDateWithVersion", "Claude Code {{version}} installed", {
|
||||
version: versionInfo.installed,
|
||||
})
|
||||
: t("navigation:claudeCode.upToDate", "Claude Code is up to date");
|
||||
case "outdated":
|
||||
return t("navigation:claudeCode.updateAvailable", "Claude Code update available");
|
||||
case "not-found":
|
||||
return t("navigation:claudeCode.notInstalled", "Claude Code not installed");
|
||||
case "error":
|
||||
return t("navigation:claudeCode.error", "Error checking Claude Code");
|
||||
}
|
||||
};
|
||||
|
||||
// Get status icon for the badge
|
||||
// Get status icon
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case "loading":
|
||||
@@ -114,52 +338,373 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
|
||||
case "outdated":
|
||||
return <AlertTriangle className="h-3 w-3" />;
|
||||
case "not-found":
|
||||
case "error":
|
||||
return <X className="h-3 w-3" />;
|
||||
case "error":
|
||||
return <AlertTriangle className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get tooltip text
|
||||
const getTooltipText = () => {
|
||||
switch (status) {
|
||||
case "loading":
|
||||
return t("navigation:claudeCode.checking", "Checking Claude Code...");
|
||||
case "installed":
|
||||
return t("navigation:claudeCode.upToDate", "Claude Code is up to date");
|
||||
case "outdated":
|
||||
return t("navigation:claudeCode.updateAvailable", "Claude Code update available");
|
||||
case "not-found":
|
||||
return t("navigation:claudeCode.notInstalled", "Claude Code not installed");
|
||||
case "error":
|
||||
return t("navigation:claudeCode.error", "Error checking Claude Code");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-md",
|
||||
status === "not-found" || status === "error" ? "text-destructive" : "",
|
||||
status === "outdated" ? "text-yellow-600 dark:text-yellow-500" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full",
|
||||
getStatusColor()
|
||||
"w-full justify-start gap-2 text-xs",
|
||||
status === "not-found" || status === "error" ? "text-destructive" : "",
|
||||
status === "outdated" ? "text-yellow-600 dark:text-yellow-500" : "",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<div className="relative">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full",
|
||||
getStatusColor()
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate">Claude Code</span>
|
||||
{status === "outdated" && (
|
||||
<span className="ml-auto text-[10px] bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 px-1.5 py-0.5 rounded">
|
||||
{t("common:update", "Update")}
|
||||
</span>
|
||||
)}
|
||||
{status === "not-found" && (
|
||||
<span className="ml-auto text-[10px] bg-destructive/20 text-destructive px-1.5 py-0.5 rounded">
|
||||
{t("common:install", "Install")}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{getTooltipText()}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent side="right" align="end" className="w-72">
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Terminal className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Claude Code CLI</h4>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
{getStatusIcon()}
|
||||
{status === "installed" && t("navigation:claudeCode.installed", "Installed")}
|
||||
{status === "outdated" && t("navigation:claudeCode.outdated", "Update available")}
|
||||
{status === "not-found" && t("navigation:claudeCode.missing", "Not installed")}
|
||||
{status === "loading" && t("navigation:claudeCode.checking", "Checking...")}
|
||||
{status === "error" && t("navigation:claudeCode.error", "Error")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="truncate">Claude Code</span>
|
||||
{status === "outdated" && (
|
||||
<span className="ml-auto text-[10px] bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
{getStatusIcon()}
|
||||
{t("common:update", "Update")}
|
||||
</span>
|
||||
|
||||
{/* Version info */}
|
||||
{versionInfo && status !== "loading" && (
|
||||
<div className="text-xs space-y-1 p-2 bg-muted rounded-md">
|
||||
{versionInfo.installed && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("navigation:claudeCode.current", "Current")}:
|
||||
</span>
|
||||
<span className="font-mono">{versionInfo.installed}</span>
|
||||
</div>
|
||||
)}
|
||||
{versionInfo.latest && versionInfo.latest !== "unknown" && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("navigation:claudeCode.latest", "Latest")}:
|
||||
</span>
|
||||
<span className="font-mono">{versionInfo.latest}</span>
|
||||
</div>
|
||||
)}
|
||||
{versionInfo.path && (
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
{t("navigation:claudeCode.path", "Path")}:
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[10px] truncate max-w-[140px]"
|
||||
title={versionInfo.path}
|
||||
>
|
||||
{versionInfo.path}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{lastChecked && (
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>{t("navigation:claudeCode.lastChecked", "Last checked")}:</span>
|
||||
<span>{lastChecked.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{status === "not-found" && (
|
||||
<span className="ml-auto text-[10px] bg-destructive/20 text-destructive px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
{getStatusIcon()}
|
||||
{t("common:install", "Install")}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{(status === "not-found" || status === "outdated") && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 gap-1"
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3 w-3" />
|
||||
)}
|
||||
{status === "outdated"
|
||||
? t("common:update", "Update")
|
||||
: t("common:install", "Install")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={() => checkVersion()}
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
<RefreshCw className={cn("h-3 w-3", status === "loading" && "animate-spin")} />
|
||||
{t("common:refresh", "Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Install/Update error display */}
|
||||
{installError && (
|
||||
<div className="text-xs p-2 bg-destructive/10 text-destructive rounded-md flex items-center gap-2">
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||||
<span>{installError}</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "installed" && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
{getStatusIcon()}
|
||||
</span>
|
||||
|
||||
{/* Version selector - only show when Claude is installed */}
|
||||
{versionInfo?.installed && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("navigation:claudeCode.switchVersion", "Switch Version")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedVersion || ""}
|
||||
onValueChange={handleVersionSelect}
|
||||
disabled={isLoadingVersions || isInstalling}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingVersions
|
||||
? t("navigation:claudeCode.loadingVersions", "Loading versions...")
|
||||
: versionsError
|
||||
? t("navigation:claudeCode.failedToLoadVersions", "Failed to load versions")
|
||||
: t("navigation:claudeCode.selectVersion", "Select version")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVersions.map((version) => {
|
||||
const isCurrentVersion = normalizeVersion(version) === normalizeVersion(versionInfo.installed || '');
|
||||
return (
|
||||
<SelectItem
|
||||
key={version}
|
||||
value={version}
|
||||
className="text-xs"
|
||||
disabled={isCurrentVersion}
|
||||
>
|
||||
<span className="font-mono">{version}</span>
|
||||
{isCurrentVersion && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
({t("navigation:claudeCode.currentVersion", "Current")})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CLI Installation selector - show when multiple installations are found */}
|
||||
{installations.length > 1 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("navigation:claudeCode.switchInstallation", "Switch Installation")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedInstallation || ""}
|
||||
onValueChange={handleInstallationSelect}
|
||||
disabled={isLoadingInstallations || isInstalling}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingInstallations
|
||||
? t("navigation:claudeCode.loadingInstallations", "Loading installations...")
|
||||
: installationsError
|
||||
? t("navigation:claudeCode.failedToLoadInstallations", "Failed to load installations")
|
||||
: t("navigation:claudeCode.selectInstallation", "Select installation")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{installations.map((installation) => (
|
||||
<SelectItem
|
||||
key={installation.path}
|
||||
value={installation.path}
|
||||
className="text-xs"
|
||||
disabled={installation.isActive}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[10px] truncate max-w-[180px]" title={installation.path}>
|
||||
{/* Split on both path separators for cross-platform compatibility */}
|
||||
{installation.path.split(/[/\\]/).slice(-2).join('/') || installation.path}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[9px]">
|
||||
{installation.version ? `v${installation.version}` : t("navigation:claudeCode.versionUnknown", "version unknown")} ({installation.source})
|
||||
{installation.isActive && ` - ${t("navigation:claudeCode.activeInstallation", "Active")}`}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changelog link */}
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="w-full text-xs text-muted-foreground gap-1"
|
||||
onClick={() =>
|
||||
window.electronAPI?.openExternal?.(
|
||||
"https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md"
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"navigation:claudeCode.viewChangelogAriaLabel",
|
||||
"View Claude Code Changelog (opens in new window)"
|
||||
)}
|
||||
>
|
||||
{t("navigation:claudeCode.viewChangelog", "View Claude Code Changelog")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{getTooltipText()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
|
||||
{/* Update warning dialog */}
|
||||
<AlertDialog open={showUpdateWarning} onOpenChange={setShowUpdateWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("navigation:claudeCode.updateWarningTitle", "Update Claude Code?")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
"navigation:claudeCode.updateWarningDescription",
|
||||
"Updating will close all running Claude Code sessions. Any unsaved work in those sessions may be lost. Make sure to save your work before proceeding."
|
||||
)}
|
||||
<span className="block mt-2 font-semibold text-foreground">
|
||||
{t(
|
||||
"navigation:claudeCode.updateWarningTerminalNote",
|
||||
"A terminal window will open to run the installation command. Please wait for the installation to complete before continuing."
|
||||
)}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common:cancel", "Cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={performInstall}>
|
||||
{t("navigation:claudeCode.updateAnyway", "Open Terminal & Update")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Version rollback warning dialog */}
|
||||
<AlertDialog open={showRollbackWarning} onOpenChange={setShowRollbackWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("navigation:claudeCode.rollbackWarningTitle", "Switch to version {{version}}?", {
|
||||
version: selectedVersion,
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
"navigation:claudeCode.rollbackWarningDescription",
|
||||
"Switching versions will close all running Claude Code sessions. Any unsaved work in those sessions may be lost. Make sure to save your work before proceeding."
|
||||
)}
|
||||
<span className="block mt-2 font-semibold text-foreground">
|
||||
{t(
|
||||
"navigation:claudeCode.rollbackWarningTerminalNote",
|
||||
"A terminal window will open to run the installation command. Please wait for the installation to complete before continuing."
|
||||
)}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setSelectedVersion(null)}>
|
||||
{t("common:cancel", "Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={performVersionSwitch}>
|
||||
{t("navigation:claudeCode.switchAnyway", "Open Terminal & Switch")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Path change warning dialog */}
|
||||
<AlertDialog open={showPathChangeWarning} onOpenChange={setShowPathChangeWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("navigation:claudeCode.pathChangeWarningTitle", "Switch CLI installation?")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
"navigation:claudeCode.pathChangeWarningDescription",
|
||||
"Switching CLI installations will use a different Claude Code binary. Any running sessions will continue using the previous installation until restarted."
|
||||
)}
|
||||
<span className="block mt-2 font-mono text-xs break-all">
|
||||
{selectedInstallation}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setSelectedInstallation(null)}>
|
||||
{t("common:cancel", "Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={performPathSwitch}>
|
||||
{t("navigation:claudeCode.switchInstallationConfirm", "Switch")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, ChevronRight, FileCode, AlertTriangle, Loader2, CheckCircle2, XCircle, Clock } from 'lucide-react';
|
||||
import { Progress } from '../../ui/progress';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { MergeProgress, MergeLogEntry, MergeLogEntryType } from '../../../../shared/types';
|
||||
|
||||
interface MergeProgressOverlayProps {
|
||||
mergeProgress: MergeProgress | null;
|
||||
logEntries: MergeLogEntry[];
|
||||
}
|
||||
|
||||
/** Time in ms without a progress update before showing stalled indicator */
|
||||
const STALL_THRESHOLD_MS = 30000;
|
||||
|
||||
const STAGE_TO_I18N_KEY: Record<string, string> = {
|
||||
analyzing: 'stages.analyzing',
|
||||
detecting_conflicts: 'stages.detectingConflicts',
|
||||
resolving: 'stages.resolving',
|
||||
validating: 'stages.validating',
|
||||
complete: 'stages.complete',
|
||||
error: 'stages.error',
|
||||
stalled: 'stages.stalled',
|
||||
};
|
||||
|
||||
const LOG_TYPE_COLORS: Record<MergeLogEntryType, string> = {
|
||||
info: 'text-info',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
error: 'text-destructive',
|
||||
};
|
||||
|
||||
/**
|
||||
* Overlay component displaying real-time merge progress with a progress bar,
|
||||
* stage label, conflict counter, current file indicator, and expandable log viewer.
|
||||
*
|
||||
* Detects stalled merges when no progress update is received for 30+ seconds.
|
||||
*/
|
||||
export function MergeProgressOverlay({ mergeProgress, logEntries }: MergeProgressOverlayProps) {
|
||||
const { t } = useTranslation(['taskReview']);
|
||||
const [logsExpanded, setLogsExpanded] = useState(false);
|
||||
const [isStalled, setIsStalled] = useState(false);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Reset stall timer whenever we receive a new progress update
|
||||
const resetStallTimer = useCallback(() => {
|
||||
setIsStalled(false);
|
||||
if (stallTimerRef.current) {
|
||||
clearTimeout(stallTimerRef.current);
|
||||
}
|
||||
stallTimerRef.current = setTimeout(() => {
|
||||
setIsStalled(true);
|
||||
}, STALL_THRESHOLD_MS);
|
||||
}, []);
|
||||
|
||||
// Start/reset stall detection when progress updates arrive
|
||||
useEffect(() => {
|
||||
if (mergeProgress && mergeProgress.stage !== 'complete' && mergeProgress.stage !== 'error') {
|
||||
resetStallTimer();
|
||||
} else {
|
||||
// Clear timer on terminal states
|
||||
setIsStalled(false);
|
||||
if (stallTimerRef.current) {
|
||||
clearTimeout(stallTimerRef.current);
|
||||
stallTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [mergeProgress, resetStallTimer]);
|
||||
|
||||
// Cleanup stall timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (stallTimerRef.current) {
|
||||
clearTimeout(stallTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll log viewer to bottom when new entries arrive
|
||||
useEffect(() => {
|
||||
if (logsExpanded && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logEntries, logsExpanded]);
|
||||
|
||||
if (!mergeProgress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { stage, percent, message, details } = mergeProgress;
|
||||
const isError = stage === 'error';
|
||||
const isComplete = stage === 'complete';
|
||||
|
||||
// Use stalled stage label when stalled, otherwise use the current stage
|
||||
const effectiveStage = isStalled && !isError && !isComplete ? 'stalled' : stage;
|
||||
const stageLabel = STAGE_TO_I18N_KEY[effectiveStage]
|
||||
? t(`taskReview:mergeProgress.${STAGE_TO_I18N_KEY[effectiveStage]}`)
|
||||
: message;
|
||||
|
||||
const conflictsFound = details?.conflicts_found ?? 0;
|
||||
const conflictsResolved = details?.conflicts_resolved ?? 0;
|
||||
const currentFile = details?.current_file;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border p-4 space-y-3',
|
||||
isError && 'border-destructive/50 bg-destructive/5',
|
||||
isComplete && 'border-success/50 bg-success/5',
|
||||
isStalled && !isError && !isComplete && 'border-warning/50 bg-warning/5',
|
||||
!isError && !isComplete && !isStalled && 'border-info/50 bg-info/5'
|
||||
)}
|
||||
>
|
||||
{/* Stage label and percentage */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isError ? (
|
||||
<XCircle className="h-4 w-4 text-destructive shrink-0" />
|
||||
) : isComplete ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-success shrink-0" />
|
||||
) : isStalled ? (
|
||||
<Clock className="h-4 w-4 text-warning shrink-0" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-info shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
isError && 'text-destructive',
|
||||
isComplete && 'text-success',
|
||||
isStalled && !isError && !isComplete && 'text-warning'
|
||||
)}
|
||||
>
|
||||
{stageLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums">{percent}%</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<Progress
|
||||
value={percent}
|
||||
className={cn(
|
||||
'h-2',
|
||||
isError && '[&>div]:bg-destructive',
|
||||
isComplete && '[&>div]:bg-success',
|
||||
isStalled && !isError && !isComplete && '[&>div]:bg-warning',
|
||||
!isError && !isComplete && !isStalled && '[&>div]:bg-info'
|
||||
)}
|
||||
animated={!isError && !isComplete}
|
||||
/>
|
||||
|
||||
{/* Conflict counter */}
|
||||
{conflictsFound > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-warning shrink-0" />
|
||||
<span>
|
||||
{t('taskReview:mergeProgress.conflictCounter', {
|
||||
found: conflictsFound,
|
||||
resolved: conflictsResolved,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current file indicator */}
|
||||
{currentFile && !isComplete && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileCode className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate font-mono" title={currentFile}>
|
||||
{t('taskReview:mergeProgress.currentFile')}: {currentFile}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion / error messages */}
|
||||
{isComplete && (
|
||||
<p className="text-xs text-success">{t('taskReview:mergeProgress.completionMessage')}</p>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-xs text-destructive">{t('taskReview:mergeProgress.errorMessage')}</p>
|
||||
)}
|
||||
|
||||
{/* Expandable log viewer */}
|
||||
{logEntries.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogsExpanded(!logsExpanded)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{logsExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{logsExpanded
|
||||
? t('taskReview:mergeProgress.hideLogs')
|
||||
: t('taskReview:mergeProgress.viewLogs')}
|
||||
</button>
|
||||
|
||||
{logsExpanded && (
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="mt-2 max-h-48 overflow-y-auto rounded-lg border bg-background/50 p-2 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{logEntries.map((entry, idx) => (
|
||||
<div key={idx} className="flex gap-2 text-xs font-mono">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className={cn(LOG_TYPE_COLORS[entry.type])}>
|
||||
{entry.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+255
-33
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
GitBranch,
|
||||
FileCode,
|
||||
@@ -13,14 +14,17 @@ import {
|
||||
CheckCircle,
|
||||
GitCommit,
|
||||
Code,
|
||||
Terminal
|
||||
Terminal,
|
||||
Info,
|
||||
CheckCheck
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo, SupportedIDE, SupportedTerminal } from '../../../../shared/types';
|
||||
import { MergeProgressOverlay } from './MergeProgressOverlay';
|
||||
import type { WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo, SupportedIDE, SupportedTerminal, MergeProgress, MergeLogEntry, MergeLogEntryType } from '../../../../shared/types';
|
||||
import { useSettingsStore } from '../../../stores/settings-store';
|
||||
|
||||
interface WorkspaceStatusProps {
|
||||
@@ -106,6 +110,97 @@ export function WorkspaceStatus({
|
||||
const preferredIDE = settings.preferredIDE || 'vscode';
|
||||
const preferredTerminal = settings.preferredTerminal || 'system';
|
||||
|
||||
// Merge progress state
|
||||
const [mergeProgress, setMergeProgress] = useState<MergeProgress | null>(null);
|
||||
const [logEntries, setLogEntries] = useState<MergeLogEntry[]>([]);
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
const prevIsMergingRef = useRef(isMerging);
|
||||
const mergeStartTimeRef = useRef<number | null>(null);
|
||||
const minDisplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const ipcCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Reset state when isMerging transitions from false → true
|
||||
useEffect(() => {
|
||||
if (isMerging && !prevIsMergingRef.current) {
|
||||
setMergeProgress(null);
|
||||
setLogEntries([]);
|
||||
setShowOverlay(true);
|
||||
mergeStartTimeRef.current = Date.now();
|
||||
}
|
||||
prevIsMergingRef.current = isMerging;
|
||||
}, [isMerging]);
|
||||
|
||||
// Minimum display time: keep overlay visible for at least 500ms after merge ends
|
||||
useEffect(() => {
|
||||
if (!isMerging && showOverlay && mergeStartTimeRef.current !== null) {
|
||||
const elapsed = Date.now() - mergeStartTimeRef.current;
|
||||
const MIN_DISPLAY_MS = 500;
|
||||
const remaining = Math.max(0, MIN_DISPLAY_MS - elapsed);
|
||||
|
||||
if (remaining > 0) {
|
||||
minDisplayTimerRef.current = setTimeout(() => {
|
||||
setShowOverlay(false);
|
||||
mergeStartTimeRef.current = null;
|
||||
}, remaining);
|
||||
} else {
|
||||
setShowOverlay(false);
|
||||
mergeStartTimeRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (minDisplayTimerRef.current) {
|
||||
clearTimeout(minDisplayTimerRef.current);
|
||||
minDisplayTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isMerging, showOverlay]);
|
||||
|
||||
// Subscribe to merge progress IPC events
|
||||
useEffect(() => {
|
||||
if (!isMerging) return;
|
||||
|
||||
const stageToLogType = (stage: string): MergeLogEntryType => {
|
||||
switch (stage) {
|
||||
case 'complete': return 'success';
|
||||
case 'error': return 'error';
|
||||
case 'resolving': return 'warning';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = window.electronAPI.onMergeProgress((_taskId: string, progress: MergeProgress) => {
|
||||
setMergeProgress(progress);
|
||||
setLogEntries(prev => [
|
||||
...prev,
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stageToLogType(progress.stage),
|
||||
message: progress.message,
|
||||
details: progress.details?.current_file,
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
// Store cleanup ref so we can call it on unmount even if isMerging changes
|
||||
ipcCleanupRef.current = cleanup;
|
||||
|
||||
return cleanup;
|
||||
}, [isMerging]);
|
||||
|
||||
// Ensure IPC listener cleanup on unmount during active merge
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ipcCleanupRef.current) {
|
||||
ipcCleanupRef.current();
|
||||
ipcCleanupRef.current = null;
|
||||
}
|
||||
if (minDisplayTimerRef.current) {
|
||||
clearTimeout(minDisplayTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenInIDE = async () => {
|
||||
if (!worktreeStatus.worktreePath) return;
|
||||
try {
|
||||
@@ -137,6 +232,12 @@ export function WorkspaceStatus({
|
||||
const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0;
|
||||
const hasAIConflicts = mergePreview && mergePreview.conflicts.length > 0;
|
||||
|
||||
// Conflict scenario detection for better UX messaging
|
||||
const conflictScenario = mergePreview?.gitConflicts?.scenario;
|
||||
const alreadyMergedFiles = mergePreview?.gitConflicts?.alreadyMergedFiles || [];
|
||||
const isAlreadyMerged = conflictScenario === 'already_merged';
|
||||
const isSuperseded = conflictScenario === 'superseded';
|
||||
|
||||
// Check if branch needs rebase (main has advanced since spec was created)
|
||||
// This requires AI merge even if no explicit file conflicts are detected
|
||||
const needsRebase = mergePreview?.gitConflicts?.needsRebase;
|
||||
@@ -177,7 +278,7 @@ export function WorkspaceStatus({
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<FileCode className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-foreground">{worktreeStatus.filesChanged || 0}</span> files
|
||||
<span className="font-medium text-foreground">{worktreeStatus.filesChanged || 0}</span> {t('taskReview:merge.status.files')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<GitCommit className="h-3.5 w-3.5" />
|
||||
@@ -267,8 +368,48 @@ export function WorkspaceStatus({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already Merged Scenario - Show friendly message when task changes exist in target */}
|
||||
{mergePreview && isAlreadyMerged && (
|
||||
<div className="flex items-start gap-2 p-2.5 rounded-lg bg-success/10 border border-success/20">
|
||||
<CheckCheck className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-success">
|
||||
{t('taskReview:merge.alreadyMergedTitle')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t('taskReview:merge.alreadyMergedDescription')}
|
||||
</p>
|
||||
{alreadyMergedFiles.length > 0 && alreadyMergedFiles.length <= 5 && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium">{t('taskReview:merge.matchingFiles')}:</span>
|
||||
<ul className="mt-1 list-disc list-inside">
|
||||
{alreadyMergedFiles.map(file => (
|
||||
<li key={file} className="truncate">{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Superseded Scenario - Target has newer version of changes */}
|
||||
{mergePreview && isSuperseded && (
|
||||
<div className="flex items-start gap-2 p-2.5 rounded-lg bg-info/10 border border-info/20">
|
||||
<Info className="h-4 w-4 text-info mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-info">
|
||||
{t('taskReview:merge.supersededTitle')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t('taskReview:merge.supersededDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Merge Status */}
|
||||
{mergePreview && (
|
||||
{mergePreview && !isAlreadyMerged && !isSuperseded && (
|
||||
<div className={cn(
|
||||
"flex items-center justify-between p-2.5 rounded-lg border",
|
||||
hasGitConflicts || isBranchBehind || hasPathMappedMerges
|
||||
@@ -282,8 +423,8 @@ export function WorkspaceStatus({
|
||||
<>
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-warning">Branch Diverged</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">AI will resolve</span>
|
||||
<span className="text-sm font-medium text-warning">{t('taskReview:merge.status.branchDiverged')}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">{t('taskReview:merge.status.aiWillResolve')}</span>
|
||||
</div>
|
||||
</>
|
||||
) : isBranchBehind || hasPathMappedMerges ? (
|
||||
@@ -291,26 +432,26 @@ export function WorkspaceStatus({
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-warning">
|
||||
{hasPathMappedMerges ? 'Files Renamed' : 'Branch Behind'}
|
||||
{hasPathMappedMerges ? t('taskReview:merge.status.filesRenamed') : t('taskReview:merge.status.branchBehind')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
AI will resolve ({hasPathMappedMerges ? `${pathMappedAIMergeCount} files` : `${commitsBehind} commits`})
|
||||
{t('taskReview:merge.status.aiWillResolve')} ({hasPathMappedMerges ? `${pathMappedAIMergeCount} ${t('taskReview:merge.status.files')}` : `${commitsBehind} commits`})
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : !hasAIConflicts ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
<span className="text-sm font-medium text-success">Ready to merge</span>
|
||||
<span className="text-sm font-medium text-success">{t('taskReview:merge.status.readyToMerge')}</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
{mergePreview.summary.totalFiles} files
|
||||
{mergePreview.summary.totalFiles} {t('taskReview:merge.status.files')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<span className="text-sm font-medium text-warning">
|
||||
{mergePreview.conflicts.length} conflict{mergePreview.conflicts.length !== 1 ? 's' : ''}
|
||||
{mergePreview.conflicts.length} {mergePreview.conflicts.length !== 1 ? t('taskReview:merge.status.conflicts') : t('taskReview:merge.status.conflict')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -323,7 +464,7 @@ export function WorkspaceStatus({
|
||||
onClick={() => onShowConflictDialog(true)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Details
|
||||
{t('taskReview:merge.status.details')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -332,7 +473,7 @@ export function WorkspaceStatus({
|
||||
onClick={onLoadMergePreview}
|
||||
disabled={isLoadingPreview}
|
||||
className="h-7 px-2"
|
||||
title="Refresh"
|
||||
title={t('taskReview:merge.status.refresh')}
|
||||
>
|
||||
{isLoadingPreview ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -344,8 +485,8 @@ export function WorkspaceStatus({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Git Conflicts Details */}
|
||||
{hasGitConflicts && mergePreview?.gitConflicts && (
|
||||
{/* Git Conflicts Details - hide for already_merged/superseded scenarios */}
|
||||
{hasGitConflicts && mergePreview?.gitConflicts && !isAlreadyMerged && !isSuperseded && (
|
||||
<div className="text-xs text-muted-foreground pl-6">
|
||||
{t('taskReview:merge.branchHasNewCommits', { branch: mergePreview.gitConflicts.baseBranch, count: mergePreview.gitConflicts.commitsBehind })}
|
||||
{mergePreview.gitConflicts.conflictingFiles.length > 0 && (
|
||||
@@ -357,7 +498,7 @@ export function WorkspaceStatus({
|
||||
)}
|
||||
|
||||
{/* Branch Behind Details (no explicit conflicts but needs AI merge due to path mappings) */}
|
||||
{!hasGitConflicts && isBranchBehind && mergePreview?.gitConflicts && (
|
||||
{!hasGitConflicts && isBranchBehind && mergePreview?.gitConflicts && !isAlreadyMerged && !isSuperseded && (
|
||||
<div className="text-xs text-muted-foreground pl-6">
|
||||
{t('taskReview:merge.branchHasNewCommitsSinceBuild', { branch: mergePreview.gitConflicts.baseBranch, count: commitsBehind })}
|
||||
{hasPathMappedMerges ? (
|
||||
@@ -373,10 +514,15 @@ export function WorkspaceStatus({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Merge Progress Overlay — shown during merge and for minimum display time after */}
|
||||
{(isMerging || showOverlay) && (
|
||||
<MergeProgressOverlay mergeProgress={mergeProgress} logEntries={logEntries} />
|
||||
)}
|
||||
|
||||
{/* Actions Footer */}
|
||||
<div className="px-4 py-3 bg-muted/20 border-t border-border space-y-3">
|
||||
{/* Stage Only Option - only show after conflicts have been checked */}
|
||||
{mergePreview && (
|
||||
{/* Stage Only Option - only show after conflicts have been checked (not for already_merged/superseded) */}
|
||||
{mergePreview && !isAlreadyMerged && !isSuperseded && (
|
||||
<label className="inline-flex items-center gap-2.5 text-sm cursor-pointer select-none px-3 py-2 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors">
|
||||
<Checkbox
|
||||
checked={stageOnly}
|
||||
@@ -386,7 +532,7 @@ export function WorkspaceStatus({
|
||||
<span className={cn(
|
||||
"transition-colors",
|
||||
stageOnly ? "text-foreground" : "text-muted-foreground"
|
||||
)}>Stage only (review in IDE before committing)</span>
|
||||
)}>{t('taskReview:merge.status.stageOnly')}</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -417,8 +563,81 @@ export function WorkspaceStatus({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* State 3: Merge preview loaded - show appropriate merge/stage button */}
|
||||
{mergePreview && !isLoadingPreview && (
|
||||
{/* State 3a: Already Merged - show "Mark as Done" as primary action */}
|
||||
{mergePreview && !isLoadingPreview && isAlreadyMerged && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="success"
|
||||
onClick={onMerge}
|
||||
disabled={isMerging || isDiscarding}
|
||||
className="flex-1"
|
||||
>
|
||||
{isMerging ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('taskReview:merge.buttons.completing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCheck className="mr-2 h-4 w-4" />
|
||||
{t('taskReview:merge.actions.markAsDone')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">
|
||||
{t('taskReview:merge.alreadyMergedTooltip')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* State 3b: Superseded - show both "View Comparison" and "Discard" */}
|
||||
{mergePreview && !isLoadingPreview && isSuperseded && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onShowConflictDialog(true)}
|
||||
disabled={isMerging || isDiscarding}
|
||||
className="flex-1"
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{t('taskReview:merge.actions.viewComparison')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">
|
||||
{t('taskReview:merge.supersededCompareTooltip')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => onShowDiscardDialog(true)}
|
||||
disabled={isMerging || isDiscarding}
|
||||
className="flex-1"
|
||||
>
|
||||
<FolderX className="mr-2 h-4 w-4" />
|
||||
{t('taskReview:merge.actions.discardTask')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">
|
||||
{t('taskReview:merge.supersededDiscardTooltip')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* State 3c: Normal merge - show appropriate merge/stage button */}
|
||||
{mergePreview && !isLoadingPreview && !isAlreadyMerged && !isSuperseded && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -456,8 +675,8 @@ export function WorkspaceStatus({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Create PR Button */}
|
||||
{onShowPRDialog && (
|
||||
{/* Create PR Button - hide for already_merged/superseded scenarios */}
|
||||
{onShowPRDialog && !isAlreadyMerged && !isSuperseded && (
|
||||
<Button
|
||||
variant="info"
|
||||
onClick={() => onShowPRDialog(true)}
|
||||
@@ -478,16 +697,19 @@ export function WorkspaceStatus({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onShowDiscardDialog(true)}
|
||||
disabled={isMerging || isDiscarding || isCreatingPR}
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10 hover:border-destructive/30"
|
||||
title="Discard build"
|
||||
>
|
||||
<FolderX className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Discard button - hide for superseded (shown as primary action there) */}
|
||||
{!isSuperseded && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onShowDiscardDialog(true)}
|
||||
disabled={isMerging || isDiscarding || isCreatingPR}
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10 hover:border-destructive/30"
|
||||
title={t('taskReview:merge.status.discardBuild')}
|
||||
>
|
||||
<FolderX className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,5 +97,6 @@ export const taskMock = {
|
||||
onTaskStatusChange: () => () => {},
|
||||
onTaskExecutionProgress: () => () => {},
|
||||
onTaskLogsChanged: () => () => {},
|
||||
onTaskLogsStream: () => () => {}
|
||||
onTaskLogsStream: () => () => {},
|
||||
onMergeProgress: () => () => {}
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ export const IPC_CHANNELS = {
|
||||
TASK_LOGS_UNWATCH: 'task:logsUnwatch', // Stop watching for log changes
|
||||
TASK_LOGS_CHANGED: 'task:logsChanged', // Event: logs changed (main -> renderer)
|
||||
TASK_LOGS_STREAM: 'task:logsStream', // Event: streaming log chunk (main -> renderer)
|
||||
TASK_MERGE_PROGRESS: 'task:mergeProgress', // Event: merge progress update (main -> renderer)
|
||||
|
||||
// Terminal operations
|
||||
TERMINAL_CREATE: 'terminal:create',
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"claudeCode": {
|
||||
"checking": "Checking Claude Code...",
|
||||
"upToDate": "Claude Code is up to date",
|
||||
"upToDateWithVersion": "Claude Code {{version}} installed",
|
||||
"updateAvailable": "Claude Code update available",
|
||||
"notInstalled": "Claude Code not installed",
|
||||
"error": "Error checking Claude Code",
|
||||
|
||||
@@ -22,6 +22,29 @@
|
||||
"fileRenamesDetected": "{{count}} file rename detected - AI will handle the merge.",
|
||||
"fileRenamesDetected_other": "{{count}} file renames detected - AI will handle the merge.",
|
||||
"filesRenamedOrMoved": "Files may have been renamed or moved - AI will handle the merge.",
|
||||
"alreadyMergedTitle": "Changes already in your branch",
|
||||
"alreadyMergedDescription": "These changes appear to already exist in your current branch. You can safely mark this task as done.",
|
||||
"alreadyMergedTooltip": "The task's changes are already present in your branch. Marking as done will clean up the worktree without merging.",
|
||||
"matchingFiles": "Matching files",
|
||||
"supersededTitle": "Changes superseded",
|
||||
"supersededDescription": "Your current branch has a newer version of these changes. Consider discarding this task or viewing the comparison.",
|
||||
"supersededCompareTooltip": "View a detailed comparison to see how the current branch differs from this task's changes.",
|
||||
"supersededDiscardTooltip": "Remove this task's worktree since the changes are no longer needed.",
|
||||
"status": {
|
||||
"branchDiverged": "Branch Diverged",
|
||||
"aiWillResolve": "AI will resolve",
|
||||
"filesRenamed": "Files Renamed",
|
||||
"branchBehind": "Branch Behind",
|
||||
"readyToMerge": "Ready to merge",
|
||||
"files": "files",
|
||||
"file": "file",
|
||||
"conflict": "conflict",
|
||||
"conflicts": "conflicts",
|
||||
"details": "Details",
|
||||
"refresh": "Refresh",
|
||||
"stageOnly": "Stage only (review in IDE before committing)",
|
||||
"discardBuild": "Discard build"
|
||||
},
|
||||
"buttons": {
|
||||
"stageWithAIMerge": "Stage with AI Merge",
|
||||
"mergeWithAI": "Merge with AI",
|
||||
@@ -29,7 +52,13 @@
|
||||
"mergeTo": "Merge to {{branch}}",
|
||||
"resolving": "Resolving...",
|
||||
"staging": "Staging...",
|
||||
"merging": "Merging..."
|
||||
"merging": "Merging...",
|
||||
"completing": "Completing..."
|
||||
},
|
||||
"actions": {
|
||||
"markAsDone": "Mark as Done",
|
||||
"discardTask": "Discard Task",
|
||||
"viewComparison": "View Comparison"
|
||||
}
|
||||
},
|
||||
"pr": {
|
||||
@@ -63,6 +92,30 @@
|
||||
"prTitle": "Leave empty to use the task title"
|
||||
}
|
||||
},
|
||||
"mergeProgress": {
|
||||
"stages": {
|
||||
"analyzing": "Analyzing changes",
|
||||
"detectingConflicts": "Detecting conflicts",
|
||||
"resolving": "Resolving conflicts",
|
||||
"validating": "Validating merge",
|
||||
"complete": "Merge complete",
|
||||
"error": "Merge failed",
|
||||
"stalled": "Merge stalled"
|
||||
},
|
||||
"conflictCounter": "{{found}} found, {{resolved}} resolved",
|
||||
"currentFile": "Current file",
|
||||
"viewLogs": "View logs",
|
||||
"hideLogs": "Hide logs",
|
||||
"logTypes": {
|
||||
"info": "Info",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"conflict": "Conflict",
|
||||
"resolution": "Resolution"
|
||||
},
|
||||
"completionMessage": "All changes have been merged successfully.",
|
||||
"errorMessage": "An error occurred during the merge process."
|
||||
},
|
||||
"bulkPR": {
|
||||
"title": "Create Pull Requests",
|
||||
"description": "Create pull requests for {{count}} selected tasks",
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"claudeCode": {
|
||||
"checking": "Vérification de Claude Code...",
|
||||
"upToDate": "Claude Code est à jour",
|
||||
"upToDateWithVersion": "Claude Code {{version}} installé",
|
||||
"updateAvailable": "Mise à jour Claude Code disponible",
|
||||
"notInstalled": "Claude Code non installé",
|
||||
"error": "Erreur de vérification de Claude Code",
|
||||
|
||||
@@ -22,6 +22,29 @@
|
||||
"fileRenamesDetected": "{{count}} renommage de fichier détecté - l'IA gérera la fusion.",
|
||||
"fileRenamesDetected_other": "{{count}} renommages de fichiers détectés - l'IA gérera la fusion.",
|
||||
"filesRenamedOrMoved": "Des fichiers ont peut-être été renommés ou déplacés - l'IA gérera la fusion.",
|
||||
"alreadyMergedTitle": "Modifications déjà dans votre branche",
|
||||
"alreadyMergedDescription": "Ces modifications semblent déjà exister dans votre branche actuelle. Vous pouvez marquer cette tâche comme terminée en toute sécurité.",
|
||||
"alreadyMergedTooltip": "Les modifications de la tâche sont déjà présentes dans votre branche. Marquer comme terminé nettoiera le worktree sans fusionner.",
|
||||
"matchingFiles": "Fichiers correspondants",
|
||||
"supersededTitle": "Modifications remplacées",
|
||||
"supersededDescription": "Votre branche actuelle a une version plus récente de ces modifications. Envisagez de supprimer cette tâche ou de voir la comparaison.",
|
||||
"supersededCompareTooltip": "Voir une comparaison détaillée pour voir comment la branche actuelle diffère des modifications de cette tâche.",
|
||||
"supersededDiscardTooltip": "Supprimer le worktree de cette tâche puisque les modifications ne sont plus nécessaires.",
|
||||
"status": {
|
||||
"branchDiverged": "Branche divergée",
|
||||
"aiWillResolve": "L'IA résoudra",
|
||||
"filesRenamed": "Fichiers renommés",
|
||||
"branchBehind": "Branche en retard",
|
||||
"readyToMerge": "Prêt à fusionner",
|
||||
"files": "fichiers",
|
||||
"file": "fichier",
|
||||
"conflict": "conflit",
|
||||
"conflicts": "conflits",
|
||||
"details": "Détails",
|
||||
"refresh": "Actualiser",
|
||||
"stageOnly": "Préparer seulement (réviser dans l'IDE avant de commiter)",
|
||||
"discardBuild": "Supprimer le build"
|
||||
},
|
||||
"buttons": {
|
||||
"stageWithAIMerge": "Préparer avec fusion IA",
|
||||
"mergeWithAI": "Fusionner avec IA",
|
||||
@@ -29,7 +52,13 @@
|
||||
"mergeTo": "Fusionner vers {{branch}}",
|
||||
"resolving": "Résolution...",
|
||||
"staging": "Préparation...",
|
||||
"merging": "Fusion..."
|
||||
"merging": "Fusion...",
|
||||
"completing": "Finalisation..."
|
||||
},
|
||||
"actions": {
|
||||
"markAsDone": "Marquer comme terminé",
|
||||
"discardTask": "Supprimer la tâche",
|
||||
"viewComparison": "Voir la comparaison"
|
||||
}
|
||||
},
|
||||
"pr": {
|
||||
@@ -63,6 +92,30 @@
|
||||
"prTitle": "Laissez vide pour utiliser le titre de la tâche"
|
||||
}
|
||||
},
|
||||
"mergeProgress": {
|
||||
"stages": {
|
||||
"analyzing": "Analyse des modifications",
|
||||
"detectingConflicts": "Détection des conflits",
|
||||
"resolving": "Résolution des conflits",
|
||||
"validating": "Validation de la fusion",
|
||||
"complete": "Fusion terminée",
|
||||
"error": "Échec de la fusion",
|
||||
"stalled": "Fusion bloquée"
|
||||
},
|
||||
"conflictCounter": "{{found}} trouvés, {{resolved}} résolus",
|
||||
"currentFile": "Fichier actuel",
|
||||
"viewLogs": "Voir les logs",
|
||||
"hideLogs": "Masquer les logs",
|
||||
"logTypes": {
|
||||
"info": "Info",
|
||||
"warning": "Avertissement",
|
||||
"error": "Erreur",
|
||||
"conflict": "Conflit",
|
||||
"resolution": "Résolution"
|
||||
},
|
||||
"completionMessage": "Toutes les modifications ont été fusionnées avec succès.",
|
||||
"errorMessage": "Une erreur s'est produite pendant le processus de fusion."
|
||||
},
|
||||
"bulkPR": {
|
||||
"title": "Créer des Pull Requests",
|
||||
"description": "Créer des pull requests pour {{count}} tâches sélectionnées",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { IPCResult } from './common';
|
||||
import type { KanbanPreferences } from './kanban';
|
||||
import type { SupportedIDE, SupportedTerminal } from './settings';
|
||||
import type {
|
||||
Project,
|
||||
@@ -44,7 +45,8 @@ import type {
|
||||
TaskMetadata,
|
||||
TaskLogs,
|
||||
TaskLogStreamChunk,
|
||||
ImageAttachment
|
||||
ImageAttachment,
|
||||
MergeProgress
|
||||
} from './task';
|
||||
import type {
|
||||
TerminalCreateOptions,
|
||||
@@ -136,7 +138,6 @@ import type {
|
||||
GitLabNewCommitsCheck
|
||||
} from './integrations';
|
||||
import type { APIProfile, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from './profile';
|
||||
import type { KanbanPreferences } from './kanban';
|
||||
|
||||
// Electron API exposed via contextBridge
|
||||
// Tab state interface (persisted in main process)
|
||||
@@ -159,7 +160,7 @@ export interface ElectronAPI {
|
||||
getTabState: () => Promise<IPCResult<TabState>>;
|
||||
saveTabState: (tabState: TabState) => Promise<IPCResult>;
|
||||
|
||||
// Kanban Preferences (persisted in main process per project)
|
||||
// Kanban preferences (per-project column collapse state)
|
||||
getKanbanPreferences: (projectId: string) => Promise<IPCResult<KanbanPreferences | null>>;
|
||||
saveKanbanPreferences: (projectId: string, preferences: KanbanPreferences) => Promise<IPCResult>;
|
||||
|
||||
@@ -771,6 +772,9 @@ export interface ElectronAPI {
|
||||
onTaskLogsStream: (
|
||||
callback: (specId: string, chunk: TaskLogStreamChunk) => void
|
||||
) => () => void;
|
||||
onMergeProgress: (
|
||||
callback: (taskId: string, progress: MergeProgress) => void
|
||||
) => () => void;
|
||||
|
||||
// File explorer operations
|
||||
listDirectory: (dirPath: string) => Promise<IPCResult<FileNode[]>>;
|
||||
|
||||
@@ -352,6 +352,13 @@ export interface PathMappedAIMerge {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Conflict scenario types for better UX messaging
|
||||
// - 'already_merged': Task changes already identical in target branch
|
||||
// - 'superseded': Target has newer version of same feature
|
||||
// - 'diverged': Standard diverged branches (AI can resolve)
|
||||
// - 'normal_conflict': Actual conflicting changes
|
||||
export type ConflictScenario = 'already_merged' | 'superseded' | 'diverged' | 'normal_conflict';
|
||||
|
||||
// Git-level conflict information (branch divergence)
|
||||
export interface GitConflictInfo {
|
||||
hasConflicts: boolean;
|
||||
@@ -364,6 +371,12 @@ export interface GitConflictInfo {
|
||||
pathMappedAIMerges?: PathMappedAIMerge[];
|
||||
// Total number of file renames detected
|
||||
totalRenames?: number;
|
||||
// Conflict scenario for better UX messaging
|
||||
scenario?: ConflictScenario;
|
||||
// Files that are already merged (identical in both branches)
|
||||
alreadyMergedFiles?: string[];
|
||||
// Human-readable message about the scenario
|
||||
scenarioMessage?: string;
|
||||
}
|
||||
|
||||
// Summary statistics from merge preview/execution
|
||||
@@ -379,6 +392,30 @@ export interface MergeStats {
|
||||
pathMappedAIMergeCount?: number;
|
||||
}
|
||||
|
||||
// Merge progress tracking (for progress bar during merge operations)
|
||||
export type MergeStage = 'analyzing' | 'detecting_conflicts' | 'resolving' | 'validating' | 'complete' | 'error';
|
||||
|
||||
export interface MergeProgress {
|
||||
stage: MergeStage;
|
||||
percent: number;
|
||||
message: string;
|
||||
details?: {
|
||||
conflicts_found?: number;
|
||||
conflicts_resolved?: number;
|
||||
current_file?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Merge log entry (for conflict resolution logging)
|
||||
export type MergeLogEntryType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface MergeLogEntry {
|
||||
timestamp: string;
|
||||
type: MergeLogEntryType;
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeMergeResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -442,10 +479,10 @@ export interface WorktreeListItem {
|
||||
path: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
commitCount?: number;
|
||||
filesChanged?: number;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
commitCount: number;
|
||||
filesChanged: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,20 +27,18 @@ import importlib.util
|
||||
|
||||
# Load file_lock first (models.py depends on it)
|
||||
file_lock_spec = importlib.util.spec_from_file_location(
|
||||
"file_lock",
|
||||
backend_path / "runners" / "github" / "file_lock.py"
|
||||
"file_lock", backend_path / "runners" / "github" / "file_lock.py"
|
||||
)
|
||||
file_lock_module = importlib.util.module_from_spec(file_lock_spec)
|
||||
sys.modules['file_lock'] = file_lock_module
|
||||
sys.modules["file_lock"] = file_lock_module
|
||||
file_lock_spec.loader.exec_module(file_lock_module)
|
||||
|
||||
# Load models next
|
||||
models_spec = importlib.util.spec_from_file_location(
|
||||
"models",
|
||||
backend_path / "runners" / "github" / "models.py"
|
||||
"models", backend_path / "runners" / "github" / "models.py"
|
||||
)
|
||||
models_module = importlib.util.module_from_spec(models_spec)
|
||||
sys.modules['models'] = models_module
|
||||
sys.modules["models"] = models_module
|
||||
models_spec.loader.exec_module(models_module)
|
||||
PRReviewFinding = models_module.PRReviewFinding
|
||||
PRReviewResult = models_module.PRReviewResult
|
||||
@@ -50,56 +48,58 @@ ReviewCategory = models_module.ReviewCategory
|
||||
# Load services module dependencies for parallel_orchestrator_reviewer
|
||||
category_utils_spec = importlib.util.spec_from_file_location(
|
||||
"category_utils",
|
||||
backend_path / "runners" / "github" / "services" / "category_utils.py"
|
||||
backend_path / "runners" / "github" / "services" / "category_utils.py",
|
||||
)
|
||||
category_utils_module = importlib.util.module_from_spec(category_utils_spec)
|
||||
sys.modules['services.category_utils'] = category_utils_module
|
||||
sys.modules["services.category_utils"] = category_utils_module
|
||||
category_utils_spec.loader.exec_module(category_utils_module)
|
||||
|
||||
# Load io_utils
|
||||
io_utils_spec = importlib.util.spec_from_file_location(
|
||||
"io_utils",
|
||||
backend_path / "runners" / "github" / "services" / "io_utils.py"
|
||||
"io_utils", backend_path / "runners" / "github" / "services" / "io_utils.py"
|
||||
)
|
||||
io_utils_module = importlib.util.module_from_spec(io_utils_spec)
|
||||
sys.modules['services.io_utils'] = io_utils_module
|
||||
sys.modules["services.io_utils"] = io_utils_module
|
||||
io_utils_spec.loader.exec_module(io_utils_module)
|
||||
|
||||
# Load pydantic_models
|
||||
pydantic_models_spec = importlib.util.spec_from_file_location(
|
||||
"pydantic_models",
|
||||
backend_path / "runners" / "github" / "services" / "pydantic_models.py"
|
||||
backend_path / "runners" / "github" / "services" / "pydantic_models.py",
|
||||
)
|
||||
pydantic_models_module = importlib.util.module_from_spec(pydantic_models_spec)
|
||||
sys.modules['services.pydantic_models'] = pydantic_models_module
|
||||
sys.modules["services.pydantic_models"] = pydantic_models_module
|
||||
pydantic_models_spec.loader.exec_module(pydantic_models_module)
|
||||
AgentAgreement = pydantic_models_module.AgentAgreement
|
||||
|
||||
# Load agent_utils (shared utility for working directory injection)
|
||||
agent_utils_spec = importlib.util.spec_from_file_location(
|
||||
"agent_utils",
|
||||
backend_path / "runners" / "github" / "services" / "agent_utils.py"
|
||||
"agent_utils", backend_path / "runners" / "github" / "services" / "agent_utils.py"
|
||||
)
|
||||
agent_utils_module = importlib.util.module_from_spec(agent_utils_spec)
|
||||
sys.modules['services.agent_utils'] = agent_utils_module
|
||||
sys.modules["services.agent_utils"] = agent_utils_module
|
||||
agent_utils_spec.loader.exec_module(agent_utils_module)
|
||||
|
||||
# Load parallel_orchestrator_reviewer (contains _is_finding_in_scope and _cross_validate_findings)
|
||||
orchestrator_spec = importlib.util.spec_from_file_location(
|
||||
"parallel_orchestrator_reviewer",
|
||||
backend_path / "runners" / "github" / "services" / "parallel_orchestrator_reviewer.py"
|
||||
backend_path
|
||||
/ "runners"
|
||||
/ "github"
|
||||
/ "services"
|
||||
/ "parallel_orchestrator_reviewer.py",
|
||||
)
|
||||
orchestrator_module = importlib.util.module_from_spec(orchestrator_spec)
|
||||
# Mock dependencies that aren't needed for unit testing
|
||||
# IMPORTANT: Save and restore ALL mocked modules to avoid polluting sys.modules for other tests
|
||||
_modules_to_mock = [
|
||||
'context_gatherer',
|
||||
'core.client',
|
||||
'gh_client',
|
||||
'phase_config',
|
||||
'services.pr_worktree_manager',
|
||||
'services.sdk_utils',
|
||||
'claude_agent_sdk',
|
||||
"context_gatherer",
|
||||
"core.client",
|
||||
"gh_client",
|
||||
"phase_config",
|
||||
"services.pr_worktree_manager",
|
||||
"services.sdk_utils",
|
||||
"claude_agent_sdk",
|
||||
]
|
||||
_original_modules = {name: sys.modules.get(name) for name in _modules_to_mock}
|
||||
for name in _modules_to_mock:
|
||||
@@ -120,6 +120,7 @@ _is_finding_in_scope = orchestrator_module._is_finding_in_scope
|
||||
# Phase 5+ Tests: Scope Filtering (Updated)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestScopeFiltering:
|
||||
"""Test scope filtering logic (updated for Phase 5 - uses is_impact_finding schema field)."""
|
||||
|
||||
@@ -132,11 +133,12 @@ class TestScopeFiltering:
|
||||
ParallelOrchestratorFinding Pydantic model. The actual code uses
|
||||
getattr(finding, 'is_impact_finding', False) to access it.
|
||||
"""
|
||||
|
||||
def _make_finding(
|
||||
file: str = "src/test.py",
|
||||
line: int = 10,
|
||||
is_impact_finding: bool = False,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
):
|
||||
defaults = {
|
||||
"id": "TEST001",
|
||||
@@ -152,6 +154,7 @@ class TestScopeFiltering:
|
||||
# Set is_impact_finding as attribute (accessed via getattr in _is_finding_in_scope)
|
||||
finding.is_impact_finding = is_impact_finding
|
||||
return finding
|
||||
|
||||
return _make_finding
|
||||
|
||||
def test_finding_in_changed_files_passes(self, make_finding):
|
||||
@@ -166,9 +169,7 @@ class TestScopeFiltering:
|
||||
"""Finding for a file NOT in changed_files should be filtered."""
|
||||
changed_files = ["src/auth.py", "src/utils.py"]
|
||||
finding = make_finding(
|
||||
file="src/database.py",
|
||||
line=10,
|
||||
description="This code has a bug"
|
||||
file="src/database.py", line=10, description="This code has a bug"
|
||||
)
|
||||
|
||||
is_valid, reason = _is_finding_in_scope(finding, changed_files)
|
||||
@@ -199,7 +200,7 @@ class TestScopeFiltering:
|
||||
file="src/utils.py",
|
||||
line=10,
|
||||
is_impact_finding=True, # Schema field replaces keyword detection
|
||||
description="This change breaks the helper function in utils.py"
|
||||
description="This change breaks the helper function in utils.py",
|
||||
)
|
||||
is_valid, _ = _is_finding_in_scope(finding, changed_files)
|
||||
assert is_valid
|
||||
@@ -213,7 +214,7 @@ class TestScopeFiltering:
|
||||
file="src/database.py",
|
||||
line=20,
|
||||
is_impact_finding=False,
|
||||
description="database.py depends on modified auth module"
|
||||
description="database.py depends on modified auth module",
|
||||
)
|
||||
is_valid, reason = _is_finding_in_scope(finding, changed_files)
|
||||
assert not is_valid
|
||||
@@ -248,15 +249,14 @@ github_dir = backend_path / "runners" / "github"
|
||||
# Load context_gatherer module directly using spec loader
|
||||
# This avoids the complex package import chain
|
||||
_cg_spec = importlib.util.spec_from_file_location(
|
||||
"context_gatherer_isolated",
|
||||
github_dir / "context_gatherer.py"
|
||||
"context_gatherer_isolated", github_dir / "context_gatherer.py"
|
||||
)
|
||||
_cg_module = importlib.util.module_from_spec(_cg_spec)
|
||||
# Set up minimal module environment
|
||||
sys.modules['context_gatherer_isolated'] = _cg_module
|
||||
sys.modules["context_gatherer_isolated"] = _cg_module
|
||||
# Mock only the gh_client dependency
|
||||
_mock_gh = MagicMock()
|
||||
sys.modules['gh_client'] = _mock_gh
|
||||
sys.modules["gh_client"] = _mock_gh
|
||||
_cg_spec.loader.exec_module(_cg_module)
|
||||
PRContextGathererIsolated = _cg_module.PRContextGatherer
|
||||
|
||||
@@ -278,7 +278,9 @@ class TestImportDetection:
|
||||
(src_dir / "config.ts").write_text("export const config = { debug: true };")
|
||||
|
||||
# Create index.ts that re-exports
|
||||
(src_dir / "index.ts").write_text("export * from './utils';\nexport { config } from './config';")
|
||||
(src_dir / "index.ts").write_text(
|
||||
"export * from './utils';\nexport { config } from './config';"
|
||||
)
|
||||
|
||||
# Create shared directory
|
||||
shared_dir = src_dir / "shared"
|
||||
@@ -286,7 +288,9 @@ class TestImportDetection:
|
||||
(shared_dir / "types.ts").write_text("export type User = { id: string };")
|
||||
|
||||
# Create Python module
|
||||
(src_dir / "python_module.py").write_text("from .helpers import util_func\nimport os")
|
||||
(src_dir / "python_module.py").write_text(
|
||||
"from .helpers import util_func\nimport os"
|
||||
)
|
||||
(src_dir / "helpers.py").write_text("def util_func(): pass")
|
||||
(src_dir / "__init__.py").write_text("")
|
||||
|
||||
@@ -295,19 +299,19 @@ class TestImportDetection:
|
||||
def test_path_alias_detection(self, temp_project):
|
||||
"""Path alias imports (@/utils) should be detected and resolved."""
|
||||
import json
|
||||
|
||||
# Create tsconfig.json with path aliases
|
||||
tsconfig = {
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@shared/*": ["src/shared/*"]
|
||||
}
|
||||
"paths": {"@/*": ["src/*"], "@shared/*": ["src/shared/*"]}
|
||||
}
|
||||
}
|
||||
(temp_project / "tsconfig.json").write_text(json.dumps(tsconfig))
|
||||
|
||||
# Create the target file that the alias points to
|
||||
(temp_project / "src" / "utils.ts").write_text("export const helper = () => {};")
|
||||
(temp_project / "src" / "utils.ts").write_text(
|
||||
"export const helper = () => {};"
|
||||
)
|
||||
|
||||
# Test file with alias import
|
||||
test_content = "import { helper } from '@/utils';"
|
||||
@@ -322,7 +326,9 @@ class TestImportDetection:
|
||||
assert isinstance(imports, set)
|
||||
# Normalize paths for cross-platform comparison (Windows uses backslashes)
|
||||
normalized_imports = {p.replace("\\", "/") for p in imports}
|
||||
assert "src/utils.ts" in normalized_imports, f"Expected 'src/utils.ts' in imports, got: {imports}"
|
||||
assert "src/utils.ts" in normalized_imports, (
|
||||
f"Expected 'src/utils.ts' in imports, got: {imports}"
|
||||
)
|
||||
|
||||
def test_commonjs_require_detection(self, temp_project):
|
||||
"""CommonJS require('./utils') should be detected."""
|
||||
@@ -384,7 +390,18 @@ class TestImportDetection:
|
||||
|
||||
|
||||
class TestReverseDepDetection:
|
||||
"""Test reverse dependency detection (Phase 2)."""
|
||||
"""Test reverse dependency detection (Phase 2).
|
||||
|
||||
ARCHITECTURE NOTE (2025-01): These tests document that programmatic file scanning
|
||||
has been intentionally removed. The _find_dependents() method now returns an empty
|
||||
set because LLM agents handle file discovery via their tools (Glob, Grep, Read).
|
||||
|
||||
This design change:
|
||||
- Removes the legacy 2000 file scan limit
|
||||
- Lets LLM agents use their judgment to find relevant files
|
||||
- Avoids pre-loading context that may not be needed
|
||||
- Scales better for large codebases
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_project_with_deps(self, tmp_path):
|
||||
@@ -392,7 +409,7 @@ class TestReverseDepDetection:
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
|
||||
# Create a utility file with non-generic name (helpers is in skip list)
|
||||
# Create a utility file with non-generic name
|
||||
(src_dir / "formatter.ts").write_text(
|
||||
"export function format(s: string) { return s; }"
|
||||
)
|
||||
@@ -405,63 +422,49 @@ class TestReverseDepDetection:
|
||||
"import { format } from './formatter';\nexport const fetch = () => {};"
|
||||
)
|
||||
|
||||
# Create a file that does NOT import formatter
|
||||
(src_dir / "standalone.ts").write_text(
|
||||
"export const standalone = () => {};"
|
||||
)
|
||||
|
||||
return tmp_path
|
||||
|
||||
def test_finds_files_importing_changed_file(self, temp_project_with_deps):
|
||||
"""Verify grep-based detection finds files that import a given file."""
|
||||
def test_find_dependents_returns_empty_set(self, temp_project_with_deps):
|
||||
"""_find_dependents() returns empty - LLM agents discover files via tools.
|
||||
|
||||
This is intentional: programmatic file scanning was removed in favor of
|
||||
letting LLM agents use Glob/Grep/Read tools to discover relevant files
|
||||
based on the PR context they receive.
|
||||
"""
|
||||
gatherer = PRContextGathererIsolated(temp_project_with_deps, pr_number=1)
|
||||
# Use non-generic name (helpers is in the skip list)
|
||||
dependents = gatherer._find_dependents("src/formatter.ts", max_results=10)
|
||||
|
||||
# Should find auth.ts and api.ts as dependents
|
||||
assert any("auth.ts" in d for d in dependents)
|
||||
assert any("api.ts" in d for d in dependents)
|
||||
# Should NOT include standalone.ts
|
||||
assert not any("standalone.ts" in d for d in dependents)
|
||||
# Method now intentionally returns empty set
|
||||
assert dependents == set()
|
||||
|
||||
def test_generic_names_not_skipped(self, tmp_path):
|
||||
"""Generic names (index, main, utils) are no longer skipped - LLM decides relevance."""
|
||||
def test_find_dependents_empty_for_any_file(self, tmp_path):
|
||||
"""Verify _find_dependents() returns empty for any input.
|
||||
|
||||
The LLM-driven architecture means agents decide what's relevant,
|
||||
not programmatic scanning.
|
||||
"""
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
|
||||
# Create files with generic names
|
||||
(src_dir / "index.ts").write_text("export * from './utils';")
|
||||
(src_dir / "main.ts").write_text("import { x } from './index';")
|
||||
|
||||
gatherer = PRContextGathererIsolated(tmp_path, pr_number=1)
|
||||
dependents = gatherer._find_dependents("src/index.ts")
|
||||
|
||||
# Generic names should NOT be skipped anymore (behavior changed in Phase 4)
|
||||
# The LLM-driven system decides what's relevant based on PR context
|
||||
dependents_index = gatherer._find_dependents("src/index.ts")
|
||||
# Returns empty - LLM agents handle file discovery
|
||||
assert dependents == set()
|
||||
|
||||
# main.ts imports index, so it should be found as a dependent
|
||||
assert "src/main.ts" in dependents_index
|
||||
|
||||
def test_respects_file_limit(self, tmp_path):
|
||||
"""Large repo search should stop after reaching file limit."""
|
||||
def test_find_dependents_returns_set_type(self, tmp_path):
|
||||
"""Verify _find_dependents() returns correct type (set)."""
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "unique_name.ts").write_text("export const x = 1;")
|
||||
(src_dir / "file.ts").write_text("export const x = 1;")
|
||||
|
||||
gatherer = PRContextGathererIsolated(tmp_path, pr_number=1)
|
||||
dependents = gatherer._find_dependents("src/file.ts")
|
||||
|
||||
# Mock os.walk to generate more files than the limit (2000)
|
||||
# This simulates a large codebase without creating actual files
|
||||
def mock_walk(path):
|
||||
# Yield a directory with 3000 TypeScript files
|
||||
yield (str(path), [], [f"file{i}.ts" for i in range(3000)])
|
||||
|
||||
with patch.object(_cg_module.os, "walk", mock_walk):
|
||||
# The function should stop after max_files_to_check (2000) files
|
||||
# and return gracefully without hanging
|
||||
dependents = gatherer._find_dependents("src/unique_name.ts")
|
||||
|
||||
# Should return a set (may be empty since mock files don't contain imports)
|
||||
# Should return a set (empty, but correct type)
|
||||
assert isinstance(dependents, set)
|
||||
|
||||
|
||||
@@ -479,6 +482,7 @@ class TestCrossValidation:
|
||||
@pytest.fixture
|
||||
def make_finding(self):
|
||||
"""Factory fixture to create PRReviewFinding instances."""
|
||||
|
||||
def _make_finding(
|
||||
id: str = "TEST001",
|
||||
file: str = "src/test.py",
|
||||
@@ -487,7 +491,7 @@ class TestCrossValidation:
|
||||
severity: ReviewSeverity = ReviewSeverity.HIGH,
|
||||
confidence: float = 0.7,
|
||||
source_agents: list = None,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
):
|
||||
return PRReviewFinding(
|
||||
id=id,
|
||||
@@ -499,8 +503,11 @@ class TestCrossValidation:
|
||||
line=line,
|
||||
confidence=confidence,
|
||||
source_agents=source_agents or [],
|
||||
**{k: v for k, v in kwargs.items() if k not in ["title", "description"]}
|
||||
**{
|
||||
k: v for k, v in kwargs.items() if k not in ["title", "description"]
|
||||
},
|
||||
)
|
||||
|
||||
return _make_finding
|
||||
|
||||
@pytest.fixture
|
||||
@@ -508,18 +515,13 @@ class TestCrossValidation:
|
||||
"""Create a mock ParallelOrchestratorReviewer instance."""
|
||||
from models import GitHubRunnerConfig
|
||||
|
||||
config = GitHubRunnerConfig(
|
||||
token="test-token",
|
||||
repo="test/repo"
|
||||
)
|
||||
config = GitHubRunnerConfig(token="test-token", repo="test/repo")
|
||||
# Create minimal directory structure
|
||||
github_dir = tmp_path / ".auto-claude" / "github"
|
||||
github_dir.mkdir(parents=True)
|
||||
|
||||
reviewer = ParallelOrchestratorReviewer(
|
||||
project_dir=tmp_path,
|
||||
github_dir=github_dir,
|
||||
config=config
|
||||
project_dir=tmp_path, github_dir=github_dir, config=config
|
||||
)
|
||||
return reviewer
|
||||
|
||||
@@ -533,7 +535,7 @@ class TestCrossValidation:
|
||||
category=ReviewCategory.SECURITY,
|
||||
confidence=0.7,
|
||||
source_agents=["security-reviewer"],
|
||||
description="SQL injection risk"
|
||||
description="SQL injection risk",
|
||||
)
|
||||
finding2 = make_finding(
|
||||
id="F2",
|
||||
@@ -542,10 +544,12 @@ class TestCrossValidation:
|
||||
category=ReviewCategory.SECURITY,
|
||||
confidence=0.6,
|
||||
source_agents=["quality-reviewer"],
|
||||
description="Input not sanitized"
|
||||
description="Input not sanitized",
|
||||
)
|
||||
|
||||
validated, agreement = mock_reviewer._cross_validate_findings([finding1, finding2])
|
||||
validated, agreement = mock_reviewer._cross_validate_findings(
|
||||
[finding1, finding2]
|
||||
)
|
||||
|
||||
# Should merge into one finding
|
||||
assert len(validated) == 1
|
||||
@@ -582,8 +586,12 @@ class TestCrossValidation:
|
||||
|
||||
def test_merged_finding_has_cross_validated_true(self, make_finding, mock_reviewer):
|
||||
"""Merged multi-agent findings should have cross_validated=True."""
|
||||
finding1 = make_finding(id="F1", file="src/test.py", line=5, source_agents=["agent1"])
|
||||
finding2 = make_finding(id="F2", file="src/test.py", line=5, source_agents=["agent2"])
|
||||
finding1 = make_finding(
|
||||
id="F1", file="src/test.py", line=5, source_agents=["agent1"]
|
||||
)
|
||||
finding2 = make_finding(
|
||||
id="F2", file="src/test.py", line=5, source_agents=["agent2"]
|
||||
)
|
||||
|
||||
validated, _ = mock_reviewer._cross_validate_findings([finding1, finding2])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user