diff --git a/.claude/skills/bug-tracker/scripts/tracker.py b/.claude/skills/bug-tracker/scripts/tracker.py index 847df22c..83af30f8 100644 --- a/.claude/skills/bug-tracker/scripts/tracker.py +++ b/.claude/skills/bug-tracker/scripts/tracker.py @@ -25,6 +25,8 @@ import json import os import subprocess import sys +import urllib.request +import urllib.error from pathlib import Path API = "https://git.azcomputerguru.com/api/v1" @@ -33,9 +35,8 @@ TRACKED_REPOS = ["gururmm", "guru-rmm", "guru-connect", "claudetools"] PRIORITY_LABELS = ["P1-critical", "P2-high", "P3-normal", "P4-low"] ASSIGN_LABELS = ["assigned:howard", "assigned:mike"] -# Resolve repo root for vault.sh SCRIPT_DIR = Path(__file__).resolve().parent -REPO_ROOT = SCRIPT_DIR.parent.parent.parent.parent # .claude/skills/bug-tracker/scripts -> repo root +REPO_ROOT = SCRIPT_DIR.parent.parent.parent.parent def get_token(): @@ -60,9 +61,6 @@ def get_token(): def api(method, path, token, data=None): """Make a Gitea API call using urllib (no external deps).""" - import urllib.request - import urllib.error - url = f"{API}{path}" headers = {"Authorization": f"token {token}", "Accept": "application/json"} body = None @@ -85,6 +83,23 @@ def api(method, path, token, data=None): return 0, {"error": str(e)} +def fetch_all_issues(token, repo, state): + """Fetch all issues for a repo+state with pagination.""" + results = [] + page = 1 + while True: + code, issues = api("GET", + f"/repos/{OWNER}/{repo}/issues?state={state}&type=issues&limit=50&page={page}", + token) + if code != 200 or not issues: + break + results.extend(issues) + page += 1 + if len(issues) < 50: + break + return results + + def get_label_ids(token, repo): """Fetch label name->id map for a repo.""" code, labels = api("GET", f"/repos/{OWNER}/{repo}/labels?limit=200", token) @@ -100,12 +115,10 @@ def cmd_status(args, token): total_open = 0 total_closed = 0 for repo in TRACKED_REPOS: - code, issues = api("GET", f"/repos/{OWNER}/{repo}/issues?state=open&type=issues&limit=1", token) - # Gitea doesn't return total count easily; fetch up to 50 - code, open_issues = api("GET", f"/repos/{OWNER}/{repo}/issues?state=open&type=issues&limit=50", token) - code2, closed_issues = api("GET", f"/repos/{OWNER}/{repo}/issues?state=closed&type=issues&limit=50", token) - o = len(open_issues) if code == 200 else 0 - c = len(closed_issues) if code2 == 200 else 0 + open_issues = fetch_all_issues(token, repo, "open") + closed_issues = fetch_all_issues(token, repo, "closed") + o = len(open_issues) + c = len(closed_issues) total_open += o total_closed += c print(f"{repo:<20} {o:>6} {c:>8}") @@ -116,32 +129,24 @@ def cmd_status(args, token): def cmd_list(args, token): """List issues with optional filters.""" repos = [args.repo] if args.repo else TRACKED_REPOS - state = args.state or "open" + state_filter = args.state or "open" + states = ["open", "closed"] if state_filter == "all" else [state_filter] all_issues = [] for repo in repos: - page = 1 - while True: - code, issues = api("GET", - f"/repos/{OWNER}/{repo}/issues?state={state}&type=issues&limit=50&page={page}", - token) - if code != 200 or not issues: - break - for i in issues: - i["_repo"] = repo - all_issues.extend(issues) - page += 1 - if len(issues) < 50: - break + for state in states: + for issue in fetch_all_issues(token, repo, state): + issue["_repo"] = repo + all_issues.append(issue) - # Filter by type if args.type: - all_issues = [i for i in all_issues if any(l["name"] == args.type for l in i.get("labels", []))] + all_issues = [i for i in all_issues + if any(l["name"] == args.type for l in i.get("labels", []))] - # Filter by client if args.client: tag = args.client if args.client.startswith("client:") else f"client:{args.client}" - all_issues = [i for i in all_issues if any(l["name"] == tag for l in i.get("labels", []))] + all_issues = [i for i in all_issues + if any(l["name"] == tag for l in i.get("labels", []))] if args.json: print(json.dumps(all_issues, indent=2, default=str)) @@ -160,37 +165,37 @@ def cmd_list(args, token): def cmd_create(args, token): """Create a new issue.""" + # Validate client-project requires --client (fail fast) + if args.type == "client-project" and not args.client: + print("[ERROR] --client is required for client-project type", file=sys.stderr) + sys.exit(1) + repo = args.repo lbl_map = get_label_ids(token, repo) label_ids = [] - # Type label - if args.type in lbl_map: - label_ids.append(lbl_map[args.type]) - else: + if args.type not in lbl_map: print(f"[ERROR] Type label '{args.type}' not found in {repo}", file=sys.stderr) sys.exit(1) + label_ids.append(lbl_map[args.type]) - # Priority label - if args.priority in lbl_map: - label_ids.append(lbl_map[args.priority]) - else: + if args.priority not in lbl_map: print(f"[ERROR] Priority label '{args.priority}' not found in {repo}", file=sys.stderr) sys.exit(1) + label_ids.append(lbl_map[args.priority]) - # Optional component if args.component and args.component in lbl_map: label_ids.append(lbl_map[args.component]) - # Optional client if args.client: tag = args.client if args.client.startswith("client:") else f"client:{args.client}" if tag in lbl_map: label_ids.append(lbl_map[tag]) + else: + print(f"[WARNING] Client label '{tag}' not found in {repo} — issue created without it", file=sys.stderr) - # Optional assignee if args.assign: - atag = args.assign if args.assign.startswith("assigned:") else f"assigned:{args.assign}" + atag = f"assigned:{args.assign}" if not args.assign.startswith("assigned:") else args.assign if atag in lbl_map: label_ids.append(lbl_map[atag]) @@ -228,7 +233,8 @@ def cmd_show(args, token): print(f"\n--- {len(comments)} comment(s) ---") for c in comments: print(f"\n {c['user']['login']} ({c['created_at'][:10]}):") - print(f" {c['body']}") + for line in (c.get("body") or "").split("\n"): + print(f" {line}") def cmd_comment(args, token): @@ -245,12 +251,10 @@ def cmd_comment(args, token): def cmd_priority(args, token): """Change issue priority (swap labels).""" lbl_map = get_label_ids(token, args.repo) - # Remove existing priority labels for p in PRIORITY_LABELS: pid = lbl_map.get(p) if pid: api("DELETE", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels/{pid}", token) - # Add new new_id = lbl_map.get(args.set) if new_id: api("POST", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels", token, {"labels": [new_id]}) @@ -263,13 +267,11 @@ def cmd_priority(args, token): def cmd_assign(args, token): """Change issue assignee (swap labels).""" lbl_map = get_label_ids(token, args.repo) - # Remove existing assign labels for a in ASSIGN_LABELS: aid = lbl_map.get(a) if aid: api("DELETE", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels/{aid}", token) - # Add new - atag = args.to if args.to.startswith("assigned:") else f"assigned:{args.to}" + atag = f"assigned:{args.to}" if not args.to.startswith("assigned:") else args.to new_id = lbl_map.get(atag) if new_id: api("POST", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels", token, {"labels": [new_id]}) @@ -286,20 +288,22 @@ def cmd_close(args, token): token, {"body": args.comment}) code, result = api("PATCH", f"/repos/{OWNER}/{args.repo}/issues/{args.number}", token, {"state": "closed"}) - if code == 201: + if code in (200, 201): print(f"[OK] Closed {args.repo} #{args.number}") else: print(f"[ERROR] HTTP {code}: {json.dumps(result)}", file=sys.stderr) + sys.exit(1) def cmd_reopen(args, token): """Reopen an issue.""" code, result = api("PATCH", f"/repos/{OWNER}/{args.repo}/issues/{args.number}", token, {"state": "open"}) - if code == 201: + if code in (200, 201): print(f"[OK] Reopened {args.repo} #{args.number}") else: print(f"[ERROR] HTTP {code}: {json.dumps(result)}", file=sys.stderr) + sys.exit(1) def cmd_label(args, token): @@ -325,10 +329,8 @@ def main(): parser = argparse.ArgumentParser(description="ACG Bug & Project Tracker") sub = parser.add_subparsers(dest="command", required=True) - # status sub.add_parser("status", help="Show issue counts") - # list p_list = sub.add_parser("list", help="List issues") p_list.add_argument("--repo", choices=TRACKED_REPOS) p_list.add_argument("--type", choices=["bug", "feature", "skill", "project", "security", "client-project"]) @@ -336,53 +338,46 @@ def main(): p_list.add_argument("--client") p_list.add_argument("--json", action="store_true") - # create p_create = sub.add_parser("create", help="Create an issue") p_create.add_argument("--repo", required=True, choices=TRACKED_REPOS) p_create.add_argument("--title", required=True) p_create.add_argument("--body", default="") - p_create.add_argument("--type", required=True, choices=["bug", "feature", "skill", "project", "security", "client-project"]) + p_create.add_argument("--type", required=True, + choices=["bug", "feature", "skill", "project", "security", "client-project"]) p_create.add_argument("--priority", required=True, choices=PRIORITY_LABELS) p_create.add_argument("--component") p_create.add_argument("--client") p_create.add_argument("--assign", choices=["howard", "mike"]) p_create.add_argument("--discovered") - # show p_show = sub.add_parser("show", help="Show issue detail") p_show.add_argument("repo", choices=TRACKED_REPOS) p_show.add_argument("number", type=int) - # comment p_comment = sub.add_parser("comment", help="Add a comment") p_comment.add_argument("repo", choices=TRACKED_REPOS) p_comment.add_argument("number", type=int) p_comment.add_argument("--body", required=True) - # priority p_prio = sub.add_parser("priority", help="Change priority") p_prio.add_argument("repo", choices=TRACKED_REPOS) p_prio.add_argument("number", type=int) p_prio.add_argument("--set", required=True, choices=PRIORITY_LABELS) - # assign p_assign = sub.add_parser("assign", help="Change assignee") p_assign.add_argument("repo", choices=TRACKED_REPOS) p_assign.add_argument("number", type=int) p_assign.add_argument("--to", required=True, choices=["howard", "mike"]) - # close p_close = sub.add_parser("close", help="Close an issue") p_close.add_argument("repo", choices=TRACKED_REPOS) p_close.add_argument("number", type=int) p_close.add_argument("--comment") - # reopen p_reopen = sub.add_parser("reopen", help="Reopen an issue") p_reopen.add_argument("repo", choices=TRACKED_REPOS) p_reopen.add_argument("number", type=int) - # label p_label = sub.add_parser("label", help="Add/remove a label") p_label.add_argument("repo", choices=TRACKED_REPOS) p_label.add_argument("number", type=int)