sync: auto-sync from HOWARD-HOME at 2026-07-18 11:37:15
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-07-18 11:37:15
This commit is contained in:
@@ -25,6 +25,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
API = "https://git.azcomputerguru.com/api/v1"
|
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"]
|
PRIORITY_LABELS = ["P1-critical", "P2-high", "P3-normal", "P4-low"]
|
||||||
ASSIGN_LABELS = ["assigned:howard", "assigned:mike"]
|
ASSIGN_LABELS = ["assigned:howard", "assigned:mike"]
|
||||||
|
|
||||||
# Resolve repo root for vault.sh
|
|
||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
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():
|
def get_token():
|
||||||
@@ -60,9 +61,6 @@ def get_token():
|
|||||||
|
|
||||||
def api(method, path, token, data=None):
|
def api(method, path, token, data=None):
|
||||||
"""Make a Gitea API call using urllib (no external deps)."""
|
"""Make a Gitea API call using urllib (no external deps)."""
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
|
|
||||||
url = f"{API}{path}"
|
url = f"{API}{path}"
|
||||||
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||||
body = None
|
body = None
|
||||||
@@ -85,6 +83,23 @@ def api(method, path, token, data=None):
|
|||||||
return 0, {"error": str(e)}
|
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):
|
def get_label_ids(token, repo):
|
||||||
"""Fetch label name->id map for a repo."""
|
"""Fetch label name->id map for a repo."""
|
||||||
code, labels = api("GET", f"/repos/{OWNER}/{repo}/labels?limit=200", token)
|
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_open = 0
|
||||||
total_closed = 0
|
total_closed = 0
|
||||||
for repo in TRACKED_REPOS:
|
for repo in TRACKED_REPOS:
|
||||||
code, issues = api("GET", f"/repos/{OWNER}/{repo}/issues?state=open&type=issues&limit=1", token)
|
open_issues = fetch_all_issues(token, repo, "open")
|
||||||
# Gitea doesn't return total count easily; fetch up to 50
|
closed_issues = fetch_all_issues(token, repo, "closed")
|
||||||
code, open_issues = api("GET", f"/repos/{OWNER}/{repo}/issues?state=open&type=issues&limit=50", token)
|
o = len(open_issues)
|
||||||
code2, closed_issues = api("GET", f"/repos/{OWNER}/{repo}/issues?state=closed&type=issues&limit=50", token)
|
c = len(closed_issues)
|
||||||
o = len(open_issues) if code == 200 else 0
|
|
||||||
c = len(closed_issues) if code2 == 200 else 0
|
|
||||||
total_open += o
|
total_open += o
|
||||||
total_closed += c
|
total_closed += c
|
||||||
print(f"{repo:<20} {o:>6} {c:>8}")
|
print(f"{repo:<20} {o:>6} {c:>8}")
|
||||||
@@ -116,32 +129,24 @@ def cmd_status(args, token):
|
|||||||
def cmd_list(args, token):
|
def cmd_list(args, token):
|
||||||
"""List issues with optional filters."""
|
"""List issues with optional filters."""
|
||||||
repos = [args.repo] if args.repo else TRACKED_REPOS
|
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 = []
|
all_issues = []
|
||||||
|
|
||||||
for repo in repos:
|
for repo in repos:
|
||||||
page = 1
|
for state in states:
|
||||||
while True:
|
for issue in fetch_all_issues(token, repo, state):
|
||||||
code, issues = api("GET",
|
issue["_repo"] = repo
|
||||||
f"/repos/{OWNER}/{repo}/issues?state={state}&type=issues&limit=50&page={page}",
|
all_issues.append(issue)
|
||||||
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
|
|
||||||
|
|
||||||
# Filter by type
|
|
||||||
if args.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:
|
if args.client:
|
||||||
tag = args.client if args.client.startswith("client:") else f"client:{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:
|
if args.json:
|
||||||
print(json.dumps(all_issues, indent=2, default=str))
|
print(json.dumps(all_issues, indent=2, default=str))
|
||||||
@@ -160,37 +165,37 @@ def cmd_list(args, token):
|
|||||||
|
|
||||||
def cmd_create(args, token):
|
def cmd_create(args, token):
|
||||||
"""Create a new issue."""
|
"""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
|
repo = args.repo
|
||||||
lbl_map = get_label_ids(token, repo)
|
lbl_map = get_label_ids(token, repo)
|
||||||
label_ids = []
|
label_ids = []
|
||||||
|
|
||||||
# Type label
|
if args.type not in lbl_map:
|
||||||
if args.type in lbl_map:
|
|
||||||
label_ids.append(lbl_map[args.type])
|
|
||||||
else:
|
|
||||||
print(f"[ERROR] Type label '{args.type}' not found in {repo}", file=sys.stderr)
|
print(f"[ERROR] Type label '{args.type}' not found in {repo}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
label_ids.append(lbl_map[args.type])
|
||||||
|
|
||||||
# Priority label
|
if args.priority not in lbl_map:
|
||||||
if args.priority in lbl_map:
|
|
||||||
label_ids.append(lbl_map[args.priority])
|
|
||||||
else:
|
|
||||||
print(f"[ERROR] Priority label '{args.priority}' not found in {repo}", file=sys.stderr)
|
print(f"[ERROR] Priority label '{args.priority}' not found in {repo}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
label_ids.append(lbl_map[args.priority])
|
||||||
|
|
||||||
# Optional component
|
|
||||||
if args.component and args.component in lbl_map:
|
if args.component and args.component in lbl_map:
|
||||||
label_ids.append(lbl_map[args.component])
|
label_ids.append(lbl_map[args.component])
|
||||||
|
|
||||||
# Optional client
|
|
||||||
if args.client:
|
if args.client:
|
||||||
tag = args.client if args.client.startswith("client:") else f"client:{args.client}"
|
tag = args.client if args.client.startswith("client:") else f"client:{args.client}"
|
||||||
if tag in lbl_map:
|
if tag in lbl_map:
|
||||||
label_ids.append(lbl_map[tag])
|
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:
|
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:
|
if atag in lbl_map:
|
||||||
label_ids.append(lbl_map[atag])
|
label_ids.append(lbl_map[atag])
|
||||||
|
|
||||||
@@ -228,7 +233,8 @@ def cmd_show(args, token):
|
|||||||
print(f"\n--- {len(comments)} comment(s) ---")
|
print(f"\n--- {len(comments)} comment(s) ---")
|
||||||
for c in comments:
|
for c in comments:
|
||||||
print(f"\n {c['user']['login']} ({c['created_at'][:10]}):")
|
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):
|
def cmd_comment(args, token):
|
||||||
@@ -245,12 +251,10 @@ def cmd_comment(args, token):
|
|||||||
def cmd_priority(args, token):
|
def cmd_priority(args, token):
|
||||||
"""Change issue priority (swap labels)."""
|
"""Change issue priority (swap labels)."""
|
||||||
lbl_map = get_label_ids(token, args.repo)
|
lbl_map = get_label_ids(token, args.repo)
|
||||||
# Remove existing priority labels
|
|
||||||
for p in PRIORITY_LABELS:
|
for p in PRIORITY_LABELS:
|
||||||
pid = lbl_map.get(p)
|
pid = lbl_map.get(p)
|
||||||
if pid:
|
if pid:
|
||||||
api("DELETE", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels/{pid}", token)
|
api("DELETE", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels/{pid}", token)
|
||||||
# Add new
|
|
||||||
new_id = lbl_map.get(args.set)
|
new_id = lbl_map.get(args.set)
|
||||||
if new_id:
|
if new_id:
|
||||||
api("POST", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels", token, {"labels": [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):
|
def cmd_assign(args, token):
|
||||||
"""Change issue assignee (swap labels)."""
|
"""Change issue assignee (swap labels)."""
|
||||||
lbl_map = get_label_ids(token, args.repo)
|
lbl_map = get_label_ids(token, args.repo)
|
||||||
# Remove existing assign labels
|
|
||||||
for a in ASSIGN_LABELS:
|
for a in ASSIGN_LABELS:
|
||||||
aid = lbl_map.get(a)
|
aid = lbl_map.get(a)
|
||||||
if aid:
|
if aid:
|
||||||
api("DELETE", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels/{aid}", token)
|
api("DELETE", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels/{aid}", token)
|
||||||
# Add new
|
atag = f"assigned:{args.to}" if not args.to.startswith("assigned:") else args.to
|
||||||
atag = args.to if args.to.startswith("assigned:") else f"assigned:{args.to}"
|
|
||||||
new_id = lbl_map.get(atag)
|
new_id = lbl_map.get(atag)
|
||||||
if new_id:
|
if new_id:
|
||||||
api("POST", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels", token, {"labels": [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})
|
token, {"body": args.comment})
|
||||||
code, result = api("PATCH", f"/repos/{OWNER}/{args.repo}/issues/{args.number}",
|
code, result = api("PATCH", f"/repos/{OWNER}/{args.repo}/issues/{args.number}",
|
||||||
token, {"state": "closed"})
|
token, {"state": "closed"})
|
||||||
if code == 201:
|
if code in (200, 201):
|
||||||
print(f"[OK] Closed {args.repo} #{args.number}")
|
print(f"[OK] Closed {args.repo} #{args.number}")
|
||||||
else:
|
else:
|
||||||
print(f"[ERROR] HTTP {code}: {json.dumps(result)}", file=sys.stderr)
|
print(f"[ERROR] HTTP {code}: {json.dumps(result)}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def cmd_reopen(args, token):
|
def cmd_reopen(args, token):
|
||||||
"""Reopen an issue."""
|
"""Reopen an issue."""
|
||||||
code, result = api("PATCH", f"/repos/{OWNER}/{args.repo}/issues/{args.number}",
|
code, result = api("PATCH", f"/repos/{OWNER}/{args.repo}/issues/{args.number}",
|
||||||
token, {"state": "open"})
|
token, {"state": "open"})
|
||||||
if code == 201:
|
if code in (200, 201):
|
||||||
print(f"[OK] Reopened {args.repo} #{args.number}")
|
print(f"[OK] Reopened {args.repo} #{args.number}")
|
||||||
else:
|
else:
|
||||||
print(f"[ERROR] HTTP {code}: {json.dumps(result)}", file=sys.stderr)
|
print(f"[ERROR] HTTP {code}: {json.dumps(result)}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def cmd_label(args, token):
|
def cmd_label(args, token):
|
||||||
@@ -325,10 +329,8 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description="ACG Bug & Project Tracker")
|
parser = argparse.ArgumentParser(description="ACG Bug & Project Tracker")
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
# status
|
|
||||||
sub.add_parser("status", help="Show issue counts")
|
sub.add_parser("status", help="Show issue counts")
|
||||||
|
|
||||||
# list
|
|
||||||
p_list = sub.add_parser("list", help="List issues")
|
p_list = sub.add_parser("list", help="List issues")
|
||||||
p_list.add_argument("--repo", choices=TRACKED_REPOS)
|
p_list.add_argument("--repo", choices=TRACKED_REPOS)
|
||||||
p_list.add_argument("--type", choices=["bug", "feature", "skill", "project", "security", "client-project"])
|
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("--client")
|
||||||
p_list.add_argument("--json", action="store_true")
|
p_list.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
# create
|
|
||||||
p_create = sub.add_parser("create", help="Create an issue")
|
p_create = sub.add_parser("create", help="Create an issue")
|
||||||
p_create.add_argument("--repo", required=True, choices=TRACKED_REPOS)
|
p_create.add_argument("--repo", required=True, choices=TRACKED_REPOS)
|
||||||
p_create.add_argument("--title", required=True)
|
p_create.add_argument("--title", required=True)
|
||||||
p_create.add_argument("--body", default="")
|
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("--priority", required=True, choices=PRIORITY_LABELS)
|
||||||
p_create.add_argument("--component")
|
p_create.add_argument("--component")
|
||||||
p_create.add_argument("--client")
|
p_create.add_argument("--client")
|
||||||
p_create.add_argument("--assign", choices=["howard", "mike"])
|
p_create.add_argument("--assign", choices=["howard", "mike"])
|
||||||
p_create.add_argument("--discovered")
|
p_create.add_argument("--discovered")
|
||||||
|
|
||||||
# show
|
|
||||||
p_show = sub.add_parser("show", help="Show issue detail")
|
p_show = sub.add_parser("show", help="Show issue detail")
|
||||||
p_show.add_argument("repo", choices=TRACKED_REPOS)
|
p_show.add_argument("repo", choices=TRACKED_REPOS)
|
||||||
p_show.add_argument("number", type=int)
|
p_show.add_argument("number", type=int)
|
||||||
|
|
||||||
# comment
|
|
||||||
p_comment = sub.add_parser("comment", help="Add a comment")
|
p_comment = sub.add_parser("comment", help="Add a comment")
|
||||||
p_comment.add_argument("repo", choices=TRACKED_REPOS)
|
p_comment.add_argument("repo", choices=TRACKED_REPOS)
|
||||||
p_comment.add_argument("number", type=int)
|
p_comment.add_argument("number", type=int)
|
||||||
p_comment.add_argument("--body", required=True)
|
p_comment.add_argument("--body", required=True)
|
||||||
|
|
||||||
# priority
|
|
||||||
p_prio = sub.add_parser("priority", help="Change priority")
|
p_prio = sub.add_parser("priority", help="Change priority")
|
||||||
p_prio.add_argument("repo", choices=TRACKED_REPOS)
|
p_prio.add_argument("repo", choices=TRACKED_REPOS)
|
||||||
p_prio.add_argument("number", type=int)
|
p_prio.add_argument("number", type=int)
|
||||||
p_prio.add_argument("--set", required=True, choices=PRIORITY_LABELS)
|
p_prio.add_argument("--set", required=True, choices=PRIORITY_LABELS)
|
||||||
|
|
||||||
# assign
|
|
||||||
p_assign = sub.add_parser("assign", help="Change assignee")
|
p_assign = sub.add_parser("assign", help="Change assignee")
|
||||||
p_assign.add_argument("repo", choices=TRACKED_REPOS)
|
p_assign.add_argument("repo", choices=TRACKED_REPOS)
|
||||||
p_assign.add_argument("number", type=int)
|
p_assign.add_argument("number", type=int)
|
||||||
p_assign.add_argument("--to", required=True, choices=["howard", "mike"])
|
p_assign.add_argument("--to", required=True, choices=["howard", "mike"])
|
||||||
|
|
||||||
# close
|
|
||||||
p_close = sub.add_parser("close", help="Close an issue")
|
p_close = sub.add_parser("close", help="Close an issue")
|
||||||
p_close.add_argument("repo", choices=TRACKED_REPOS)
|
p_close.add_argument("repo", choices=TRACKED_REPOS)
|
||||||
p_close.add_argument("number", type=int)
|
p_close.add_argument("number", type=int)
|
||||||
p_close.add_argument("--comment")
|
p_close.add_argument("--comment")
|
||||||
|
|
||||||
# reopen
|
|
||||||
p_reopen = sub.add_parser("reopen", help="Reopen an issue")
|
p_reopen = sub.add_parser("reopen", help="Reopen an issue")
|
||||||
p_reopen.add_argument("repo", choices=TRACKED_REPOS)
|
p_reopen.add_argument("repo", choices=TRACKED_REPOS)
|
||||||
p_reopen.add_argument("number", type=int)
|
p_reopen.add_argument("number", type=int)
|
||||||
|
|
||||||
# label
|
|
||||||
p_label = sub.add_parser("label", help="Add/remove a label")
|
p_label = sub.add_parser("label", help="Add/remove a label")
|
||||||
p_label.add_argument("repo", choices=TRACKED_REPOS)
|
p_label.add_argument("repo", choices=TRACKED_REPOS)
|
||||||
p_label.add_argument("number", type=int)
|
p_label.add_argument("number", type=int)
|
||||||
|
|||||||
Reference in New Issue
Block a user