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 \n Prompt: { task ['prompt' ]} \n \n Changed 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