diff --git a/scripts/orchestration/__init__.py b/scripts/orchestration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/orchestration/cli.py b/scripts/orchestration/cli.py new file mode 100755 index 0000000000..36b69c21ec --- /dev/null +++ b/scripts/orchestration/cli.py @@ -0,0 +1,45 @@ +import argparse +import sys +import os + +# Ensure the root of the project is in the python path to allow direct execution +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from scripts.orchestration.orchestrator import Orchestrator + +def main(): + parser = argparse.ArgumentParser(description="Orchestration CLI for Jules, Gemini, and GitHub.") + subparsers = parser.add_subparsers(dest="command", required=True) + + # Review PR Command + pr_parser = subparsers.add_parser("review", help="Review a PR using Gemini") + pr_parser.add_argument("--pr", required=True, help="PR number") + + # Dispatch Jules Command + jules_parser = subparsers.add_parser("dispatch", help="Dispatch a Jules session for an issue") + jules_parser.add_argument("--issue", required=True, help="Issue number") + jules_parser.add_argument("--owner", required=True, help="Repo owner") + jules_parser.add_argument("--repo", required=True, help="Repo name") + + # Read GitHub Issue Command + read_parser = subparsers.add_parser("read", help="Read a GitHub issue") + read_parser.add_argument("--issue", required=True, help="Issue number") + + # Fix Conflicts Command + fix_parser = subparsers.add_parser("fix-conflicts", help="Fix merge conflicts for a branch") + fix_parser.add_argument("--branch", required=True, help="Branch name") + + args = parser.parse_args() + orchestrator = Orchestrator() + + if args.command == "review": + orchestrator.review_pr(args.pr) + elif args.command == "dispatch": + orchestrator.dispatch_jules(args.issue, args.owner, args.repo) + elif args.command == "read": + orchestrator.read_github_issue(args.issue) + elif args.command == "fix-conflicts": + orchestrator.fix_conflicts(args.branch) + +if __name__ == "__main__": + main() diff --git a/scripts/orchestration/gemini_service.py b/scripts/orchestration/gemini_service.py new file mode 100644 index 0000000000..19a28f8ee6 --- /dev/null +++ b/scripts/orchestration/gemini_service.py @@ -0,0 +1,70 @@ +import os +import subprocess +import tempfile +import json + +class GeminiService: + def __init__(self, api_key=None, model="gemini-2.5-flash"): + self.api_key = api_key or os.environ.get("GEMINI_API_KEY") + self.model = model + + def generate_content(self, prompt: str) -> str: + """ + Invokes the local scripts/gemini-client.ts to generate content. + """ + if not self.api_key: + raise ValueError("GEMINI_API_KEY is not set") + + # Write prompt to a temporary file since gemini-client.ts expects a task or task-file + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as temp_task_file: + temp_task_file.write(prompt) + temp_task_path = temp_task_file.name + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as temp_output_file: + temp_output_path = temp_output_file.name + + try: + env = os.environ.copy() + env["GEMINI_API_KEY"] = self.api_key + + # The script expects npx tsx and these arguments + cmd = [ + "npx", "tsx", "scripts/gemini-client.ts", + "--task-file", temp_task_path, + "--output", temp_output_path + ] + + result = subprocess.run(cmd, env=env, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"Gemini client error: {result.stderr}") + + # Read output from the generated file + with open(temp_output_path, 'r') as f: + output_content = f.read() + + return output_content + + finally: + # Cleanup temp files + if os.path.exists(temp_task_path): + os.remove(temp_task_path) + if os.path.exists(temp_output_path): + os.remove(temp_output_path) + + def review_code(self, diff: str) -> str: + """ + Uses Gemini to review a code diff. + """ + prompt = f"Review the following code changes and provide feedback:\n\n{diff}" + return self.generate_content(prompt) + + def fix_conflicts(self, diff: str) -> str: + """ + Uses Gemini to generate a conflict resolution for a diff. + """ + prompt = ( + "The following diff contains git merge conflicts. " + "Please resolve the conflicts and provide ONLY the corrected code without the conflict markers. " + f"Here is the diff:\n\n{diff}" + ) + return self.generate_content(prompt) diff --git a/scripts/orchestration/github_service.py b/scripts/orchestration/github_service.py new file mode 100644 index 0000000000..1aa85e5cbe --- /dev/null +++ b/scripts/orchestration/github_service.py @@ -0,0 +1,86 @@ +import os +import subprocess +import json + +class GitHubService: + def __init__(self, token=None): + self.token = token or os.environ.get("GITHUB_TOKEN") + + def _run_gh_command(self, args): + env = os.environ.copy() + if self.token: + env["GH_TOKEN"] = self.token + + # Determine if we should parse as JSON or not + try: + result = subprocess.run(["gh"] + args, env=env, capture_output=True, text=True, check=True) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + raise RuntimeError(f"GitHub CLI error: {e.stderr}") + + def get_pr(self, pr_number: str) -> dict: + output = self._run_gh_command(["pr", "view", pr_number, "--json", "title,body,headRefName,baseRefName"]) + return json.loads(output) + + def get_issue(self, issue_number: str) -> dict: + output = self._run_gh_command(["issue", "view", issue_number, "--json", "title,body"]) + return json.loads(output) + + def add_comment(self, subject_type: str, number: str, body: str): + if subject_type.lower() == "pr": + self._run_gh_command(["pr", "comment", number, "--body", body]) + elif subject_type.lower() == "issue": + self._run_gh_command(["issue", "comment", number, "--body", body]) + + def get_diff(self, pr_number: str) -> str: + return self._run_gh_command(["pr", "diff", pr_number]) + + def add_label(self, subject_type: str, number: str, label: str): + if subject_type.lower() == "pr": + self._run_gh_command(["pr", "edit", number, "--add-label", label]) + elif subject_type.lower() == "issue": + self._run_gh_command(["issue", "edit", number, "--add-label", label]) + + def parse_issue_body(self, issue_body: str) -> tuple: + """ + Parses the issue body to extract task description, target files, and custom branch name. + Mimics .github/scripts/parse_issue.py. + """ + sections = { + "Refactoring Task": "", + "Target Files": "", + "Custom Branch Name (Optional)": "" + } + + current_section = None + for line in issue_body.splitlines(): + if line.startswith("### "): + section_name = line[4:].strip() + if section_name in sections: + current_section = section_name + else: + current_section = None + elif current_section: + sections[current_section] += line + "\n" + + task_description = sections["Refactoring Task"].strip() + + target_files_raw = sections["Target Files"].strip() + target_files = [line.strip().replace("- ", "").replace("`", "") for line in target_files_raw.splitlines() if line.strip()] + + branch_name_raw = sections["Custom Branch Name (Optional)"].strip() + branch_name = "" + if branch_name_raw: + branch_name_line = [line for line in branch_name_raw.splitlines() if line.strip()] + if branch_name_line: + branch_name = branch_name_line[0].replace("`", "").replace("branch-name:", "").strip() + + return task_description, ",".join(target_files), branch_name + + def checkout_branch(self, branch: str): + subprocess.run(["git", "checkout", branch], check=True) + + def commit_and_push(self, branch: str, message: str): + subprocess.run(["git", "add", "."], check=True) + subprocess.run(["git", "commit", "-m", message], check=True) + subprocess.run(["git", "push", "origin", branch], check=True) diff --git a/scripts/orchestration/jules_service.py b/scripts/orchestration/jules_service.py new file mode 100644 index 0000000000..72d688cb3d --- /dev/null +++ b/scripts/orchestration/jules_service.py @@ -0,0 +1,67 @@ +import os +import sys +import requests + +class JulesService: + def __init__(self, api_url="https://api.jules.ai/v1/sessions", api_key=None): + self.api_url = api_url + self.api_key = api_key or os.environ.get("JULES_API_KEY") + + def _get_headers(self): + if not self.api_key or not self.api_key.strip(): + raise ValueError("JULES_API_KEY is not set or empty") + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + def create_session(self, prompt: str, branch: str, title: str, owner: str, repo_name: str) -> str: + """ + Creates a new Jules session via the API and returns the session ID. + Mimics .github/scripts/jules_ops.py create_jules_session. + """ + payload = { + "prompt": prompt, + "branch": branch, + "title": title, + "owner": owner, + "repo_name": repo_name, + } + + try: + response = requests.post(self.api_url, headers=self._get_headers(), json=payload) + response.raise_for_status() + response_data = response.json() + session_id = response_data.get("id") + if not session_id: + raise RuntimeError("Could not find session ID in API response.") + return session_id + except requests.exceptions.RequestException as e: + error_msg = f"Error creating Jules session: {e}" + if e.response is not None: + error_msg += f"\nResponse: {e.response.text}" + raise RuntimeError(error_msg) + + def delete_session(self, session_id: str): + """ + Deletes a Jules session via the API. + Mimics .github/scripts/jules_ops.py delete_jules_session. + """ + if not session_id: + raise ValueError("session_id is required for the 'delete' command.") + + url = f"{self.api_url}/{session_id}" + + headers = { + "Authorization": f"Bearer {self.api_key}", + } + + try: + response = requests.delete(url, headers=headers) + response.raise_for_status() + return True + except requests.exceptions.RequestException as e: + error_msg = f"Error deleting Jules session: {e}" + if e.response is not None: + error_msg += f"\nResponse: {e.response.text}" + raise RuntimeError(error_msg) diff --git a/scripts/orchestration/orchestrator.py b/scripts/orchestration/orchestrator.py new file mode 100644 index 0000000000..c5f8cda7a0 --- /dev/null +++ b/scripts/orchestration/orchestrator.py @@ -0,0 +1,90 @@ +from .jules_service import JulesService +from .gemini_service import GeminiService +from .github_service import GitHubService + +class Orchestrator: + def __init__(self): + self.jules = JulesService() + self.gemini = GeminiService() + self.github = GitHubService() + + def review_pr(self, pr_number: str): + print(f"Fetching diff for PR {pr_number}...") + diff = self.github.get_diff(pr_number) + + print(f"Requesting review from Gemini...") + review = self.gemini.review_code(diff) + + print(f"Posting review to PR {pr_number}...") + self.github.add_comment("pr", pr_number, f"### AI Code Review\n\n{review}") + print("Review completed.") + + def dispatch_jules(self, issue_number: str, owner: str, repo_name: str): + print(f"Fetching issue {issue_number}...") + issue = self.github.get_issue(issue_number) + + title = issue.get("title", "") + body = issue.get("body", "") + + print("Parsing issue body...") + task_description, target_files, branch_name = self.github.parse_issue_body(body) + + # Determine branch name + if not branch_name: + branch_name = f"jules-issue-{issue_number}" + + print("Creating Jules session...") + session_id = self.jules.create_session( + prompt=task_description if task_description else body, + branch=branch_name, + title=title, + owner=owner, + repo_name=repo_name + ) + print(f"Jules session created: {session_id}") + return session_id + + def read_github_issue(self, issue_number: str): + issue = self.github.get_issue(issue_number) + print(f"Title: {issue.get('title')}\nBody: {issue.get('body')}") + + def fix_conflicts(self, branch: str): + print(f"Checking out branch {branch}...") + self.github.checkout_branch(branch) + + print("Attempting to get conflicting diff...") + import subprocess + import os + try: + result = subprocess.run(["git", "diff", "--diff-filter=U"], capture_output=True, text=True, check=True) + diff = result.stdout + if not diff.strip(): + print("No conflicts found.") + return + except subprocess.CalledProcessError as e: + print(f"Error getting diff: {e.stderr}") + return + + print("Sending conflicting diff to Gemini for resolution...") + resolved_code = self.gemini.fix_conflicts(diff) + + if resolved_code: + print("Received resolution. Applying it...") + import tempfile + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.patch') as patch_file: + patch_file.write(resolved_code) + patch_file_path = patch_file.name + + try: + subprocess.run(["git", "apply", patch_file_path], check=True) + print("Successfully applied conflict resolution.") + # Stage the resolved files + subprocess.run(["git", "add", "."], check=True) + except subprocess.CalledProcessError as e: + print(f"Failed to apply patch: {e}") + print(f"Patch content was:\n{resolved_code}") + finally: + if os.path.exists(patch_file_path): + os.remove(patch_file_path) + else: + print("Failed to resolve conflicts using Gemini.")