Skip to content

Commit 2a26bf2

Browse files
refactor: server into modular blueprints with separate concerns
1 parent f638f5a commit 2a26bf2

File tree

7 files changed

+990
-948
lines changed

7 files changed

+990
-948
lines changed

server/git_operations.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from flask import Blueprint, jsonify
2+
import logging
3+
from models import TaskStatus
4+
from utils import tasks
5+
6+
logger = logging.getLogger(__name__)
7+
8+
git_bp = Blueprint('git', __name__)
9+
10+
@git_bp.route('/git-diff/<task_id>', methods=['GET'])
11+
def get_git_diff(task_id):
12+
"""Get the git diff for a completed task"""
13+
if task_id not in tasks:
14+
return jsonify({'error': 'Task not found'}), 404
15+
16+
task = tasks[task_id]
17+
logger.info(f"📋 Frontend requesting git diff for task {task_id} (status: {task['status']})")
18+
19+
if task['status'] != TaskStatus.COMPLETED:
20+
logger.warning(f"⚠️ Git diff requested for incomplete task {task_id}")
21+
return jsonify({'error': 'Task not completed yet'}), 400
22+
23+
diff_length = len(task.get('git_diff', ''))
24+
logger.info(f"📄 Returning git diff: {diff_length} characters")
25+
26+
return jsonify({
27+
'status': 'success',
28+
'git_diff': task.get('git_diff', ''),
29+
'commit_hash': task.get('commit_hash')
30+
})

