313 lines
9.3 KiB
Python
313 lines
9.3 KiB
Python
#!/usr/bin/env -S uv run
|
|
# /// script
|
|
# dependencies = ["python-dotenv", "pydantic"]
|
|
# ///
|
|
|
|
"""
|
|
GitHub Operations Module - AI Developer Workflow (ADW)
|
|
|
|
This module contains all GitHub-related operations including:
|
|
- Issue fetching and manipulation
|
|
- Comment posting
|
|
- Repository path extraction
|
|
- Issue status management
|
|
"""
|
|
|
|
import subprocess
|
|
import sys
|
|
import os
|
|
import json
|
|
from typing import Dict, List, Optional
|
|
from .data_types import GitHubIssue, GitHubIssueListItem, GitHubComment
|
|
|
|
# Bot identifier to prevent webhook loops and filter bot comments
|
|
ADW_BOT_IDENTIFIER = "[ADW-AGENTS]"
|
|
|
|
|
|
def get_github_env() -> Optional[dict]:
|
|
"""Get environment with GitHub token set up. Returns None if no GITHUB_PAT.
|
|
|
|
Subprocess env behavior:
|
|
- env=None → Inherits parent's environment (default)
|
|
- env={} → Empty environment (no variables)
|
|
- env=custom_dict → Only uses specified variables
|
|
|
|
So this will work with gh authentication:
|
|
# These are equivalent:
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
result = subprocess.run(cmd, capture_output=True, text=True, env=None)
|
|
|
|
But this will NOT work (no PATH, no auth):
|
|
result = subprocess.run(cmd, capture_output=True, text=True, env={})
|
|
"""
|
|
github_pat = os.getenv("GITHUB_PAT")
|
|
if not github_pat:
|
|
return None
|
|
|
|
# Only create minimal env with GitHub token
|
|
env = {
|
|
"GH_TOKEN": github_pat,
|
|
"PATH": os.environ.get("PATH", ""),
|
|
}
|
|
return env
|
|
|
|
|
|
def get_repo_url() -> str:
|
|
"""Get GitHub repository URL from git remote."""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "remote", "get-url", "origin"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return result.stdout.strip()
|
|
except subprocess.CalledProcessError:
|
|
raise ValueError(
|
|
"No git remote 'origin' found. Please ensure you're in a git repository with a remote."
|
|
)
|
|
except FileNotFoundError:
|
|
raise ValueError("git command not found. Please ensure git is installed.")
|
|
|
|
|
|
def extract_repo_path(github_url: str) -> str:
|
|
"""Extract owner/repo from GitHub URL."""
|
|
# Handle both https://github.com/owner/repo and https://github.com/owner/repo.git
|
|
return github_url.replace("https://github.com/", "").replace(".git", "")
|
|
|
|
|
|
def fetch_issue(issue_number: str, repo_path: str) -> GitHubIssue:
|
|
"""Fetch GitHub issue using gh CLI and return typed model."""
|
|
# Use JSON output for structured data
|
|
cmd = [
|
|
"gh",
|
|
"issue",
|
|
"view",
|
|
issue_number,
|
|
"-R",
|
|
repo_path,
|
|
"--json",
|
|
"number,title,body,state,author,assignees,labels,milestone,comments,createdAt,updatedAt,closedAt,url",
|
|
]
|
|
|
|
# Set up environment with GitHub token if available
|
|
env = get_github_env()
|
|
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
|
|
if result.returncode == 0:
|
|
# Parse JSON response into Pydantic model
|
|
issue_data = json.loads(result.stdout)
|
|
issue = GitHubIssue(**issue_data)
|
|
|
|
return issue
|
|
else:
|
|
print(result.stderr, file=sys.stderr)
|
|
sys.exit(result.returncode)
|
|
except FileNotFoundError:
|
|
print("Error: GitHub CLI (gh) is not installed.", file=sys.stderr)
|
|
print("\nTo install gh:", file=sys.stderr)
|
|
print(" - macOS: brew install gh", file=sys.stderr)
|
|
print(
|
|
" - Linux: See https://github.com/cli/cli#installation",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
" - Windows: See https://github.com/cli/cli#installation", file=sys.stderr
|
|
)
|
|
print("\nAfter installation, authenticate with: gh auth login", file=sys.stderr)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"Error parsing issue data: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def make_issue_comment(issue_id: str, comment: str) -> None:
|
|
"""Post a comment to a GitHub issue using gh CLI."""
|
|
# Get repo information from git remote
|
|
github_repo_url = get_repo_url()
|
|
repo_path = extract_repo_path(github_repo_url)
|
|
|
|
# Ensure comment has ADW_BOT_IDENTIFIER to prevent webhook loops
|
|
if not comment.startswith(ADW_BOT_IDENTIFIER):
|
|
comment = f"{ADW_BOT_IDENTIFIER} {comment}"
|
|
|
|
# Build command
|
|
cmd = [
|
|
"gh",
|
|
"issue",
|
|
"comment",
|
|
issue_id,
|
|
"-R",
|
|
repo_path,
|
|
"--body",
|
|
comment,
|
|
]
|
|
|
|
# Set up environment with GitHub token if available
|
|
env = get_github_env()
|
|
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
|
|
if result.returncode == 0:
|
|
print(f"Successfully posted comment to issue #{issue_id}")
|
|
else:
|
|
print(f"Error posting comment: {result.stderr}", file=sys.stderr)
|
|
raise RuntimeError(f"Failed to post comment: {result.stderr}")
|
|
except Exception as e:
|
|
print(f"Error posting comment: {e}", file=sys.stderr)
|
|
raise
|
|
|
|
|
|
def mark_issue_in_progress(issue_id: str) -> None:
|
|
"""Mark issue as in progress by adding label and comment."""
|
|
# Get repo information from git remote
|
|
github_repo_url = get_repo_url()
|
|
repo_path = extract_repo_path(github_repo_url)
|
|
|
|
# Add "in_progress" label
|
|
cmd = [
|
|
"gh",
|
|
"issue",
|
|
"edit",
|
|
issue_id,
|
|
"-R",
|
|
repo_path,
|
|
"--add-label",
|
|
"in_progress",
|
|
]
|
|
|
|
# Set up environment with GitHub token if available
|
|
env = get_github_env()
|
|
|
|
# Try to add label (may fail if label doesn't exist)
|
|
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
if result.returncode != 0:
|
|
print(f"Note: Could not add 'in_progress' label: {result.stderr}")
|
|
|
|
# Post comment indicating work has started
|
|
# make_issue_comment(issue_id, "🚧 ADW is working on this issue...")
|
|
|
|
# Assign to self (optional)
|
|
cmd = [
|
|
"gh",
|
|
"issue",
|
|
"edit",
|
|
issue_id,
|
|
"-R",
|
|
repo_path,
|
|
"--add-assignee",
|
|
"@me",
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
if result.returncode == 0:
|
|
print(f"Assigned issue #{issue_id} to self")
|
|
|
|
|
|
def fetch_open_issues(repo_path: str) -> List[GitHubIssueListItem]:
|
|
"""Fetch all open issues from the GitHub repository."""
|
|
try:
|
|
cmd = [
|
|
"gh",
|
|
"issue",
|
|
"list",
|
|
"--repo",
|
|
repo_path,
|
|
"--state",
|
|
"open",
|
|
"--json",
|
|
"number,title,body,labels,createdAt,updatedAt",
|
|
"--limit",
|
|
"1000",
|
|
]
|
|
|
|
# Set up environment with GitHub token if available
|
|
env = get_github_env()
|
|
|
|
# DEBUG level - not printing command
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, check=True, env=env
|
|
)
|
|
|
|
issues_data = json.loads(result.stdout)
|
|
issues = [GitHubIssueListItem(**issue_data) for issue_data in issues_data]
|
|
print(f"Fetched {len(issues)} open issues")
|
|
return issues
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"ERROR: Failed to fetch issues: {e.stderr}", file=sys.stderr)
|
|
return []
|
|
except json.JSONDecodeError as e:
|
|
print(f"ERROR: Failed to parse issues JSON: {e}", file=sys.stderr)
|
|
return []
|
|
|
|
|
|
def fetch_issue_comments(repo_path: str, issue_number: int) -> List[Dict]:
|
|
"""Fetch all comments for a specific issue."""
|
|
try:
|
|
cmd = [
|
|
"gh",
|
|
"issue",
|
|
"view",
|
|
str(issue_number),
|
|
"--repo",
|
|
repo_path,
|
|
"--json",
|
|
"comments",
|
|
]
|
|
|
|
# Set up environment with GitHub token if available
|
|
env = get_github_env()
|
|
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, check=True, env=env
|
|
)
|
|
data = json.loads(result.stdout)
|
|
comments = data.get("comments", [])
|
|
|
|
# Sort comments by creation time
|
|
comments.sort(key=lambda c: c.get("createdAt", ""))
|
|
|
|
# DEBUG level - not printing
|
|
return comments
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
print(
|
|
f"ERROR: Failed to fetch comments for issue #{issue_number}: {e.stderr}",
|
|
file=sys.stderr,
|
|
)
|
|
return []
|
|
except json.JSONDecodeError as e:
|
|
print(
|
|
f"ERROR: Failed to parse comments JSON for issue #{issue_number}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
return []
|
|
|
|
|
|
def find_keyword_from_comment(keyword: str, issue: GitHubIssue) -> Optional[GitHubComment]:
|
|
"""Find the latest comment containing a specific keyword.
|
|
|
|
Args:
|
|
keyword: The keyword to search for in comments
|
|
issue: The GitHub issue containing comments
|
|
|
|
Returns:
|
|
The latest GitHubComment containing the keyword, or None if not found
|
|
"""
|
|
# Sort comments by created_at date (newest first)
|
|
sorted_comments = sorted(issue.comments, key=lambda c: c.created_at, reverse=True)
|
|
|
|
# Search through sorted comments (newest first)
|
|
for comment in sorted_comments:
|
|
# Skip ADW bot comments to prevent loops
|
|
if ADW_BOT_IDENTIFIER in comment.body:
|
|
continue
|
|
|
|
if keyword in comment.body:
|
|
return comment
|
|
|
|
return None
|