151 lines
5.8 KiB
Python
Executable File
151 lines
5.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import json, os, subprocess, sys, tempfile, time, urllib.request
|
|
from pathlib import Path
|
|
|
|
BASE = os.environ.get('GITEA_API_BASE', 'https://forge.alexanderwhitestone.com/api/v1')
|
|
REPO = sys.argv[1]
|
|
ISSUE_NUM = int(sys.argv[2])
|
|
ISSUE_TITLE = sys.argv[3]
|
|
OWNER, NAME = REPO.split('/')
|
|
BRANCH = f'claw-code/issue-{ISSUE_NUM}'
|
|
WORKTREE = Path.home() / 'worktrees' / f'claw-code-{ISSUE_NUM}'
|
|
CLAW_ROOT = Path.home() / 'code-claw' / 'rust'
|
|
CLAW_BIN = CLAW_ROOT / 'target' / 'debug' / 'claw'
|
|
CLAW_HOME = Path.home() / '.claw-qwen36-gitea'
|
|
OPENROUTER_KEY = (Path.home() / '.timmy' / 'openrouter_key').read_text().strip()
|
|
GITEA_TOKEN = (Path.home() / '.config' / 'gitea' / 'claw-code-token').read_text().strip()
|
|
|
|
headers = {'Authorization': f'token {GITEA_TOKEN}', 'Content-Type': 'application/json', 'Accept': 'application/json'}
|
|
|
|
def api(method, path, data=None):
|
|
req = urllib.request.Request(f"{BASE}{path}", headers=headers, method=method)
|
|
if data is not None:
|
|
body = json.dumps(data).encode()
|
|
req.data = body
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
raw = resp.read().decode()
|
|
return json.loads(raw) if raw else {}
|
|
|
|
def comment(body):
|
|
return api('POST', f'/repos/{OWNER}/{NAME}/issues/{ISSUE_NUM}/comments', {'body': body})
|
|
|
|
def issue_labels():
|
|
return api('GET', f'/repos/{OWNER}/{NAME}/labels?limit=100')
|
|
|
|
def issue_data():
|
|
return api('GET', f'/repos/{OWNER}/{NAME}/issues/{ISSUE_NUM}')
|
|
|
|
def comments():
|
|
return api('GET', f'/repos/{OWNER}/{NAME}/issues/{ISSUE_NUM}/comments')
|
|
|
|
def find_exact_open_pr(branch_name):
|
|
prs = api('GET', f'/repos/{OWNER}/{NAME}/pulls?state=open&limit=100')
|
|
for pr in prs:
|
|
head = (pr.get('head') or {}).get('ref')
|
|
if head == branch_name:
|
|
return pr
|
|
return None
|
|
|
|
def label_id(name):
|
|
for l in issue_labels():
|
|
if l['name'] == name:
|
|
return l['id']
|
|
return None
|
|
|
|
def add_label(name):
|
|
lid = label_id(name)
|
|
if lid:
|
|
api('POST', f'/repos/{OWNER}/{NAME}/issues/{ISSUE_NUM}/labels', {'labels': [lid]})
|
|
|
|
def remove_label(name):
|
|
lid = label_id(name)
|
|
if lid:
|
|
try:
|
|
api('DELETE', f'/repos/{OWNER}/{NAME}/issues/{ISSUE_NUM}/labels/{lid}')
|
|
except Exception:
|
|
pass
|
|
|
|
def run(cmd, cwd=None, env=None, timeout=900, check=True):
|
|
p = subprocess.run(cmd, cwd=cwd, env=env, text=True, capture_output=True, timeout=timeout)
|
|
if check and p.returncode != 0:
|
|
raise RuntimeError(f'cmd failed: {cmd}\nstdout:\n{p.stdout}\nstderr:\n{p.stderr}')
|
|
return p
|
|
|
|
# fetch issue body/comments
|
|
issue = issue_data()
|
|
body = issue.get('body') or ''
|
|
cmts = comments()
|
|
comment_text = '\n\n'.join([c.get('body','') for c in cmts[-8:]])[:6000]
|
|
|
|
# clone target repo
|
|
if WORKTREE.exists():
|
|
subprocess.run(['rm','-rf',str(WORKTREE)])
|
|
clone_url = f'https://claw-code:{GITEA_TOKEN}@forge.alexanderwhitestone.com/{OWNER}/{NAME}.git'
|
|
run(['git','clone','--depth','1','-b','main',clone_url,str(WORKTREE)], cwd=str(Path.home()))
|
|
run(['git','checkout','-b',BRANCH], cwd=str(WORKTREE))
|
|
run(['git','config','user.name','claw-code'], cwd=str(WORKTREE))
|
|
run(['git','config','user.email','claw-code@timmy.local'], cwd=str(WORKTREE))
|
|
|
|
prompt = f'''You are Code Claw running as the Gitea user claw-code.
|
|
|
|
Repository: {REPO}
|
|
Issue: #{ISSUE_NUM} — {ISSUE_TITLE}
|
|
Branch: {BRANCH}
|
|
|
|
Read the issue and recent comments, then implement the smallest correct change.
|
|
You are in a git repo checkout already.
|
|
|
|
Issue body:
|
|
{body}
|
|
|
|
Recent comments:
|
|
{comment_text}
|
|
|
|
Rules:
|
|
- Make focused code/config/doc changes only if they directly address the issue.
|
|
- Prefer the smallest proof-oriented fix.
|
|
- Run relevant verification commands if obvious.
|
|
- Do NOT create PRs yourself; the outer worker handles commit/push/PR.
|
|
- If the task is too large or not code-fit, leave the tree unchanged.
|
|
'''
|
|
|
|
env = os.environ.copy()
|
|
env['CLAW_FORCE_OPENAI_COMPAT'] = '1'
|
|
env['OPENAI_API_KEY'] = OPENROUTER_KEY
|
|
# Anthropic-compatible path via OpenRouter works better with current local patch
|
|
env['ANTHROPIC_API_KEY'] = OPENROUTER_KEY
|
|
env['ANTHROPIC_BASE_URL'] = 'https://openrouter.ai/api'
|
|
env['CLAW_CONFIG_HOME'] = str(CLAW_HOME)
|
|
|
|
result = run([str(CLAW_BIN), '--model', 'qwen/qwen3.6-plus:free', '--permission-mode', 'workspace-write', '-p', prompt], cwd=str(WORKTREE), env=env, timeout=1200, check=False)
|
|
|
|
# salvage + commit + push if anything changed
|
|
status = run(['git','status','--porcelain'], cwd=str(WORKTREE), check=False)
|
|
if status.stdout.strip():
|
|
run(['git','add','-A'], cwd=str(WORKTREE))
|
|
run(['git','commit','-m', f'chore: claw-code progress on #{ISSUE_NUM}\n\nRefs #{ISSUE_NUM}'], cwd=str(WORKTREE), check=False)
|
|
|
|
ahead = run(['git','log','--oneline','origin/main..HEAD'], cwd=str(WORKTREE), check=False)
|
|
has_work = bool(ahead.stdout.strip())
|
|
pr_url = ''
|
|
if has_work:
|
|
run(['git','push','-u','origin',BRANCH], cwd=str(WORKTREE), check=False)
|
|
pr = find_exact_open_pr(BRANCH)
|
|
if not pr:
|
|
pr = api('POST', f'/repos/{OWNER}/{NAME}/pulls', {
|
|
'title': f'[claw-code] {ISSUE_TITLE} (#{ISSUE_NUM})',
|
|
'head': BRANCH,
|
|
'base': 'main',
|
|
'body': f'Refs #{ISSUE_NUM}\n\nBuilt by Code Claw on OpenRouter qwen/qwen3.6-plus:free.'
|
|
})
|
|
pr_url = pr.get('html_url','')
|
|
add_label('claw-code-done')
|
|
remove_label('assigned-claw-code')
|
|
remove_label('claw-code-in-progress')
|
|
comment(f'✅ Code Claw completed a pass on this issue.\n\nBranch: `{BRANCH}`\nPR: {pr_url or "(created but URL missing)"}\nExit: {result.returncode}')
|
|
else:
|
|
remove_label('claw-code-in-progress')
|
|
comment(f'⚠️ Code Claw made no durable code changes on this pass.\n\nExit: {result.returncode}\nThis likely means the issue is too broad, not code-fit, or needs human clarification.')
|
|
|
|
print(json.dumps({'issue': ISSUE_NUM, 'repo': REPO, 'exit': result.returncode, 'has_work': has_work, 'pr_url': pr_url}))
|