server/github_integration.py

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
from flask import Blueprint, jsonify, request
2+
import time
3+
import logging
4+
from github import Github
5+
from models import TaskStatus
6+
from utils import tasks
7+
8+
logger = logging.getLogger(__name__)
9+
10+
github_bp = Blueprint('github', __name__)
11+
12+
@github_bp.route('/validate-token', methods=['POST'])
13+
def validate_github_token():
14+
"""Validate GitHub token and check permissions"""
15+
try:
16+
data = request.get_json()
17+
github_token = data.get('github_token')
18+
repo_url = data.get('repo_url', '')
19+
20+
if not github_token:
21+
return jsonify({'error': 'github_token is required'}), 400
22+
23+
# Create GitHub client
24+
g = Github(github_token)
25+
26+
# Test basic authentication
27+
user = g.get_user()
28+
logger.info(f"🔐 Token belongs to user: {user.login}")
29+
30+
# Test token scopes
31+
rate_limit = g.get_rate_limit()
32+
logger.info(f"📊 Rate limit info: {rate_limit.core.remaining}/{rate_limit.core.limit}")
33+
34+
# If repo URL provided, test repo access
35+
repo_info = {}
36+
if repo_url:
37+
try:
38+
repo_parts = repo_url.replace('https://github.com/', '').replace('.git', '')
39+
repo = g.get_repo(repo_parts)
40+
41+
# Test various permissions
42+
permissions = {
43+
'read': True, # If we got here, we can read
44+
'write': False,
45+
'admin': False
46+
}
47+
48+
try:
49+
# Test if we can read branches
50+
branches = list(repo.get_branches())
51+
permissions['read_branches'] = True
52+
logger.info(f"✅ Can read branches ({len(branches)} found)")
53+
54+
# Test if we can create branches (this is what's actually failing)
55+
test_branch_name = f"test-permissions-{int(time.time())}"
56+
try:
57+
# Try to create a test branch
58+
main_branch = repo.get_branch(repo.default_branch)
59+
test_ref = repo.create_git_ref(f"refs/heads/{test_branch_name}", main_branch.commit.sha)
60+
permissions['create_branches'] = True
61+
logger.info(f"✅ Can create branches - test successful")
62+
63+
# Clean up test branch immediately
64+
test_ref.delete()
65+
logger.info(f"🧹 Cleaned up test branch")
66+
67+
except Exception as branch_error:
68+
permissions['create_branches'] = False
69+
logger.warning(f"❌ Cannot create branches: {branch_error}")
70+
71+
except Exception as e:
72+
permissions['read_branches'] = False
73+
permissions['create_branches'] = False
74+
logger.warning(f"❌ Cannot read branches: {e}")
75+
76+
try:
77+
# Check if we can write (without actually writing)
78+
repo_perms = repo.permissions
79+
permissions['write'] = repo_perms.push
80+
permissions['admin'] = repo_perms.admin
81+
logger.info(f"📋 Repo permissions: push={repo_perms.push}, admin={repo_perms.admin}")
82+
except Exception as e:
83+
logger.warning(f"⚠️ Could not check repo permissions: {e}")
84+
85+
repo_info = {
86+
'name': repo.full_name,
87+
'private': repo.private,
88+
'permissions': permissions,
89+
'default_branch': repo.default_branch
90+
}
91+
92+
except Exception as repo_error:
93+
return jsonify({
94+
'error': f'Cannot access repository: {str(repo_error)}',
95+
'user': user.login
96+
}), 403
97+
98+
return jsonify({
99+
'status': 'success',
100+
'user': user.login,
101+
'repo': repo_info,
102+
'message': 'Token is valid and has repository access'
103+
})
104+
105+
except Exception as e:
106+
logger.error(f"Token validation error: {str(e)}")
107+
return jsonify({'error': f'Token validation failed: {str(e)}'}), 401
108+
109+
@github_bp.route('/create-pr/<task_id>', methods=['POST'])
110+
def create_pull_request(task_id):
111+
"""Create a pull request by applying the saved patch to a fresh repo clone"""
112+
try:
113+
logger.info(f"🔍 PR creation requested for task: {task_id}")
114+
logger.info(f"📋 Available tasks: {list(tasks.keys())}")
115+
116+
if task_id not in tasks:
117+
logger.error(f"❌ Task {task_id} not found. Available tasks: {list(tasks.keys())}")
118+
return jsonify({
119+
'error': 'Task not found',
120+
'task_id': task_id,
121+
'available_tasks': list(tasks.keys())
122+
}), 404
123+
124+
task = tasks[task_id]
125+
126+
if task['status'] != TaskStatus.COMPLETED:
127+
return jsonify({'error': 'Task not completed yet'}), 400
128+
129+
if not task.get('git_patch'):
130+
return jsonify({'error': 'No patch data available for this task'}), 400
131+
132+
data = request.get_json() or {}
133+
pr_title = data.get('title', f"Claude Code: {task['prompt'][:50]}...")
134+
pr_body = data.get('body', f"Automated changes generated by Claude Code.\n\nPrompt: {task['prompt']}\n\nChanged files:\n" + '\n'.join(f"- {f}" for f in task.get('changed_files', [])))
135+
136+
logger.info(f"🚀 Creating PR for task {task_id}")
137+
138+
# Extract repo info from URL
139+
repo_parts = task['repo_url'].replace('https://github.com/', '').replace('.git', '')
140+
141+
# Create GitHub client
142+
g = Github(task['github_token'])
143+
repo = g.get_repo(repo_parts)
144+
145+
# Determine branch strategy
146+
base_branch = task['branch']
147+
pr_branch = f"claude-code-{task_id[:8]}"
148+
149+
logger.info(f"📋 Creating PR branch '{pr_branch}' from base '{base_branch}'")
150+
151+
# Get the latest commit from the base branch
152+
base_branch_obj = repo.get_branch(base_branch)
153+
base_sha = base_branch_obj.commit.sha
154+
155+
# Create new branch for the PR
156+
try:
157+
# Check if branch already exists
158+
try:
159+
existing_branch = repo.get_branch(pr_branch)
160+
logger.warning(f"⚠️ Branch '{pr_branch}' already exists, deleting it first...")
161+
repo.get_git_ref(f"heads/{pr_branch}").delete()
162+
logger.info(f"🗑️ Deleted existing branch '{pr_branch}'")
163+
except:
164+
pass # Branch doesn't exist, which is what we want
165+
166+
# Create the new branch
167+
new_ref = repo.create_git_ref(f"refs/heads/{pr_branch}", base_sha)
168+
logger.info(f"✅ Created branch '{pr_branch}' from {base_sha[:8]}")
169+
170+
except Exception as branch_error:
171+
logger.error(f"❌ Failed to create branch '{pr_branch}': {str(branch_error)}")
172+
173+
# Provide specific error messages based on the error
174+
error_msg = str(branch_error).lower()
175+
if "resource not accessible" in error_msg:
176+
detailed_error = (
177+
f"GitHub token lacks permission to create branches. "
178+
f"Please ensure your token has 'repo' scope (not just 'public_repo'). "
179+
f"Error: {branch_error}"
180+
)
181+
elif "already exists" in error_msg:
182+
detailed_error = f"Branch '{pr_branch}' already exists. Please try again or use a different task."
183+
else:
184+
detailed_error = f"Failed to create branch '{pr_branch}': {branch_error}"
185+
186+
return jsonify({'error': detailed_error}), 403
187+
188+
# Apply the patch by creating/updating files
189+
logger.info(f"📦 Applying patch with {len(task['changed_files'])} changed files...")
190+
191+
# Parse the patch to extract file changes
192+
patch_content = task['git_patch']
193+
files_to_update = apply_patch_to_github_repo(repo, pr_branch, patch_content, task)
194+
195+
if not files_to_update:
196+
return jsonify({'error': 'Failed to apply patch - no file changes extracted'}), 500
197+
198+
logger.info(f"✅ Applied patch, updated {len(files_to_update)} files")
199+
200+
# Create pull request
201+
pr = repo.create_pull(
202+
title=pr_title,
203+
body=pr_body,
204+
head=pr_branch,
205+
base=base_branch
206+
)
207+
208+
logger.info(f"🎉 Created PR #{pr.number}: {pr.html_url}")
209+
210+
return jsonify({
211+
'status': 'success',
212+
'pr_url': pr.html_url,
213+
'pr_number': pr.number,
214+
'branch': pr_branch,
215+
'files_updated': len(files_to_update)
216+
})
217+
218+
except Exception as e:
219+
logger.error(f"Error creating PR: {str(e)}")
220+
return jsonify({'error': str(e)}), 500
221+
222+
def apply_patch_to_github_repo(repo, branch, patch_content, task):
223+
"""Apply a git patch to a GitHub repository using the GitHub API"""
224+
try:
225+
logger.info(f"🔧 Parsing patch content...")
226+
227+
# Parse git patch format to extract file changes
228+
files_to_update = {}
229+
current_file = None
230+
new_content_lines = []
231+
232+
# This is a simplified patch parser - for production you might want a more robust one
233+
lines = patch_content.split('\n')
234+
i = 0
235+
236+
while i < len(lines):
237+
line = lines[i]
238+
239+
# Look for file headers in patch format
240+
if line.startswith('--- a/') or line.startswith('--- /dev/null'):
241+
# Next line should be +++ b/filename
242+
if i + 1 < len(lines) and lines[i + 1].startswith('+++ b/'):
243+
current_file = lines[i + 1][6:] # Remove '+++ b/'
244+
logger.info(f"📄 Found file change: {current_file}")
245+
246+
# Get the original file content if it exists
247+
try:
248+
file_obj = repo.get_contents(current_file, ref=branch)
249+
original_content = file_obj.decoded_content.decode('utf-8')
250+
logger.info(f"📥 Got original content for {current_file}")
251+
except:
252+
original_content = "" # New file
253+
logger.info(f"📝 New file: {current_file}")
254+
255+
# For simplicity, we'll reconstruct the file from the diff
256+
# Skip to the actual diff content (after @@)
257+
j = i + 2
258+
while j < len(lines) and not lines[j].startswith('@@'):
259+
j += 1
260+
261+
if j < len(lines):
262+
# Apply the diff changes
263+
new_content = apply_diff_to_content(original_content, lines[j:], current_file)
264+
if new_content is not None:
265+
files_to_update[current_file] = new_content
266+
logger.info(f"✅ Prepared update for {current_file}")
267+
268+
i = j
269+
i += 1
270+
271+
# Now update all the files via GitHub API
272+
updated_files = []
273+
commit_message = f"Claude Code: {task['prompt'][:100]}"
274+
275+
for file_path, new_content in files_to_update.items():
276+
try:
277+
# Check if file exists
278+
try:
279+
file_obj = repo.get_contents(file_path, ref=branch)
280+
# Update existing file
281+
repo.update_file(
282+
path=file_path,
283+
message=commit_message,
284+
content=new_content,
285+
sha=file_obj.sha,
286+
branch=branch
287+
)
288+
logger.info(f"📝 Updated existing file: {file_path}")
289+
except:
290+
# Create new file
291+
repo.create_file(
292+
path=file_path,
293+
message=commit_message,
294+
content=new_content,
295+
branch=branch
296+
)
297+
logger.info(f"🆕 Created new file: {file_path}")
298+
299+
updated_files.append(file_path)
300+
301+
except Exception as file_error:
302+
logger.error(f"❌ Failed to update {file_path}: {file_error}")
303+
304+
return updated_files
305+
306+
except Exception as e:
307+
logger.error(f"💥 Error applying patch: {str(e)}")
308+
return []
309+
310+
def apply_diff_to_content(original_content, diff_lines, filename):
311+
"""Apply diff changes to original content - simplified implementation"""
312+
try:
313+
# For now, let's use a simple approach: reconstruct from + lines
314+
# This is not a complete diff parser, but works for basic cases
315+
316+
result_lines = []
317+
original_lines = original_content.split('\n') if original_content else []
318+
319+
# Find the actual diff content starting from @@ line
320+
diff_start = 0
321+
for i, line in enumerate(diff_lines):
322+
if line.startswith('@@'):
323+
diff_start = i + 1
324+
break
325+
326+
# Simple reconstruction: take context and + lines, skip - lines
327+
for line in diff_lines[diff_start:]:
328+
if line.startswith('+++') or line.startswith('---'):
329+
continue
330+
elif line.startswith('+') and not line.startswith('+++'):
331+
result_lines.append(line[1:]) # Remove the +
332+
elif line.startswith(' '): # Context line
333+
result_lines.append(line[1:]) # Remove the space
334+
elif line.startswith('-'):
335+
continue # Skip removed lines
336+
elif line.strip() == '':
337+
continue # Skip empty lines in diff
338+
else:
339+
# Check if we've reached the next file
340+
if line.startswith('diff --git') or line.startswith('--- a/'):
341+
break
342+
343+
# If we got content, return it, otherwise fall back to using the git diff directly
344+
if result_lines:
345+
return '\n'.join(result_lines)
346+
else:
347+
# Fallback: return original content (no changes applied)
348+
logger.warning(f"⚠️ Could not parse diff for {filename}, keeping original")
349+
return original_content
350+
351+
except Exception as e:
352+
logger.error(f"❌ Error applying diff to {filename}: {str(e)}")
353+
return None

0 commit comments

Comments
 (0)