Skip to content
Merged
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
39 changes: 24 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,20 @@ The `gh` CLI is authenticated as @ClaydeCode and git is configured with my name
src/clayde/
__init__.py
config.py # CLAYDE_DIR, paths, APPROVER, WHITELISTED_USERS,
# load_config(), setup_logging()
# load_config(), setup_logging(), get_github_client()
state.py # load_state(), save_state(), get_issue_state(),
# update_issue_state()
github.py # gh_api(), parse_issue_url(), fetch_issue(),
# fetch_issue_comments(), post_comment(),
# get_default_branch(), ensure_repo(),
# get_assigned_issues(), check_approval(),
# is_whitelisted_author(), has_whitelisted_thumbsup()
github.py # PyGitHub wrappers: parse_issue_url(), fetch_issue(),
# fetch_issue_comments(), post_comment(), fetch_comment(),
# get_default_branch(), get_assigned_issues(), find_open_pr()
git.py # ensure_repo() — clone or update repos under REPOS_DIR
safety.py # is_issue_authorized(), is_plan_approved() — safety gates
claude.py # invoke_claude(prompt, repo_path) — subprocess to claude CLI
planner.py # do_plan(issue_url) — research + post plan comment
implementer.py # do_implement(issue_url) — implement + open PR + post result
orchestrator.py # main() — cron entry point, state machine dispatcher
tasks/
__init__.py
plan.py # run(issue_url) — research + post plan comment
implement.py # run(issue_url) — implement + open PR + post result
```

---
Expand Down Expand Up @@ -128,18 +130,25 @@ invoke_claude(prompt, repo_path)

---

## GitHub API Wrapper (`github.py`)
## GitHub API (`github.py`)

All GitHub calls go through `gh_api(endpoint, method, fields)` which shells out to `gh api`. Returns parsed JSON or raises `RuntimeError` on non-zero exit.
Uses PyGitHub. All functions accept a `Github` client instance as first argument.

Repo cloning convention: `repos/{owner}__{repo}/` (double underscore separator).
`ensure_repo()` clones on first use, then `git checkout <default_branch> && git pull` on subsequent calls.
`git.ensure_repo()` clones on first use, then `git checkout <default_branch> && git pull` on subsequent calls.

---

## Plan Phase (`planner.py`)
## Safety Gates (`safety.py`)

1. Fetch issue metadata and comments via GitHub API
- `is_issue_authorized(issue)` — True if issue author is whitelisted OR a whitelisted user reacted +1.
- `is_plan_approved(g, owner, repo, number, comment_id)` — True if APPROVER reacted +1 to plan comment AND a whitelisted user reacted +1 to the issue.

---

## Plan Task (`tasks/plan.py`)

1. Fetch issue metadata and comments via PyGitHub
2. `ensure_repo()` to have the code on disk
3. Build prompt with issue body, labels, comments, repo path
4. `invoke_claude()` — Claude explores the repo and returns a markdown plan
Expand All @@ -148,7 +157,7 @@ Repo cloning convention: `repos/{owner}__{repo}/` (double underscore separator).

---

## Implementation Phase (`implementer.py`)
## Implementation Task (`tasks/implement.py`)

1. Fetch plan comment text and any discussion comments posted after the plan
2. `ensure_repo()` to reset to latest default branch
Expand All @@ -163,7 +172,7 @@ Repo cloning convention: `repos/{owner}__{repo}/` (double underscore separator).

Format: `[YYYY-MM-DD HH:MM:SS] [clayde.<module>] <message>`
File: `logs/agent.log` (appended; cron also redirects stderr there)
Logger names: `clayde.orchestrator`, `clayde.planner`, `clayde.implementer`, `clayde.github`, `clayde.claude`
Logger names: `clayde.orchestrator`, `clayde.tasks.plan`, `clayde.tasks.implement`, `clayde.github`, `clayde.claude`

---

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Clayde — autonomous GitHub issue agent"
requires-python = ">=3.12"
dependencies = [
"jinja2>=3.1.6",
"PyGitHub>=2.5.0",
]

[project.scripts]
Expand Down
7 changes: 7 additions & 0 deletions src/clayde/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import logging
import os

from github import Auth, Github

CLAYDE_DIR = "/home/ubuntu/clayde"
STATE_FILE = os.path.join(CLAYDE_DIR, "state.json")
LOG_FILE = os.path.join(CLAYDE_DIR, "logs", "agent.log")
Expand All @@ -23,6 +25,11 @@ def load_config():
return config


def get_github_client() -> Github:
"""Return an authenticated PyGitHub client using GH_TOKEN from the environment."""
return Github(auth=Auth.Token(os.environ["GH_TOKEN"]))


def setup_logging():
"""Configure stdlib logging to append to the agent log file."""
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
Expand Down
37 changes: 37 additions & 0 deletions src/clayde/git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Git repository management — clone or update repos under REPOS_DIR."""

import logging
import os
import subprocess

from clayde.config import REPOS_DIR

log = logging.getLogger("clayde.git")


def ensure_repo(owner: str, repo: str, default_branch: str) -> str:
"""Clone repo if needed, otherwise checkout default_branch and pull.

Returns the local path to the repository.
"""
repo_path = os.path.join(REPOS_DIR, f"{owner}__{repo}")
clone_url = f"https://github.com/{owner}/{repo}.git"

if os.path.isdir(os.path.join(repo_path, ".git")):
log.info("Updating %s/%s (checkout %s + pull)", owner, repo, default_branch)
subprocess.run(
["git", "checkout", default_branch],
cwd=repo_path, capture_output=True,
)
subprocess.run(["git", "pull"], cwd=repo_path, capture_output=True)
else:
log.info("Cloning %s/%s", owner, repo)
os.makedirs(REPOS_DIR, exist_ok=True)
result = subprocess.run(
["git", "clone", clone_url, repo_path],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Clone failed: {result.stderr}")

return repo_path
119 changes: 26 additions & 93 deletions src/clayde/github.py
Original file line number Diff line number Diff line change
@@ -1,122 +1,55 @@
"""GitHub CLI wrappers and repo management."""
"""GitHub API helpers using PyGitHub."""

import json
import logging
import os
import re
import subprocess

from clayde.config import APPROVER, REPOS_DIR, WHITELISTED_USERS
from github import Github, GithubException

log = logging.getLogger("clayde.github")


def gh_api(endpoint, method="GET", fields=None):
"""Call gh api and return parsed JSON."""
cmd = ["gh", "api", endpoint]
if method != "GET":
cmd += ["--method", method]
for k, v in (fields or {}).items():
cmd += ["-f", f"{k}={v}"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"gh api {endpoint} failed: {result.stderr}")
return json.loads(result.stdout) if result.stdout.strip() else {}


def parse_issue_url(url):
def parse_issue_url(url: str) -> tuple[str, str, int]:
m = re.match(r"https://github\.com/([^/]+)/([^/]+)/issues/(\d+)", url)
if not m:
raise ValueError(f"Cannot parse issue URL: {url}")
return m.group(1), m.group(2), int(m.group(3))


def fetch_issue(owner, repo, number):
return gh_api(f"/repos/{owner}/{repo}/issues/{number}")

def fetch_issue(g: Github, owner: str, repo: str, number: int):
return g.get_repo(f"{owner}/{repo}").get_issue(number)

def fetch_issue_comments(owner, repo, number):
return gh_api(f"/repos/{owner}/{repo}/issues/{number}/comments")

def fetch_issue_comments(g: Github, owner: str, repo: str, number: int):
return list(g.get_repo(f"{owner}/{repo}").get_issue(number).get_comments())

def post_comment(owner, repo, number, body):
data = gh_api(
f"/repos/{owner}/{repo}/issues/{number}/comments",
method="POST",
fields={"body": body},
)
return data["id"]

def post_comment(g: Github, owner: str, repo: str, number: int, body: str) -> int:
"""Post a comment on an issue and return the comment ID."""
comment = g.get_repo(f"{owner}/{repo}").get_issue(number).create_comment(body)
return comment.id

def get_default_branch(owner, repo):
data = gh_api(f"/repos/{owner}/{repo}")
return data.get("default_branch", "main")

def fetch_comment(g: Github, owner: str, repo: str, comment_id: int):
return g.get_repo(f"{owner}/{repo}").get_issue_comment(comment_id)

def ensure_repo(owner, repo):
"""Clone or update a repository under REPOS_DIR."""
repo_path = os.path.join(REPOS_DIR, f"{owner}__{repo}")
clone_url = f"https://github.com/{owner}/{repo}.git"

if os.path.isdir(os.path.join(repo_path, ".git")):
default_branch = get_default_branch(owner, repo)
log.info("Updating %s/%s (checkout %s + pull)", owner, repo, default_branch)
subprocess.run(
["git", "checkout", default_branch],
cwd=repo_path, capture_output=True,
)
subprocess.run(["git", "pull"], cwd=repo_path, capture_output=True)
else:
log.info("Cloning %s/%s", owner, repo)
os.makedirs(REPOS_DIR, exist_ok=True)
result = subprocess.run(
["git", "clone", clone_url, repo_path],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Clone failed: {result.stderr}")
def get_default_branch(g: Github, owner: str, repo: str) -> str:
return g.get_repo(f"{owner}/{repo}").default_branch

return repo_path


def get_assigned_issues():
"""Fetch all open issues assigned to the authenticated user."""
def get_assigned_issues(g: Github) -> list:
"""Return all open issues assigned to the authenticated user."""
try:
return gh_api("/issues?filter=assigned&state=open&per_page=100")
except RuntimeError as e:
return list(g.get_user().get_issues(filter="assigned", state="open"))
except GithubException as e:
log.error("Failed to fetch assigned issues: %s", e)
return []


def check_approval(owner, repo, comment_id):
"""Check if APPROVER has reacted with thumbs-up to the plan comment."""
try:
reactions = gh_api(
f"/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions"
)
except RuntimeError:
return False
return any(
r.get("content") == "+1" and r.get("user", {}).get("login") == APPROVER
for r in reactions
)


def is_whitelisted_author(issue):
"""Check if the issue was created by a whitelisted user."""
author = issue.get("user", {}).get("login", "")
return author in WHITELISTED_USERS


def has_whitelisted_thumbsup(owner, repo, number):
"""Check if any whitelisted user has reacted with +1 to the issue itself."""
try:
reactions = gh_api(
f"/repos/{owner}/{repo}/issues/{number}/reactions"
)
except RuntimeError:
return False
return any(
r.get("content") == "+1" and r.get("user", {}).get("login") in WHITELISTED_USERS
for r in reactions
)
def find_open_pr(g: Github, owner: str, repo: str, number: int) -> str | None:
"""Return the HTML URL of an open PR for clayde/issue-{number}, or None."""
branch = f"clayde/issue-{number}"
pulls = list(g.get_repo(f"{owner}/{repo}").get_pulls(
state="open", head=f"{owner}:{branch}"
))
return pulls[0].html_url if pulls else None
Loading