Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
45 changes: 45 additions & 0 deletions scripts/orchestration/cli.py
Original file line number Diff line number Diff line change
@@ -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()
70 changes: 70 additions & 0 deletions scripts/orchestration/gemini_service.py
Original file line number Diff line number Diff line change
@@ -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)
86 changes: 86 additions & 0 deletions scripts/orchestration/github_service.py
Original file line number Diff line number Diff line change
@@ -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)
67 changes: 67 additions & 0 deletions scripts/orchestration/jules_service.py
Original file line number Diff line number Diff line change
@@ -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)
90 changes: 90 additions & 0 deletions scripts/orchestration/orchestrator.py
Original file line number Diff line number Diff line change
@@ -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.")
Loading