#!/usr/bin/env python3 import os, sys, json, subprocess, random, time, logging from datetime import datetime logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger(__name__) TOKEN = os.getenv("GITEA_TOKEN", "ffce3b62d583b761238ae00839dce7718acaad85") API_BASE = os.getenv("GITEA_API_BASE", "http://gitea.bubuit.net:3000/api/v1") REPO = "oib/aitbc" AGENT = os.getenv("AGENT_NAME", "aitbc") def log(msg): logger.info(msg) def die(msg): log(f"FATAL: {msg}") sys.exit(1) def api_get(path): cmd = ["curl", "-s", "-H", f"Authorization: token {TOKEN}", f"{API_BASE}/{path}"] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: return None try: return json.loads(result.stdout) except: return None def api_post(path, payload): cmd = ["curl", "-s", "-X", "POST", "-H", f"Authorization: token {TOKEN}", "-H", "Content-Type: application/json", f"{API_BASE}/{path}", "-d", json.dumps(payload)] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: return None try: return json.loads(result.stdout) except: return None def compute_utility(issue): score = 0 labels = [l['name'] for l in issue['labels']] if 'security' in labels: score += 100 elif 'bug' in labels: score += 50 elif 'feature' in labels: score += 30 elif 'refactor' in labels: score += 10 if 'good-first-task-for-agent' in labels: score += 20 if any(tag in labels for tag in ['needs-design', 'blocked', 'needs-reproduction']): score -= 1000 score += issue['comments'] * 5 return score def get_open_unassigned_issues(): items = api_get(f"repos/{REPO}/issues?state=open") or [] candidates = [] for i in items: # Skip PRs: pull_request field is non-null for PRs if i.get('pull_request') is not None: continue if i['assignee'] is not None: continue labels = [l['name'] for l in i['labels']] if any(tag in labels for tag in ['needs-design', 'blocked', 'needs-reproduction']): continue candidates.append(i) return candidates def create_claim_branch(issue_number): branch = f"claim/{issue_number}" subprocess.run(["git", "fetch", "origin"], capture_output=True, cwd=REPO_DIR) subprocess.run(["git", "checkout", "-B", branch, "origin/main"], capture_output=True, cwd=REPO_DIR) subprocess.run(["git", "commit", "--allow-empty", "-m", f"Claim issue #{issue_number} for {AGENT}"], capture_output=True, cwd=REPO_DIR) r = subprocess.run(["git", "push", "-u", "origin", branch], capture_output=True, cwd=REPO_DIR) return r.returncode == 0 def assign_issue(issue_number): payload = {"assignee": AGENT} result = api_post(f"repos/{REPO}/issues/{issue_number}/assignees", payload) return result is not None def add_comment(issue_number, body): payload = {"body": body} return api_post(f"repos/{REPO}/issues/{issue_number}/comments", payload) is not None def main(): # Jitter 0-60s time.sleep(random.randint(0, 60)) log("Claim task cycle starting...") state_file = os.path.join(REPO_DIR, ".claim-state.json") try: with open(state_file) as f: state = json.load(f) except: state = {} if state.get('current_claim'): log(f"Already working on issue #{state['current_claim']} (branch {state.get('work_branch')})") return issues = get_open_unassigned_issues() if not issues: log("No unassigned issues available.") return issues.sort(key=lambda i: compute_utility(i), reverse=True) for issue in issues: num = issue['number'] title = issue['title'] labels = [lbl['name'] for lbl in issue.get('labels', [])] log(f"Attempting to claim issue #{num}: {title} (labels={labels})") if create_claim_branch(num): assign_issue(num) slug = ''.join(c if c.isalnum() else '-' for c in title.lower())[:40].strip('-') work_branch = f"{AGENT}/{num}-{slug}" subprocess.run(["git", "checkout", "-b", work_branch, "main"], cwd=REPO_DIR, capture_output=True) state = { 'current_claim': num, 'claim_branch': f'claim/{num}', 'work_branch': work_branch, 'claimed_at': datetime.utcnow().isoformat() + 'Z', 'issue_title': title, 'labels': labels } with open(state_file, 'w') as f: json.dump(state, f, indent=2) add_comment(num, f"Agent `{AGENT}` claiming this task. (automated)") log(f"✅ Claimed issue #{num}. Work branch: {work_branch}") return else: log(f"Claim failed for #{num} (branch exists). Trying next...") log("Could not claim any issue; all taken or unavailable.") if __name__ == "__main__": REPO_DIR = '/opt/aitbc' main()