sync: auto-sync from HOWARD-HOME at 2026-07-18 11:26:31

Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-18 11:26:31
This commit is contained in:
2026-07-18 11:26:58 -07:00
parent c8985bd34b
commit 96093000aa
3 changed files with 525 additions and 69 deletions

View File

@@ -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()

View File

@@ -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:<slug>` (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

View File

@@ -562,9 +562,19 @@ async function showDetail(repo, num) {
m.innerHTML = `
<button class="xbtn" onclick="closeDetail()">&times;</button>
<div class="m-repo">${REPO_NAMES[repo]||repo} #${num} <span class="sbadge s-${st}" style="margin-left:6px">${st}</span></div>
<h2>${esc(issue.title)}</h2>
<div class="m-labels">${labels.map(l=>`<span class="tag" style="${tagStyle(l)}">${esc(l.name)}</span>`).join('')}</div>
<div class="m-body">${body}</div>
<div id="detail-view-mode">
<h2>${esc(issue.title)} <span style="font-size:12px;color:var(--muted);cursor:pointer;margin-left:8px;font-weight:400" onclick="enterEditMode('${repo}',${num})">[edit]</span></h2>
<div class="m-labels">${labels.map(l=>`<span class="tag" style="${tagStyle(l)}">${esc(l.name)}</span>`).join('')}</div>
<div class="m-body">${body}</div>
</div>
<div id="detail-edit-mode" style="display:none">
<div class="fg"><label>Title</label><input type="text" id="edit-title" value="${esc(issue.title).replace(/"/g,'&quot;')}"></div>
<div class="fg"><label>Description</label><textarea id="edit-body" style="min-height:150px">${esc(issue.body||'')}</textarea></div>
<div style="display:flex;gap:6px">
<button class="btn btn-success" style="font-size:12px" onclick="saveEdit('${repo}',${num})">Save</button>
<button class="btn" style="font-size:12px" onclick="cancelEdit()">Cancel</button>
</div>
</div>
<div class="m-meta">
Created ${new Date(issue.created_at).toLocaleString()} &middot;
Updated ${new Date(issue.updated_at).toLocaleString()} &middot;
@@ -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})});