diff --git a/.claude/skills/bug-tracker/scripts/tracker.py b/.claude/skills/bug-tracker/scripts/tracker.py new file mode 100644 index 00000000..847df22c --- /dev/null +++ b/.claude/skills/bug-tracker/scripts/tracker.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +"""ACG Bug & Project Tracker — Gitea Issues CLI. + +Usage: + py tracker.py list [--repo REPO] [--type TYPE] [--state STATE] [--client CLIENT] [--json] + py tracker.py create --repo REPO --title TITLE [--body BODY] --type TYPE --priority PRIO + [--component COMP] [--client CLIENT] [--assign WHO] [--discovered DATE] + py tracker.py show REPO NUMBER + py tracker.py comment REPO NUMBER --body TEXT + py tracker.py label REPO NUMBER --add LABEL + py tracker.py label REPO NUMBER --remove LABEL + py tracker.py priority REPO NUMBER --set PRIO + py tracker.py assign REPO NUMBER --to WHO + py tracker.py close REPO NUMBER [--comment TEXT] + py tracker.py reopen REPO NUMBER + py tracker.py status + +Environment: + Reads Gitea API token from the SOPS vault via vault.sh. + Falls back to GITEA_TOKEN env var if vault is unavailable. +""" + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +API = "https://git.azcomputerguru.com/api/v1" +OWNER = "azcomputerguru" +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 + + +def get_token(): + """Get Gitea API token from vault or env.""" + tok = os.environ.get("GITEA_TOKEN") + if tok: + return tok + vault_sh = REPO_ROOT / ".claude" / "scripts" / "vault.sh" + if vault_sh.exists(): + try: + result = subprocess.run( + ["bash", str(vault_sh), "get-field", "services/gitea", "credentials.api.api-token"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except Exception: + pass + print("[ERROR] No Gitea token found. Set GITEA_TOKEN or configure vault.", file=sys.stderr) + sys.exit(1) + + +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 + if data is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(data).encode() + + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode() + return resp.status, json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + raw = e.read().decode() if e.fp else "" + try: + return e.code, json.loads(raw) + except Exception: + return e.code, {"error": raw} + except Exception as e: + return 0, {"error": str(e)} + + +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) + if code != 200: + return {} + return {l["name"]: l["id"] for l in labels} + + +def cmd_status(args, token): + """Show issue counts across all tracked repos.""" + print(f"{'Repo':<20} {'Open':>6} {'Closed':>8}") + print("-" * 36) + 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 + total_open += o + total_closed += c + print(f"{repo:<20} {o:>6} {c:>8}") + print("-" * 36) + print(f"{'Total':<20} {total_open:>6} {total_closed:>8}") + + +def cmd_list(args, token): + """List issues with optional filters.""" + repos = [args.repo] if args.repo else TRACKED_REPOS + state = args.state or "open" + 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 + + # 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", []))] + + # 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", []))] + + if args.json: + print(json.dumps(all_issues, indent=2, default=str)) + return + + if not all_issues: + print("[INFO] No issues found.") + return + + for i in sorted(all_issues, key=lambda x: x["number"]): + labels = ", ".join(l["name"] for l in i.get("labels", [])) + print(f" {i['_repo']:<16} #{i['number']:<4} {i['title'][:60]}") + if labels: + print(f" {'':16} [{labels}]") + + +def cmd_create(args, token): + """Create a new issue.""" + 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: + print(f"[ERROR] Type label '{args.type}' not found in {repo}", file=sys.stderr) + sys.exit(1) + + # Priority label + 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) + sys.exit(1) + + # 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]) + + # Optional assignee + if args.assign: + atag = args.assign if args.assign.startswith("assigned:") else f"assigned:{args.assign}" + if atag in lbl_map: + label_ids.append(lbl_map[atag]) + + body = args.body or "" + if args.discovered: + body = f"**Discovered:** {args.discovered}\n\n{body}" + + data = {"title": args.title, "body": body, "labels": label_ids} + code, result = api("POST", f"/repos/{OWNER}/{repo}/issues", token, data) + + if code == 201: + print(f"[OK] Created #{result['number']}: {result['title']}") + print(f" URL: https://git.azcomputerguru.com/{OWNER}/{repo}/issues/{result['number']}") + else: + print(f"[ERROR] HTTP {code}: {json.dumps(result)}", file=sys.stderr) + sys.exit(1) + + +def cmd_show(args, token): + """Show issue detail + comments.""" + code, issue = api("GET", f"/repos/{OWNER}/{args.repo}/issues/{args.number}", token) + if code != 200: + print(f"[ERROR] HTTP {code}", file=sys.stderr) + sys.exit(1) + + labels = ", ".join(l["name"] for l in issue.get("labels", [])) + print(f"#{issue['number']} [{issue['state']}] {issue['title']}") + print(f"Labels: {labels}") + print(f"Created: {issue['created_at'][:10]} Updated: {issue['updated_at'][:10]}") + if issue.get("body"): + print(f"\n{issue['body']}") + + code2, comments = api("GET", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/comments", token) + if code2 == 200 and comments: + 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']}") + + +def cmd_comment(args, token): + """Add a comment to an issue.""" + code, result = api("POST", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/comments", + token, {"body": args.body}) + if code == 201: + print(f"[OK] Comment added to {args.repo} #{args.number}") + else: + print(f"[ERROR] HTTP {code}: {json.dumps(result)}", file=sys.stderr) + sys.exit(1) + + +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]}) + print(f"[OK] {args.repo} #{args.number} priority set to {args.set}") + else: + print(f"[ERROR] Priority label '{args.set}' not found", file=sys.stderr) + sys.exit(1) + + +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}" + new_id = lbl_map.get(atag) + if new_id: + api("POST", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels", token, {"labels": [new_id]}) + print(f"[OK] {args.repo} #{args.number} assigned to {args.to}") + else: + print(f"[ERROR] Assignee label '{atag}' not found", file=sys.stderr) + sys.exit(1) + + +def cmd_close(args, token): + """Close an issue, optionally with a comment.""" + if args.comment: + api("POST", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/comments", + token, {"body": args.comment}) + code, result = api("PATCH", f"/repos/{OWNER}/{args.repo}/issues/{args.number}", + token, {"state": "closed"}) + if code == 201: + print(f"[OK] Closed {args.repo} #{args.number}") + else: + print(f"[ERROR] HTTP {code}: {json.dumps(result)}", file=sys.stderr) + + +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: + print(f"[OK] Reopened {args.repo} #{args.number}") + else: + print(f"[ERROR] HTTP {code}: {json.dumps(result)}", file=sys.stderr) + + +def cmd_label(args, token): + """Add or remove a label.""" + lbl_map = get_label_ids(token, args.repo) + if args.add: + lid = lbl_map.get(args.add) + if not lid: + print(f"[ERROR] Label '{args.add}' not found in {args.repo}", file=sys.stderr) + sys.exit(1) + api("POST", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels", token, {"labels": [lid]}) + print(f"[OK] Added '{args.add}' to {args.repo} #{args.number}") + elif args.remove: + lid = lbl_map.get(args.remove) + if not lid: + print(f"[ERROR] Label '{args.remove}' not found in {args.repo}", file=sys.stderr) + sys.exit(1) + api("DELETE", f"/repos/{OWNER}/{args.repo}/issues/{args.number}/labels/{lid}", token) + print(f"[OK] Removed '{args.remove}' from {args.repo} #{args.number}") + + +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"]) + p_list.add_argument("--state", choices=["open", "closed", "all"], default="open") + 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("--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) + grp = p_label.add_mutually_exclusive_group(required=True) + grp.add_argument("--add") + grp.add_argument("--remove") + + args = parser.parse_args() + token = get_token() + + commands = { + "status": cmd_status, "list": cmd_list, "create": cmd_create, + "show": cmd_show, "comment": cmd_comment, "priority": cmd_priority, + "assign": cmd_assign, "close": cmd_close, "reopen": cmd_reopen, + "label": cmd_label, + } + commands[args.command](args, token) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/bug-tracker/skill.md b/.claude/skills/bug-tracker/skill.md index 01a6ebc2..79dcdd0e 100644 --- a/.claude/skills/bug-tracker/skill.md +++ b/.claude/skills/bug-tracker/skill.md @@ -1,90 +1,110 @@ --- name: bug-tracker -description: "File, list, update, and close bugs/features/tasks in the Gitea issue tracker across GuruRMM, GuruConnect, and ClaudeTools repos. Visual dashboard at projects/msp-tools/bug-tracker/dashboard.html." -triggers: - - "file a bug" - - "track a bug" - - "open an issue" - - "bug tracker" - - "what bugs are open" - - "list bugs" - - "close issue" - - "update issue" - - "mark fixed" - - "what's being worked on" +description: "File, list, update, and close bugs/features/tasks/client-projects in the Gitea issue tracker across GuruRMM, GuruConnect, and ClaudeTools repos. Live dashboard at tracker.azcomputerguru.com. Triggers: file a bug, track a bug, open an issue, bug tracker, what bugs are open, list bugs, close issue, update issue, mark fixed, what is being worked on." --- # Bug & Project Tracker — Gitea Issues -Manage the ACG bug/feature/skill/project tracker via Gitea Issues API. -Dashboard: `projects/msp-tools/bug-tracker/dashboard.html` +Live client for the ACG bug/feature/skill/project/client-project tracker. All data lives in +Gitea Issues — no separate database. The web dashboard and this skill are two interfaces +to the same data. -## Setup +- **Dashboard:** https://tracker.azcomputerguru.com/dashboard.html +- **Direct:** http://172.16.3.20:8089/dashboard.html -- Gitea: `https://git.azcomputerguru.com/api/v1` -- Owner: `azcomputerguru` -- Token: vault `services/gitea` → `credentials.api.api-token` -- Tracked repos: `gururmm`, `guru-connect`, `claudetools` +## Running + +```bash +BT=".claude/skills/bug-tracker/scripts/tracker.py" + +# --- reads --- +py "$BT" status # issue counts across all repos +py "$BT" list # all open issues, all repos +py "$BT" list --repo gururmm --type bug # gururmm bugs only +py "$BT" list --type client-project --client cascades # Cascades client projects +py "$BT" list --state closed --json # closed issues as JSON +py "$BT" show gururmm 39 # detail + comments for gururmm #39 + +# --- writes --- +py "$BT" create --repo claudetools --title "vault: X fails" \ + --body "Error detail here" --type bug --priority P2-high \ + --component component:harness --discovered 2026-07-18 + +py "$BT" create --repo claudetools --title "Cascades: new project" \ + --body "Description" --type client-project --priority P3-normal \ + --client cascades --assign howard --discovered 2026-07-18 + +py "$BT" comment gururmm 39 --body "Tried X, still reproduces" +py "$BT" priority gururmm 39 --set P1-critical +py "$BT" assign claudetools 5 --to mike +py "$BT" label gururmm 39 --add in-progress +py "$BT" label gururmm 39 --remove blocked +py "$BT" close claudetools 7 --comment "Fixed in commit abc123" +py "$BT" reopen claudetools 7 +``` + +## Credentials + +Token loaded at runtime from the SOPS vault (never hardcoded): +**`services/gitea`** -> `credentials.api.api-token`. +Falls back to `GITEA_TOKEN` env var if vault is unavailable. + +## Tracked repos + +| Repo | What it tracks | +|------|---------------| +| `gururmm` | RMM agent/server/dashboard bugs, features, security | +| `guru-rmm` | RMM code review tracking | +| `guru-connect` | GuruConnect bugs, security audit findings | +| `claudetools` | Harness/skill bugs, skill work, projects, **all client projects** | ## Label scheme -| Label | Purpose | -|-------|---------| -| `bug` | Something broken | -| `feature` | New capability | -| `skill` | Skill work | -| `project` | Project-level | -| `P1-critical` / `P2-high` / `P3-normal` / `P4-low` | Priority | -| `in-progress` | Being worked | -| `blocked` | Blocked | -| `dropped` | Stalled | -| `fixed` | Fix applied | -| `verified` | Confirmed working | -| `security` | Security-related | -| `component:rmm-agent` / `rmm-server` / `rmm-dashboard` / `guruconnect` / `harness` | Component | +| Category | Labels | +|----------|--------| +| Type | `bug` `feature` `skill` `project` `security` `client-project` | +| Priority | `P1-critical` `P2-high` `P3-normal` `P4-low` | +| Status | `in-progress` `blocked` `dropped` `fixed` `verified` `wontfix` | +| Component | `component:rmm-agent` `component:rmm-server` `component:rmm-dashboard` `component:guruconnect` `component:harness` | +| Client | `client:` (e.g. `client:cascades`, `client:dataforth`) | +| Assignee | `assigned:howard` `assigned:mike` | -## Operations - -### File a new issue -Read the token from vault. POST to `/repos/{owner}/{repo}/issues`: -```json -{ - "title": "...", - "body": "## Problem\n...\n\n## Steps to reproduce\n...\n\n## Expected behavior\n...\n\n## Session context\nFiled from session on MACHINE at DATE", - "labels": [label_id, label_id, ...] -} -``` -Always include: one type label (bug/feature/skill/project), one priority label, component label(s) if applicable. - -### List open issues -GET `/repos/{owner}/{repo}/issues?state=open&type=issues&limit=50` -For cross-repo view, query all tracked repos and merge results. - -### Update an issue (add progress, change status) -- Add a comment: POST `/repos/{owner}/{repo}/issues/{number}/comments` with `{"body": "..."}` -- Change labels: PATCH `/repos/{owner}/{repo}/issues/{number}` or use label endpoints -- Close: PATCH with `{"state": "closed"}` - -### Mark fixed -Add `fixed` label, add a comment describing the fix, reference the commit if available. - -### Mark verified -Add `verified` label, close the issue. +Always include: one type label + one priority label. Add component/client/assignee as applicable. ## Auto-filing (IMPORTANT) -When a bug surfaces during a Claude session — a command fails unexpectedly, a skill hits an error that needs follow-up, or the user reports something broken — **file it as an issue automatically** (with user confirmation for the title/priority). This is the whole point: bugs should not require someone to remember them. +When a bug surfaces during a Claude session — a command fails unexpectedly, a skill +hits an error that needs follow-up, or the user reports something broken — **file it +as an issue** using this script (with user confirmation for title/priority). This is +the whole point: bugs should not require someone to remember them. The issue body should include: - What happened (error message, unexpected behavior) - Context (what was being attempted, which machine, which session) - Any workaround applied -- Links to relevant session logs if available +- A `**Discovered:** YYYY-MM-DD` line so the dashboard shows accurate age -## Dashboard +Do NOT file: +- Client machine problems (PacketDial 404s, Intune device metrics, client network issues) +- Vendor API limitations we cannot fix +- One-off env/friction issues that are already resolved -Open `projects/msp-tools/bug-tracker/dashboard.html` in a browser. -Pass the Gitea token as a URL parameter: `?token=xxx` -It shows a kanban board (Open / In Progress / Blocked / Done) with filtering by repo and type. +## Error logging + +On any failure (API error, label not found, vault unavailable), log it: +```bash +bash .claude/scripts/log-skill-error.sh "bug-tracker" "brief error description" --context "cmd=create repo=claudetools" +``` + +## Relationship to the dashboard + +The dashboard at tracker.azcomputerguru.com is a single-page HTML app that makes the +same Gitea API calls this script does. There is no sync, no cache, no separate database. +An issue created by this script appears on the dashboard immediately. An issue edited +on the dashboard is visible to this script immediately. They are the same data. + +Dashboard infrastructure: Docker container `acg-tracker` on Jupiter (172.16.3.20:8089), +proxied through NPM with Let's Encrypt SSL. Files at `/mnt/user/appdata/acg-tracker/`. +To update the dashboard: upload new `dashboard.html` via SFTP, `docker restart acg-tracker`. $ARGUMENTS diff --git a/projects/msp-tools/bug-tracker/dashboard.html b/projects/msp-tools/bug-tracker/dashboard.html index 955f18e9..175aa8f9 100644 --- a/projects/msp-tools/bug-tracker/dashboard.html +++ b/projects/msp-tools/bug-tracker/dashboard.html @@ -562,9 +562,19 @@ async function showDetail(repo, num) { m.innerHTML = `
${REPO_NAMES[repo]||repo} #${num} ${st}
-

${esc(issue.title)}

-
${labels.map(l=>`${esc(l.name)}`).join('')}
-
${body}
+
+

${esc(issue.title)} [edit]

+
${labels.map(l=>`${esc(l.name)}`).join('')}
+
${body}
+
+
Created ${new Date(issue.created_at).toLocaleString()} · Updated ${new Date(issue.updated_at).toLocaleString()} · @@ -610,6 +620,26 @@ async function showDetail(repo, num) { } function closeDetail() { document.getElementById('detail-overlay').classList.remove('open'); } +function enterEditMode() { + document.getElementById('detail-view-mode').style.display='none'; + document.getElementById('detail-edit-mode').style.display='block'; + document.getElementById('edit-title').focus(); +} +function cancelEdit() { + document.getElementById('detail-view-mode').style.display='block'; + document.getElementById('detail-edit-mode').style.display='none'; +} +async function saveEdit(repo, num) { + const title = document.getElementById('edit-title').value.trim(); + const body = document.getElementById('edit-body').value; + if (!title) return; + await fetch(`${API}/repos/${OWNER}/${repo}/issues/${num}`, { + method:'PATCH', headers:{...headers(),'Content-Type':'application/json'}, + body: JSON.stringify({title, body}) + }); + showDetail(repo, num); loadIssues(); +} + async function addComment(repo,num) { const t=document.getElementById('cmt-text').value.trim(); if(!t) return; await fetch(`${API}/repos/${OWNER}/${repo}/issues/${num}/comments`, {method:'POST', headers:{...headers(),'Content-Type':'application/json'}, body:JSON.stringify({body:t})});