feat(merge,oauth): add path-aware AI merge resolution and device code streaming (#296)

* improve/merge-confclit-layer

* improve AI resolution

* fix caching on merge conflicts

* imrpove merge layer with rebase

* fix(github): add OAuth authentication to follow-up PR review

The follow-up PR review AI analysis was failing with "Could not resolve
authentication method" because AsyncAnthropic() was instantiated without
credentials. The codebase uses OAuth tokens (not ANTHROPIC_API_KEY), so
the client needs the auth_token parameter.

Uses get_auth_token() from core.auth to retrieve the OAuth token from
environment variables or macOS Keychain, matching how initial reviews
authenticate via create_client().

* fix(merge): add validation to prevent AI writing natural language to files

When AI merge receives truncated file contents (due to character limits),
it sometimes responds with explanations like "I need to see the complete
file contents..." instead of actual merged code. This garbage was being
written directly to source files.

Adds two validation layers after AI merge:
1. Natural language detection - catches patterns like "I need to", "Let me"
2. Syntax validation - uses esbuild to verify TypeScript/JavaScript syntax

If either validation fails, the merge returns an error instead of writing
invalid content to the file.

Also adds project_dir field to ParallelMergeTask to enable syntax validation.

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

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

* fix(merge): skip git merge when AI already resolved path-mapped files

When AI successfully merges path-mapped files (due to file renames
between branches), the check only looked at `conflicts_resolved` which
was 0 for path-mapped cases. This caused the code to fall through to
`git merge` which then failed with conflicts.

Now also checks `files_merged` and `ai_assisted` stats to determine
if AI has already handled the merge. When files are AI-merged, they're
already written and staged - no need for git merge.

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

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

* fix device code issue with github

* fix(frontend): remember GitHub auth method (OAuth vs PAT) in settings

Previously, after authenticating via GitHub OAuth, the settings page
would show "Personal Access Token" input even though OAuth was used.
This was confusing for users who expected to see their OAuth status.

Added githubAuthMethod field to track how authentication was performed.
Settings UI now shows "Authenticated via GitHub OAuth" when OAuth was
used, with option to switch to manual token if needed. The auth method
persists across settings reopening.

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

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

* refactor(backend): centralize OAuth client creation in core/client.py

- Add create_message_client() for simple message API calls
- Refactor followup_reviewer.py to use centralized client factory
- Remove direct anthropic.AsyncAnthropic import from followup_reviewer
- Add proper ValueError handling for missing OAuth token
- Update docstrings to document both client factories

This ensures all AI interactions use the centralized OAuth authentication
in core/, avoiding direct ANTHROPIC_API_KEY usage per CLAUDE.md guidelines.

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

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

* fix(frontend): remove unused statusColor variable in WorkspaceStatus

Dead code cleanup - the statusColor variable was computed but never
used in the component.

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

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

* fix(tests): add BrowserWindow mock to oauth-handlers tests

The sendDeviceCodeToRenderer function uses BrowserWindow.getAllWindows()
which wasn't mocked, causing unhandled rejection errors in tests.

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

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

* fix(backend): use ClaudeSDKClient instead of raw anthropic SDK

Remove direct anthropic SDK import from core/client.py and update
followup_reviewer.py to use ClaudeSDKClient directly as per project
conventions. All AI interactions should use claude-agent-sdk.

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

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

* fix(backend): add debug logging for AI response in followup_reviewer

Add logging to diagnose why AI review returns no JSON - helps identify
if response is in thinking blocks vs text blocks.

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

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:
Andy
2025-12-26 13:42:45 +01:00
committed by GitHub
parent 8416f3076a
commit 26beefe39d
23 changed files with 661 additions and 85 deletions
+68 -1
View File
@@ -14,7 +14,14 @@ _PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from core.workspace.git_utils import _is_auto_claude_file, is_lock_file
from core.workspace.git_utils import (
_is_auto_claude_file,
apply_path_mapping,
detect_file_renames,
get_file_content_from_ref,
get_merge_base,
is_lock_file,
)
from debug import debug_warning
from ui import (
Icons,
@@ -680,6 +687,58 @@ def handle_merge_preview_command(
# but we want to show the user all files that will be merged
total_files_from_git = len(all_changed_files)
# Detect files that need AI merge due to path mappings (file renames)
# This happens when the target branch has renamed/moved files that the
# worktree modified at their old locations
path_mapped_ai_merges: list[dict] = []
path_mappings: dict[str, str] = {}
if git_conflicts["needs_rebase"] and git_conflicts["commits_behind"] > 0:
# Get the merge-base between the branches
spec_branch = git_conflicts["spec_branch"]
base_branch = git_conflicts["base_branch"]
merge_base = get_merge_base(project_dir, spec_branch, base_branch)
if merge_base:
# Detect file renames between merge-base and current base branch
path_mappings = detect_file_renames(
project_dir, merge_base, base_branch
)
if path_mappings:
debug(
MODULE,
f"Detected {len(path_mappings)} file rename(s) between merge-base and target",
sample_mappings={
k: v for k, v in list(path_mappings.items())[:3]
},
)
# Check which changed files have path mappings and need AI merge
for file_path in all_changed_files:
mapped_path = apply_path_mapping(file_path, path_mappings)
if mapped_path != file_path:
# File was renamed - check if both versions exist
worktree_content = get_file_content_from_ref(
project_dir, spec_branch, file_path
)
target_content = get_file_content_from_ref(
project_dir, base_branch, mapped_path
)
if worktree_content and target_content:
path_mapped_ai_merges.append(
{
"oldPath": file_path,
"newPath": mapped_path,
"reason": "File was renamed/moved and modified in both branches",
}
)
debug(
MODULE,
f"Path-mapped file needs AI merge: {file_path} -> {mapped_path}",
)
result = {
"success": True,
# Use git diff files as the authoritative list of files to merge
@@ -693,6 +752,9 @@ def handle_merge_preview_command(
"commitsBehind": git_conflicts["commits_behind"],
"baseBranch": git_conflicts["base_branch"],
"specBranch": git_conflicts["spec_branch"],
# Path-mapped files that need AI merge due to renames
"pathMappedAIMerges": path_mapped_ai_merges,
"totalRenames": len(path_mappings),
},
"summary": {
# Use git diff count, not semantic tracker count
@@ -702,6 +764,8 @@ def handle_merge_preview_command(
"autoMergeable": summary.get("auto_mergeable", 0),
"hasGitConflicts": git_conflicts["has_conflicts"]
and len(non_lock_conflicting_files) > 0,
# Include path-mapped AI merge count for UI display
"pathMappedAIMergeCount": len(path_mapped_ai_merges),
},
# Include lock files info so UI can optionally show them
"lockFilesExcluded": lock_files_excluded,
@@ -716,6 +780,8 @@ def handle_merge_preview_command(
total_conflicts=result["summary"]["totalConflicts"],
has_git_conflicts=git_conflicts["has_conflicts"],
auto_mergeable=result["summary"]["autoMergeable"],
path_mapped_ai_merges=len(path_mapped_ai_merges),
total_renames=len(path_mappings),
)
return result
@@ -736,5 +802,6 @@ def handle_merge_preview_command(
"conflictFiles": 0,
"totalConflicts": 0,
"autoMergeable": 0,
"pathMappedAIMergeCount": 0,
},
}
+4
View File
@@ -3,6 +3,10 @@ Claude SDK Client Configuration
===============================
Functions for creating and configuring the Claude Agent SDK client.
All AI interactions should use `create_client()` to ensure consistent OAuth authentication
and proper tool/MCP configuration. For simple message calls without full agent sessions,
use `ClaudeSDKClient` directly with `allowed_tools=[]` and `max_turns=1`.
"""
import json
+236 -28
View File
@@ -84,6 +84,12 @@ from core.workspace.git_utils import (
_is_auto_claude_file,
get_existing_build_worktree,
)
from core.workspace.git_utils import (
apply_path_mapping as _apply_path_mapping,
)
from core.workspace.git_utils import (
detect_file_renames as _detect_file_renames,
)
from core.workspace.git_utils import (
get_changed_files_from_branch as _get_changed_files_from_branch,
)
@@ -93,6 +99,9 @@ from core.workspace.git_utils import (
from core.workspace.git_utils import (
is_lock_file as _is_lock_file,
)
from core.workspace.git_utils import (
validate_merged_syntax as _validate_merged_syntax,
)
# Import from refactored modules in core/workspace/
from core.workspace.models import (
@@ -230,12 +239,15 @@ def merge_existing_build(
if smart_result is not None:
# Smart merge handled it (success or identified conflicts)
if smart_result.get("success"):
# Check if smart merge resolved git conflicts directly
# Check if smart merge resolved git conflicts or path-mapped files
stats = smart_result.get("stats", {})
had_conflicts = stats.get("conflicts_resolved", 0) > 0
files_merged = stats.get("files_merged", 0) > 0
ai_assisted = stats.get("ai_assisted", 0) > 0
if had_conflicts:
# Git conflicts were resolved (via AI or lock file exclusion) - changes are already staged
if had_conflicts or files_merged or ai_assisted:
# Git conflicts were resolved OR path-mapped files were AI merged
# Changes are already written and staged - no need for git merge
_print_merge_success(
no_commit, stats, spec_name=spec_name, keep_worktree=True
)
@@ -246,7 +258,7 @@ def merge_existing_build(
return True
else:
# No git conflicts, do standard git merge
# No conflicts and no files merged - do standard git merge
success_result = manager.merge_worktree(
spec_name, delete_after=False, no_commit=no_commit
)
@@ -731,6 +743,23 @@ def _resolve_git_conflicts_with_ai(
merge_base=merge_base[:12] if merge_base else None,
)
# Detect file renames between merge-base and target branch
# This handles cases where files were moved/renamed (e.g., directory restructures)
path_mappings: dict[str, str] = {}
if merge_base:
path_mappings = _detect_file_renames(project_dir, merge_base, base_branch)
if path_mappings:
debug(
MODULE,
f"Detected {len(path_mappings)} file renames between merge-base and target",
sample_mappings=dict(list(path_mappings.items())[:5]),
)
print(
muted(
f" Detected {len(path_mappings)} file rename(s) since branch creation"
)
)
# FIX: Copy NEW files FIRST before resolving conflicts
# This ensures dependencies exist before files that import them are written
changed_files = _get_changed_files_from_branch(
@@ -748,14 +777,24 @@ def _resolve_git_conflicts_with_ai(
project_dir, spec_branch, file_path
)
if content is not None:
target_path = project_dir / file_path
# Apply path mapping - write to new location if file was renamed
target_file_path = _apply_path_mapping(file_path, path_mappings)
target_path = project_dir / target_file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", file_path], cwd=project_dir, capture_output=True
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
resolved_files.append(file_path)
debug(MODULE, f"Copied new file: {file_path}")
resolved_files.append(target_file_path)
if target_file_path != file_path:
debug(
MODULE,
f"Copied new file with path mapping: {file_path} -> {target_file_path}",
)
else:
debug(MODULE, f"Copied new file: {file_path}")
except Exception as e:
debug_warning(MODULE, f"Could not copy new file {file_path}: {e}")
@@ -769,20 +808,26 @@ def _resolve_git_conflicts_with_ai(
debug(MODULE, "Categorizing conflicting files for parallel processing")
for file_path in conflicting_files:
debug(MODULE, f"Categorizing conflicting file: {file_path}")
# Apply path mapping to get the target path in the current branch
target_file_path = _apply_path_mapping(file_path, path_mappings)
debug(
MODULE,
f"Categorizing conflicting file: {file_path}"
+ (f" -> {target_file_path}" if target_file_path != file_path else ""),
)
try:
# Get content from main branch
# Get content from main branch using MAPPED path (file may have been renamed)
main_content = _get_file_content_from_ref(
project_dir, base_branch, file_path
project_dir, base_branch, target_file_path
)
# Get content from worktree branch
# Get content from worktree branch using ORIGINAL path
worktree_content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
# Get content from merge-base (common ancestor)
# Get content from merge-base (common ancestor) using ORIGINAL path
base_content = None
if merge_base:
base_content = _get_file_content_from_ref(
@@ -795,38 +840,49 @@ def _resolve_git_conflicts_with_ai(
if main_content is None:
# File only exists in worktree - it's a new file (no AI needed)
simple_merges.append((file_path, worktree_content))
# Write to target path (mapped if applicable)
simple_merges.append((target_file_path, worktree_content))
debug(MODULE, f" {file_path}: new file (no AI needed)")
elif worktree_content is None:
# File only exists in main - was deleted in worktree (no AI needed)
simple_merges.append((file_path, None)) # None = delete
simple_merges.append((target_file_path, None)) # None = delete
debug(MODULE, f" {file_path}: deleted (no AI needed)")
else:
# File exists in both - check if it's a lock file
if _is_lock_file(file_path):
if _is_lock_file(target_file_path):
# Lock files should be excluded from merge entirely
# They must be regenerated after merge by running the package manager
# (e.g., npm install, pnpm install, uv sync, cargo update)
#
# Strategy: Take main branch version and let user regenerate
lock_files_excluded.append(file_path)
simple_merges.append((file_path, main_content))
lock_files_excluded.append(target_file_path)
simple_merges.append((target_file_path, main_content))
debug(
MODULE,
f" {file_path}: lock file (excluded - will use main version)",
f" {target_file_path}: lock file (excluded - will use main version)",
)
else:
# Regular file - needs AI merge
# Store the TARGET path for writing, but track original for content retrieval
files_needing_ai_merge.append(
ParallelMergeTask(
file_path=file_path,
file_path=target_file_path, # Use target path for writing
main_content=main_content,
worktree_content=worktree_content,
base_content=base_content,
spec_name=spec_name,
project_dir=project_dir,
)
)
debug(MODULE, f" {file_path}: needs AI merge")
debug(
MODULE,
f" {file_path}: needs AI merge"
+ (
f" (will write to {target_file_path})"
if target_file_path != file_path
else ""
),
)
except Exception as e:
print(error(f" ✗ Failed to categorize {file_path}: {e}"))
@@ -946,29 +1002,140 @@ def _resolve_git_conflicts_with_ai(
if f not in conflicting_files and s != "A" # Skip new files, already copied
]
# Separate files that need AI merge (path-mapped) from simple copies
path_mapped_files: list[ParallelMergeTask] = []
simple_copy_files: list[
tuple[str, str, str]
] = [] # (file_path, target_path, status)
for file_path, status in non_conflicting:
# Apply path mapping for renamed/moved files
target_file_path = _apply_path_mapping(file_path, path_mappings)
if target_file_path != file_path and status != "D":
# File was renamed/moved - needs AI merge to incorporate changes
# Get content from worktree (old path) and target branch (new path)
worktree_content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
target_content = _get_file_content_from_ref(
project_dir, base_branch, target_file_path
)
base_content = None
if merge_base:
base_content = _get_file_content_from_ref(
project_dir, merge_base, file_path
)
if worktree_content and target_content:
# Both exist - need AI merge
path_mapped_files.append(
ParallelMergeTask(
file_path=target_file_path,
main_content=target_content,
worktree_content=worktree_content,
base_content=base_content,
spec_name=spec_name,
project_dir=project_dir,
)
)
debug(
MODULE,
f"Path-mapped file needs AI merge: {file_path} -> {target_file_path}",
)
elif worktree_content:
# Only exists in worktree - simple copy to new path
simple_copy_files.append((file_path, target_file_path, status))
else:
# No path mapping or deletion - simple operation
simple_copy_files.append((file_path, target_file_path, status))
# Process path-mapped files with AI merge
if path_mapped_files:
print()
print_status(
f"Merging {len(path_mapped_files)} path-mapped file(s) with AI...",
"progress",
)
import time
start_time = time.time()
# Run parallel merges for path-mapped files
path_mapped_results = asyncio.run(
_run_parallel_merges(
tasks=path_mapped_files,
project_dir=project_dir,
max_concurrent=MAX_PARALLEL_AI_MERGES,
)
)
elapsed = time.time() - start_time
for result in path_mapped_results:
if result.success:
target_path = project_dir / result.file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(result.merged_content, encoding="utf-8")
subprocess.run(
["git", "add", result.file_path],
cwd=project_dir,
capture_output=True,
)
resolved_files.append(result.file_path)
if result.was_auto_merged:
auto_merged_count += 1
print(success(f"{result.file_path} (auto-merged)"))
else:
ai_merged_count += 1
print(success(f"{result.file_path} (AI merged)"))
else:
print(error(f"{result.file_path}: {result.error}"))
remaining_conflicts.append(
{
"file": result.file_path,
"reason": result.error or "AI could not merge path-mapped file",
"severity": "high",
}
)
print(muted(f" Path-mapped merge completed in {elapsed:.1f}s"))
# Process simple copy/delete files
for file_path, target_file_path, status in simple_copy_files:
try:
if status == "D":
# Deleted in worktree
target_path = project_dir / file_path
# Deleted in worktree - delete from target path
target_path = project_dir / target_file_path
if target_path.exists():
target_path.unlink()
subprocess.run(
["git", "add", file_path], cwd=project_dir, capture_output=True
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
else:
# Added or modified - copy from worktree
# Modified without path change - simple copy
content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
if content is not None:
target_path = project_dir / file_path
target_path = project_dir / target_file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", file_path], cwd=project_dir, capture_output=True
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
resolved_files.append(file_path)
resolved_files.append(target_file_path)
if target_file_path != file_path:
debug(
MODULE,
f"Merged with path mapping: {file_path} -> {target_file_path}",
)
except Exception as e:
print(muted(f" Warning: Could not process {file_path}: {e}"))
@@ -1274,6 +1441,47 @@ async def _merge_file_with_ai_async(
# Strip any code fences the model might have added
merged_content = _strip_code_fences(response_text.strip())
# VALIDATION: Check if AI returned natural language instead of code
# This catches cases where AI says "I need to see more..." instead of merging
natural_language_patterns = [
"I need to",
"Let me",
"I cannot",
"I'm unable",
"The file appears",
"I don't have",
"Unfortunately",
"I apologize",
]
first_line = merged_content.split("\n")[0] if merged_content else ""
if any(pattern in first_line for pattern in natural_language_patterns):
debug_warning(
MODULE,
f"AI returned natural language instead of code for {task.file_path}: {first_line[:100]}",
)
return ParallelMergeResult(
file_path=task.file_path,
merged_content=None,
success=False,
error=f"AI returned explanation instead of code: {first_line[:80]}...",
)
# VALIDATION: Run syntax check on the merged content
is_valid, syntax_error = _validate_merged_syntax(
task.file_path, merged_content, task.project_dir
)
if not is_valid:
debug_warning(
MODULE,
f"AI merge produced invalid syntax for {task.file_path}: {syntax_error}",
)
return ParallelMergeResult(
file_path=task.file_path,
merged_content=None,
success=False,
error=f"AI merge produced invalid syntax: {syntax_error}",
)
debug(MODULE, f"AI merged {task.file_path} successfully")
return ParallelMergeResult(
file_path=task.file_path,
+105
View File
@@ -83,6 +83,111 @@ MERGE_LOCK_TIMEOUT = 300 # 5 minutes
MAX_SYNTAX_FIX_RETRIES = 2
def detect_file_renames(
project_dir: Path,
from_ref: str,
to_ref: str,
) -> dict[str, str]:
"""
Detect file renames between two git refs using git's rename detection.
This analyzes the commit history between two refs to find all file
renames/moves. Critical for merging changes from older branches that
used a different directory structure.
Uses git's -M flag for rename detection with high similarity threshold.
Args:
project_dir: Project directory
from_ref: Starting ref (e.g., merge-base commit or old branch)
to_ref: Target ref (e.g., current branch HEAD)
Returns:
Dict mapping old_path -> new_path for all renamed files
"""
renames: dict[str, str] = {}
try:
# Use git log with rename detection to find all renames between refs
# -M flag enables rename detection
# --diff-filter=R shows only renames
# --name-status shows status and file names
result = subprocess.run(
[
"git",
"log",
"--name-status",
"-M",
"--diff-filter=R",
"--format=", # No commit info, just file changes
f"{from_ref}..{to_ref}",
],
cwd=project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
if line.startswith("R"):
# Format: R100\told_path\tnew_path (tab-separated)
parts = line.split("\t")
if len(parts) >= 3:
old_path = parts[1]
new_path = parts[2]
renames[old_path] = new_path
except Exception:
pass # Return empty dict on error
return renames
def apply_path_mapping(file_path: str, mappings: dict[str, str]) -> str:
"""
Apply file path mappings to get the new path for a file.
Args:
file_path: Original file path (from older branch)
mappings: Dict of old_path -> new_path from detect_file_renames
Returns:
Mapped new path if found, otherwise original path
"""
# Direct match
if file_path in mappings:
return mappings[file_path]
# No mapping found
return file_path
def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
"""
Get the merge-base commit between two refs.
Args:
project_dir: Project directory
ref1: First ref (branch/commit)
ref2: Second ref (branch/commit)
Returns:
Merge-base commit hash, or None if not found
"""
try:
result = subprocess.run(
["git", "merge-base", ref1, ref2],
cwd=project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return None
def has_uncommitted_changes(project_dir: Path) -> bool:
"""Check if user has unsaved work."""
result = subprocess.run(
+1
View File
@@ -36,6 +36,7 @@ class ParallelMergeTask:
worktree_content: str
base_content: str | None
spec_name: str
project_dir: Path
@dataclass
@@ -543,10 +543,6 @@ class FollowupReviewer:
Returns parsed AI response with finding resolutions and new findings,
or None if AI review fails.
"""
# Use raw Anthropic client for simple message API calls
# (ClaudeSDKClient is for agent sessions, not direct message calls)
import anthropic
self._report_progress(
"analyzing", 65, "Running AI-powered review...", context.pr_number
)
@@ -623,21 +619,51 @@ Please analyze this follow-up review context and provide your response in the JS
"""
try:
# Create Anthropic client for simple message API call
# Note: For agent sessions with tools, use ClaudeSDKClient instead
client = anthropic.AsyncAnthropic()
# Use ClaudeSDKClient directly for simple message calls
# (no agent tools needed, just a single query/response)
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
model = self.config.model or "claude-sonnet-4-5-20250929"
response = await client.messages.create(
model=model,
max_tokens=4096,
messages=[{"role": "user", "content": user_message}],
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model=model,
system_prompt="You are a code review assistant. Analyze the provided context and respond with valid JSON.",
allowed_tools=[],
max_turns=1,
max_thinking_tokens=2048,
)
)
# Parse the response
response_text = response.content[0].text
response_text = ""
async with client:
await client.query(user_message)
async for msg in client.receive_response():
msg_type = type(msg).__name__
logger.debug(f"AI response message type: {msg_type}")
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
block_type = type(block).__name__
logger.debug(f" Content block type: {block_type}")
if hasattr(block, "text"):
response_text += block.text
elif hasattr(block, "thinking"):
# Skip thinking blocks - we only want the final text
logger.debug(" (skipping thinking block)")
if not response_text:
logger.warning("AI returned empty response (no text blocks found)")
return None
logger.debug(f"AI response text (first 500 chars): {response_text[:500]}")
return self._parse_ai_response(response_text)
except ValueError as e:
# OAuth token not found
logger.warning(f"No OAuth token available for AI review: {e}")
print("AI review failed: No OAuth token found", flush=True)
return None
except Exception as e:
logger.error(f"AI review failed: {e}")
return None
@@ -44,11 +44,21 @@ vi.mock('electron', () => {
}
})();
// Mock BrowserWindow for sendDeviceCodeToRenderer
const mockBrowserWindow = {
getAllWindows: () => [{
webContents: {
send: vi.fn()
}
}]
};
return {
ipcMain: mockIpcMain,
shell: {
openExternal: (...args: unknown[]) => mockOpenExternal(...args)
}
},
BrowserWindow: mockBrowserWindow
};
});
@@ -3,12 +3,28 @@
* Provides a simpler OAuth flow than manual PAT creation
*/
import { ipcMain, shell } from 'electron';
import { ipcMain, shell, BrowserWindow } from 'electron';
import { execSync, execFileSync, spawn } from 'child_process';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult } from '../../../shared/types';
import { getAugmentedEnv, findExecutable } from '../../env-utils';
/**
* Send device code info to all renderer windows immediately when extracted
* This allows the UI to display the code while the auth process is still running
*/
function sendDeviceCodeToRenderer(deviceCode: string, authUrl: string, browserOpened: boolean): void {
debugLog('Sending device code to renderer windows');
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
win.webContents.send(IPC_CHANNELS.GITHUB_AUTH_DEVICE_CODE, {
deviceCode,
authUrl,
browserOpened
});
}
}
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
@@ -265,6 +281,10 @@ export function registerStartGhAuth(): void {
// Don't fail here - we'll return the device code so user can manually navigate
}
// IMMEDIATELY send device code to renderer so user can see it while auth is in progress
// This is critical - the frontend needs to display the code while the gh process is still running
sendDeviceCodeToRenderer(extractedDeviceCode, extractedAuthUrl, browserOpenedSuccessfully);
// Extraction complete - mutex flag stays true to prevent re-extraction
// The deviceCodeExtracted flag will prevent future attempts
extractionInProgress = false;
@@ -155,6 +155,11 @@ export interface GitHubAPI {
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
// OAuth event listener - receives device code immediately when extracted
onGitHubAuthDeviceCode: (
callback: (data: { deviceCode: string; authUrl: string; browserOpened: boolean }) => void
) => IpcListenerCleanup;
// Repository detection and management
detectGitHubRepo: (projectPath: string) => Promise<IPCResult<string>>;
getGitHubBranches: (repo: string, token: string) => Promise<IPCResult<string[]>>;
@@ -398,6 +403,12 @@ export const createGitHubAPI = (): GitHubAPI => ({
listGitHubUserRepos: (): Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_LIST_USER_REPOS),
// OAuth event listener - receives device code immediately when extracted (during auth process)
onGitHubAuthDeviceCode: (
callback: (data: { deviceCode: string; authUrl: string; browserOpened: boolean }) => void
): IpcListenerCleanup =>
createIpcListener(IPC_CHANNELS.GITHUB_AUTH_DEVICE_CODE, callback),
// Repository detection and management
detectGitHubRepo: (projectPath: string): Promise<IPCResult<string>> =>
invokeIpc(IPC_CHANNELS.GITHUB_DETECT_REPO, projectPath),
+3 -1
View File
@@ -504,6 +504,7 @@ export function App() {
githubToken: string;
githubRepo: string;
mainBranch: string;
githubAuthMethod?: 'oauth' | 'pat';
}) => {
if (!gitHubSetupProject) return;
@@ -518,7 +519,8 @@ export function App() {
await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, {
githubEnabled: true,
githubToken: settings.githubToken, // GitHub token for repo access
githubRepo: settings.githubRepo
githubRepo: settings.githubRepo,
githubAuthMethod: settings.githubAuthMethod // Track how user authenticated
});
// Update project settings with mainBranch
@@ -42,7 +42,7 @@ interface GitHubSetupModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: Project;
onComplete: (settings: { githubToken: string; githubRepo: string; mainBranch: string }) => void;
onComplete: (settings: { githubToken: string; githubRepo: string; mainBranch: string; githubAuthMethod?: 'oauth' | 'pat' }) => void;
onSkip?: () => void;
}
@@ -367,7 +367,8 @@ export function GitHubSetupModal({
onComplete({
githubToken,
githubRepo,
mainBranch: selectedBranch
mainBranch: selectedBranch,
githubAuthMethod: 'oauth' // Setup modal always uses OAuth flow
});
}
};
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Github, RefreshCw, KeyRound, Info } from 'lucide-react';
import { Github, RefreshCw, KeyRound, Info, CheckCircle2 } from 'lucide-react';
import { CollapsibleSection } from './CollapsibleSection';
import { StatusBadge } from './StatusBadge';
import { PasswordInput } from './PasswordInput';
@@ -31,17 +31,24 @@ export function GitHubIntegrationSection({
isCheckingGitHub,
projectName,
}: GitHubIntegrationSectionProps) {
const [showOAuthFlow, setShowOAuthFlow] = useState(false);
// Show OAuth flow if user previously used OAuth, or if there's no token yet
const [showOAuthFlow, setShowOAuthFlow] = useState(
envConfig.githubAuthMethod === 'oauth' || (!envConfig.githubToken && !envConfig.githubAuthMethod)
);
const badge = envConfig.githubEnabled ? (
<StatusBadge status="success" label="Enabled" />
) : null;
const handleOAuthSuccess = (token: string, _username?: string) => {
onUpdateConfig({ githubToken: token });
onUpdateConfig({ githubToken: token, githubAuthMethod: 'oauth' });
setShowOAuthFlow(false);
};
const handleManualTokenChange = (value: string) => {
onUpdateConfig({ githubToken: value, githubAuthMethod: 'pat' });
};
return (
<CollapsibleSection
title="GitHub Integration"
@@ -81,7 +88,26 @@ export function GitHubIntegrationSection({
{envConfig.githubEnabled && (
<>
{showOAuthFlow ? (
{/* Show OAuth connected state when authenticated via OAuth */}
{envConfig.githubAuthMethod === 'oauth' && envConfig.githubToken && !showOAuthFlow ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">GitHub Authentication</Label>
<Button
variant="ghost"
size="sm"
onClick={() => onUpdateConfig({ githubToken: '', githubAuthMethod: undefined })}
className="text-muted-foreground hover:text-foreground"
>
Use Manual Token
</Button>
</div>
<div className="flex items-center gap-2 p-3 rounded-lg border border-success/30 bg-success/5">
<CheckCircle2 className="h-4 w-4 text-success" />
<span className="text-sm text-foreground">Authenticated via GitHub OAuth (gh CLI)</span>
</div>
</div>
) : showOAuthFlow ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">GitHub Authentication</Label>
@@ -125,7 +151,7 @@ export function GitHubIntegrationSection({
</p>
<PasswordInput
value={envConfig.githubToken || ''}
onChange={(value) => onUpdateConfig({ githubToken: value })}
onChange={handleManualTokenChange}
placeholder="ghp_xxxxxxxx or github_pat_xxxxxxxx"
/>
</div>
@@ -111,6 +111,38 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once on mount, checkGitHubStatus is intentionally excluded
}, [clearAuthTimeout]);
// Listen for device code events from the main process
// This allows us to display the code IMMEDIATELY when extracted, not after the auth completes
useEffect(() => {
if (status !== 'authenticating') {
return;
}
debugLog('Setting up device code event listener');
// Listen for device code from main process (sent immediately when extracted)
const cleanup = window.electronAPI.onGitHubAuthDeviceCode((data) => {
debugLog('Received device code from main process:', {
hasCode: !!data.deviceCode,
authUrl: data.authUrl,
browserOpened: data.browserOpened
});
if (data.deviceCode) {
setDeviceCode(data.deviceCode);
}
if (data.authUrl) {
setAuthUrl(data.authUrl);
}
setBrowserOpened(data.browserOpened);
});
return () => {
debugLog('Cleaning up device code event listener');
cleanup();
};
}, [status]);
const checkGitHubStatus = async () => {
debugLog('checkGitHubStatus() called');
setStatus('checking');
@@ -158,8 +158,8 @@ export function GitHubIntegration({
debugLog('handleOAuthSuccess called with token length:', token.length);
debugLog('OAuth username:', username);
// Update the token
updateEnvConfig({ githubToken: token });
// Update the token and auth method
updateEnvConfig({ githubToken: token, githubAuthMethod: 'oauth' });
// Show success state with username
setOauthUsername(username || null);
@@ -193,21 +193,14 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
});
}, []);
// Restore merge preview from sessionStorage on mount (survives HMR reloads)
// Clear merge preview cache when task changes to ensure fresh data is fetched
// This invalidates any stale cached data (e.g., old uncommitted changes status)
useEffect(() => {
const storageKey = `mergePreview-${task.id}`;
const stored = sessionStorage.getItem(storageKey);
if (stored) {
try {
const previewData = JSON.parse(stored);
console.warn('%c[useTaskDetail] Restored merge preview from sessionStorage:', 'color: magenta;', previewData);
setMergePreview(previewData);
// Don't auto-popup - restored data stays silent
} catch {
console.warn('[useTaskDetail] Failed to parse stored merge preview');
sessionStorage.removeItem(storageKey);
}
}
// Clear any existing cached preview - we want fresh data when opening a task
sessionStorage.removeItem(storageKey);
setMergePreview(null);
console.warn('[useTaskDetail] Cleared merge preview cache for task:', task.id);
}, [task.id]);
// Load merge preview (conflict detection)
@@ -62,14 +62,21 @@ export function WorkspaceStatus({
const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0;
const hasAIConflicts = mergePreview && mergePreview.conflicts.length > 0;
// Determine overall status
const statusColor = hasGitConflicts
? 'warning'
: hasUncommittedChanges
? 'warning'
: mergePreview && !hasAIConflicts
? 'success'
: 'info';
// 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;
const commitsBehind = mergePreview?.gitConflicts?.commitsBehind || 0;
// Path-mapped files that need AI merge due to file renames
const pathMappedAIMergeCount = mergePreview?.summary?.pathMappedAIMergeCount || 0;
const totalRenames = mergePreview?.gitConflicts?.totalRenames || 0;
// Branch is behind if needsRebase is true and there are commits to catch up on
// This triggers AI merge for path-mapped files even without explicit conflicts
const isBranchBehind = needsRebase && commitsBehind > 0;
// Has path-mapped files that need AI merge
const hasPathMappedMerges = pathMappedAIMergeCount > 0;
return (
<div className="rounded-xl border border-border bg-card overflow-hidden">
@@ -201,7 +208,7 @@ export function WorkspaceStatus({
{mergePreview && (
<div className={cn(
"flex items-center justify-between p-2.5 rounded-lg border",
hasGitConflicts
hasGitConflicts || isBranchBehind || hasPathMappedMerges
? "bg-warning/10 border-warning/20"
: !hasAIConflicts
? "bg-success/10 border-success/20"
@@ -216,6 +223,18 @@ export function WorkspaceStatus({
<span className="text-xs text-muted-foreground ml-2">AI will resolve</span>
</div>
</>
) : isBranchBehind || hasPathMappedMerges ? (
<>
<AlertTriangle className="h-4 w-4 text-warning" />
<div>
<span className="text-sm font-medium text-warning">
{hasPathMappedMerges ? 'Files Renamed' : 'Branch Behind'}
</span>
<span className="text-xs text-muted-foreground ml-2">
AI will resolve ({hasPathMappedMerges ? `${pathMappedAIMergeCount} files` : `${commitsBehind} commits`})
</span>
</div>
</>
) : !hasAIConflicts ? (
<>
<CheckCircle className="h-4 w-4 text-success" />
@@ -234,7 +253,7 @@ export function WorkspaceStatus({
)}
</div>
<div className="flex items-center gap-1">
{(hasGitConflicts || hasAIConflicts) && (
{(hasGitConflicts || isBranchBehind || hasPathMappedMerges || hasAIConflicts) && (
<Button
variant="ghost"
size="sm"
@@ -273,6 +292,22 @@ export function WorkspaceStatus({
)}
</div>
)}
{/* Branch Behind Details (no explicit conflicts but needs AI merge due to path mappings) */}
{!hasGitConflicts && isBranchBehind && mergePreview?.gitConflicts && (
<div className="text-xs text-muted-foreground pl-6">
Target branch has {commitsBehind} new commit{commitsBehind !== 1 ? 's' : ''} since this build started.
{hasPathMappedMerges ? (
<span className="text-warning">
{' '}{pathMappedAIMergeCount} file{pathMappedAIMergeCount !== 1 ? 's' : ''} need AI merge due to {totalRenames} file rename{totalRenames !== 1 ? 's' : ''}.
</span>
) : totalRenames > 0 ? (
<span className="text-warning"> {totalRenames} file rename{totalRenames !== 1 ? 's' : ''} detected - AI will handle the merge.</span>
) : (
<span className="text-warning"> Files may have been renamed or moved - AI will handle the merge.</span>
)}
</div>
)}
</div>
{/* Actions Footer */}
@@ -293,7 +328,7 @@ export function WorkspaceStatus({
{/* Primary Actions */}
<div className="flex gap-2">
<Button
variant={hasGitConflicts ? "warning" : "success"}
variant={hasGitConflicts || isBranchBehind || hasPathMappedMerges ? "warning" : "success"}
onClick={onMerge}
disabled={isMerging || isDiscarding}
className="flex-1"
@@ -301,12 +336,12 @@ export function WorkspaceStatus({
{isMerging ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{hasGitConflicts ? 'Resolving...' : stageOnly ? 'Staging...' : 'Merging...'}
{hasGitConflicts || isBranchBehind || hasPathMappedMerges ? 'Resolving...' : stageOnly ? 'Staging...' : 'Merging...'}
</>
) : (
<>
<GitMerge className="mr-2 h-4 w-4" />
{hasGitConflicts
{hasGitConflicts || isBranchBehind || hasPathMappedMerges
? (stageOnly ? 'Stage with AI Merge' : 'Merge with AI')
: (stageOnly ? 'Stage Changes' : 'Merge to Main')}
</>
@@ -132,6 +132,7 @@ const browserMockAPI: ElectronAPI = {
createGitHubRepo: async () => ({ success: true, data: { fullName: '', url: '' } }),
addGitRemote: async () => ({ success: true, data: { remoteUrl: '' } }),
listGitHubOrgs: async () => ({ success: true, data: { orgs: [] } }),
onGitHubAuthDeviceCode: () => () => {},
onGitHubInvestigationProgress: () => () => {},
onGitHubInvestigationComplete: () => () => {},
onGitHubInvestigationError: () => () => {},
@@ -208,5 +208,8 @@ export const integrationMock = {
{ login: 'another-org', avatarUrl: 'https://avatars.githubusercontent.com/u/2?v=4' }
]
}
})
}),
// OAuth device code event listener (for streaming device code during auth)
onGitHubAuthDeviceCode: () => () => {}
};
@@ -200,6 +200,9 @@ export const IPC_CHANNELS = {
GITHUB_ADD_REMOTE: 'github:addRemote',
GITHUB_LIST_ORGS: 'github:listOrgs',
// GitHub OAuth events (main -> renderer) - for streaming device code during auth
GITHUB_AUTH_DEVICE_CODE: 'github:authDeviceCode',
// GitHub events (main -> renderer)
GITHUB_INVESTIGATION_PROGRESS: 'github:investigationProgress',
GITHUB_INVESTIGATION_COMPLETE: 'github:investigationComplete',
+5
View File
@@ -352,6 +352,11 @@ export interface ElectronAPI {
) => Promise<IPCResult<{ remoteUrl: string }>>;
listGitHubOrgs: () => Promise<IPCResult<{ orgs: Array<{ login: string; avatarUrl?: string }> }>>;
// GitHub OAuth device code event (streams device code during auth flow)
onGitHubAuthDeviceCode: (
callback: (data: { deviceCode: string; authUrl: string; browserOpened: boolean }) => void
) => () => void;
// GitHub event listeners
onGitHubInvestigationProgress: (
callback: (projectId: string, status: GitHubInvestigationStatus) => void
@@ -288,6 +288,7 @@ export interface ProjectEnvConfig {
githubToken?: string;
githubRepo?: string; // Format: owner/repo
githubAutoSync?: boolean; // Auto-sync issues on project load
githubAuthMethod?: 'oauth' | 'pat'; // How the token was obtained
// Git/Worktree Settings
defaultBranch?: string; // Base branch for worktree creation (e.g., 'main', 'develop')
+13
View File
@@ -331,6 +331,13 @@ export interface MergeConflict {
type?: ConflictType; // 'semantic' = parallel task conflict, 'git' = branch divergence
}
// Path-mapped file that needs AI merge due to rename
export interface PathMappedAIMerge {
oldPath: string;
newPath: string;
reason: string;
}
// Git-level conflict information (branch divergence)
export interface GitConflictInfo {
hasConflicts: boolean;
@@ -339,6 +346,10 @@ export interface GitConflictInfo {
commitsBehind: number;
baseBranch: string;
specBranch: string;
// Files that need AI merge due to path mappings (file renames)
pathMappedAIMerges?: PathMappedAIMerge[];
// Total number of file renames detected
totalRenames?: number;
}
// Summary statistics from merge preview/execution
@@ -350,6 +361,8 @@ export interface MergeStats {
aiResolved?: number;
humanRequired?: number;
hasGitConflicts?: boolean; // True if there are git-level conflicts requiring rebase
// Count of files needing AI merge due to path mappings (file renames)
pathMappedAIMergeCount?: number;
}
export interface WorktreeMergeResult {
+13 -4
View File
@@ -27,7 +27,7 @@ from core.workspace import _run_parallel_merges
class TestParallelMergeDataclasses:
"""Tests for parallel merge data structures."""
def test_parallel_merge_task_creation(self):
def test_parallel_merge_task_creation(self, tmp_path):
"""ParallelMergeTask can be created with required fields."""
task = ParallelMergeTask(
file_path="src/App.tsx",
@@ -35,6 +35,7 @@ class TestParallelMergeDataclasses:
worktree_content="const main = 2;",
base_content="const main = 0;",
spec_name="001-test",
project_dir=tmp_path,
)
assert task.file_path == "src/App.tsx"
@@ -42,8 +43,9 @@ class TestParallelMergeDataclasses:
assert task.worktree_content == "const main = 2;"
assert task.base_content == "const main = 0;"
assert task.spec_name == "001-test"
assert task.project_dir == tmp_path
def test_parallel_merge_task_optional_base(self):
def test_parallel_merge_task_optional_base(self, tmp_path):
"""ParallelMergeTask works with None base_content."""
task = ParallelMergeTask(
file_path="src/new-file.tsx",
@@ -51,6 +53,7 @@ class TestParallelMergeDataclasses:
worktree_content="// worktree version",
base_content=None, # New file, no common ancestor
spec_name="001-new-feature",
project_dir=tmp_path,
)
assert task.base_content is None
@@ -105,7 +108,7 @@ class TestParallelMergeRunner:
results = asyncio.run(_run_parallel_merges([], tmp_path))
assert results == []
def test_parallel_merge_task_with_data(self):
def test_parallel_merge_task_with_data(self, tmp_path):
"""ParallelMergeTask holds merge data correctly."""
task = ParallelMergeTask(
file_path="src/test.py",
@@ -113,6 +116,7 @@ class TestParallelMergeRunner:
worktree_content="def main():\n print('hi')",
base_content="def main(): pass",
spec_name="001-feature",
project_dir=tmp_path,
)
assert "main" in task.main_content
@@ -133,6 +137,7 @@ class TestSimple3WayMerge:
worktree_content="def main(): pass", # Same as main
base_content="def main(): pass", # Same as both
spec_name="001-no-change",
project_dir=tmp_path,
)
results = asyncio.run(_run_parallel_merges([task], tmp_path))
@@ -151,6 +156,7 @@ class TestSimple3WayMerge:
worktree_content="def main():\n print('new')", # Changed
base_content="def main(): pass",
spec_name="001-worktree-only",
project_dir=tmp_path,
)
results = asyncio.run(_run_parallel_merges([task], tmp_path))
@@ -169,6 +175,7 @@ class TestSimple3WayMerge:
worktree_content="def main(): pass", # Same as base
base_content="def main(): pass",
spec_name="001-main-only",
project_dir=tmp_path,
)
results = asyncio.run(_run_parallel_merges([task], tmp_path))
@@ -187,6 +194,7 @@ class TestSimple3WayMerge:
worktree_content="# Same content",
base_content=None, # New file, no base
spec_name="001-new-identical",
project_dir=tmp_path,
)
results = asyncio.run(_run_parallel_merges([task], tmp_path))
@@ -198,7 +206,7 @@ class TestSimple3WayMerge:
class TestParallelMergeIntegration:
"""Integration tests for parallel merge flow."""
def test_multiple_file_merge_structure(self):
def test_multiple_file_merge_structure(self, tmp_path):
"""Multiple ParallelMergeTasks can be created."""
tasks = [
ParallelMergeTask(
@@ -207,6 +215,7 @@ class TestParallelMergeIntegration:
worktree_content=f"# File {i} feature",
base_content=f"# File {i} base",
spec_name="001-multi",
project_dir=tmp_path,
)
for i in range(3)
]