argus/bin/argus
Zeus 8eff3e548b feat: add repo watching commands
- argus repo list: show watched repositories
- argus repo watch <owner/repo>: subscribe to notifications
- argus repo unwatch <owner/repo>: unsubscribe

Works with both gh (GitHub) and tea (Forgejo) backends.

Bump to v0.3.0
2026-02-22 11:56:01 +00:00

965 lines
32 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Argus - CLI tool for GitHub/Forgejo community awareness.
Named after Argus Panoptes, the all-seeing giant of Greek mythology.
"""
import argparse
import json
import os
import sqlite3
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
# ============================================================================
# Configuration
# ============================================================================
VERSION = "0.3.0"
DEFAULT_DB_PATH = Path.home() / ".argus" / "argus.db"
DEFAULT_PORT = 8100 # Dashboard port; datasette runs on port+1
def get_db_path() -> Path:
return Path(os.environ.get("ARGUS_DB", DEFAULT_DB_PATH))
def get_schema_path() -> Path:
# Check relative to script first (for development)
script_dir = Path(__file__).parent.parent
schema_path = script_dir / "lib" / "schema.sql"
if schema_path.exists():
return schema_path
# Fall back to installed location
return Path("/opt/argus/lib/schema.sql")
def get_dashboard_path() -> Path:
script_dir = Path(__file__).parent.parent
dashboard_path = script_dir / "dashboard" / "index.html"
if dashboard_path.exists():
return dashboard_path
return Path("/opt/argus/dashboard/index.html")
# ============================================================================
# Database
# ============================================================================
def get_db() -> sqlite3.Connection:
db_path = get_db_path()
if not db_path.exists():
print(f"Error: Database not found at {db_path}", file=sys.stderr)
print("Run 'argus init' first.", file=sys.stderr)
sys.exit(1)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
db_path = get_db_path()
db_path.parent.mkdir(parents=True, exist_ok=True)
schema_path = get_schema_path()
if not schema_path.exists():
print(f"Error: Schema not found at {schema_path}", file=sys.stderr)
sys.exit(1)
conn = sqlite3.connect(db_path)
with open(schema_path) as f:
conn.executescript(f.read())
conn.close()
print(f"✓ Initialized database at {db_path}")
# ============================================================================
# Backend Configuration
# ============================================================================
DEFAULT_BACKEND = "gh" # gh | tea
def get_backend() -> str:
return os.environ.get("ARGUS_BACKEND", DEFAULT_BACKEND)
# ============================================================================
# GitHub API (gh backend)
# ============================================================================
def gh_api(endpoint: str) -> dict:
"""Call GitHub API via gh CLI."""
try:
result = subprocess.run(
["gh", "api", endpoint],
capture_output=True,
text=True,
check=True
)
return json.loads(result.stdout)
except subprocess.CalledProcessError as e:
print(f"Error calling GitHub API: {e.stderr}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError:
print(f"Error parsing GitHub API response", file=sys.stderr)
sys.exit(1)
def gh_fetch_notifications() -> list:
"""Fetch notifications from GitHub."""
notifications = gh_api("notifications")
results = []
for notif in notifications:
repo_full = notif["repository"]["full_name"]
url = notif["subject"]["url"] or ""
# Convert API URL to web URL
web_url = url.replace("api.github.com/repos", "github.com")
web_url = web_url.replace("/pulls/", "/pull/")
results.append({
"id": notif["id"],
"repo_id": repo_full,
"reason": notif["reason"],
"title": notif["subject"]["title"],
"url": web_url,
"type": notif["subject"]["type"].lower(),
})
return results
# ============================================================================
# Forgejo API (tea backend)
# ============================================================================
def tea_cmd(args: list) -> str:
"""Run tea CLI command and return output."""
try:
result = subprocess.run(
["tea"] + args,
capture_output=True,
text=True,
check=True
)
return result.stdout
except subprocess.CalledProcessError as e:
# Filter out the "NOTE: no gitea login" warning
stderr = e.stderr.strip()
if "token does not have" in stderr:
print(f"Error: {stderr}", file=sys.stderr)
print("Hint: Your tea token needs 'read:notification' scope.", file=sys.stderr)
else:
print(f"Error calling tea CLI: {stderr}", file=sys.stderr)
sys.exit(1)
def tea_fetch_notifications() -> list:
"""Fetch notifications from Forgejo via tea CLI."""
import yaml
output = tea_cmd(["notifications", "list", "--mine", "-o", "yaml"])
# Filter out "NOTE:" lines that tea prints to stdout (breaks YAML parsing)
lines = [l for l in output.split('\n') if not l.startswith('NOTE:')]
clean_output = '\n'.join(lines)
# Parse YAML output
try:
notifications = yaml.safe_load(clean_output) or []
except Exception as e:
print(f"Warning: Failed to parse tea output: {e}", file=sys.stderr)
notifications = []
results = []
for notif in notifications:
# tea notifications format differs from gh
repo = notif.get("repository", "")
index = notif.get("index", "")
# Construct URL from repo and issue/PR number
url = f"https://forgejo.tail593e12.ts.net/{repo}/issues/{index}" if repo and index else ""
results.append({
"id": str(notif.get("id", "")),
"repo_id": repo,
"reason": notif.get("type", "unknown"),
"title": notif.get("title", ""),
"url": url,
"type": notif.get("type", "").lower(),
})
return results
# ============================================================================
# Unified Backend Interface
# ============================================================================
def fetch_notifications(backend: str) -> list:
"""Fetch notifications using the specified backend."""
if backend == "gh":
return gh_fetch_notifications()
elif backend == "tea":
return tea_fetch_notifications()
else:
print(f"Error: Unknown backend '{backend}'. Use 'gh' or 'tea'.", file=sys.stderr)
sys.exit(1)
# ============================================================================
# Repo Watching
# ============================================================================
def gh_list_watched() -> list:
"""List watched repos via gh CLI."""
repos = gh_api("user/subscriptions?per_page=100")
return [r["full_name"] for r in repos]
def gh_watch_repo(repo: str) -> bool:
"""Watch a repo via gh CLI."""
try:
subprocess.run(
["gh", "api", "-X", "PUT", f"repos/{repo}/subscription",
"-f", "subscribed=true"],
capture_output=True, check=True
)
return True
except subprocess.CalledProcessError:
return False
def gh_unwatch_repo(repo: str) -> bool:
"""Unwatch a repo via gh CLI."""
try:
subprocess.run(
["gh", "api", "-X", "DELETE", f"repos/{repo}/subscription"],
capture_output=True, check=True
)
return True
except subprocess.CalledProcessError:
return False
def tea_list_watched() -> list:
"""List watched repos via Forgejo API."""
# tea doesn't have a direct command, use API
try:
result = subprocess.run(
["tea", "api", "GET", "user/subscriptions"],
capture_output=True, text=True, check=True
)
import json
repos = json.loads(result.stdout)
return [r["full_name"] for r in repos]
except:
# Fallback: try curl with token from tea config
return tea_list_watched_api()
def tea_list_watched_api() -> list:
"""List watched repos via direct API call."""
import yaml
config_path = Path.home() / ".config" / "tea" / "config.yml"
if not config_path.exists():
return []
with open(config_path) as f:
config = yaml.safe_load(f)
login = config.get("logins", [{}])[0]
token = login.get("token", "")
url = login.get("url", "")
if not token or not url:
return []
try:
import urllib.request
req = urllib.request.Request(
f"{url}/api/v1/user/subscriptions",
headers={"Authorization": f"token {token}"}
)
with urllib.request.urlopen(req) as resp:
import json
repos = json.loads(resp.read())
return [r["full_name"] for r in repos]
except:
return []
def tea_watch_repo(repo: str) -> bool:
"""Watch a repo via Forgejo API."""
import yaml
config_path = Path.home() / ".config" / "tea" / "config.yml"
if not config_path.exists():
return False
with open(config_path) as f:
config = yaml.safe_load(f)
login = config.get("logins", [{}])[0]
token = login.get("token", "")
url = login.get("url", "")
if not token or not url:
return False
try:
import urllib.request
req = urllib.request.Request(
f"{url}/api/v1/repos/{repo}/subscription",
method="PUT",
headers={"Authorization": f"token {token}"}
)
urllib.request.urlopen(req)
return True
except:
return False
def tea_unwatch_repo(repo: str) -> bool:
"""Unwatch a repo via Forgejo API."""
import yaml
config_path = Path.home() / ".config" / "tea" / "config.yml"
if not config_path.exists():
return False
with open(config_path) as f:
config = yaml.safe_load(f)
login = config.get("logins", [{}])[0]
token = login.get("token", "")
url = login.get("url", "")
if not token or not url:
return False
try:
import urllib.request
req = urllib.request.Request(
f"{url}/api/v1/repos/{repo}/subscription",
method="DELETE",
headers={"Authorization": f"token {token}"}
)
urllib.request.urlopen(req)
return True
except:
return False
def list_watched_repos(backend: str) -> list:
"""List watched repos using the specified backend."""
if backend == "gh":
return gh_list_watched()
elif backend == "tea":
return tea_list_watched()
return []
def watch_repo(backend: str, repo: str) -> bool:
"""Watch a repo using the specified backend."""
if backend == "gh":
return gh_watch_repo(repo)
elif backend == "tea":
return tea_watch_repo(repo)
return False
def unwatch_repo(backend: str, repo: str) -> bool:
"""Unwatch a repo using the specified backend."""
if backend == "gh":
return gh_unwatch_repo(repo)
elif backend == "tea":
return tea_unwatch_repo(repo)
return False
# ============================================================================
# Commands: Core
# ============================================================================
def cmd_init(args) -> None:
"""Initialize database."""
init_db()
def cmd_version(args) -> None:
"""Show version."""
print(f"argus {VERSION}")
def cmd_status(args) -> None:
"""Show status summary."""
conn = get_db()
# Pending notifications
pending = conn.execute(
"SELECT COUNT(*) FROM notifications WHERE dismissed = 0 AND acted_at IS NULL"
).fetchone()[0]
# Recent activity (last 24h)
yesterday = (datetime.now() - timedelta(days=1)).isoformat()
recent_activity = conn.execute(
"SELECT COUNT(*) FROM activity_log WHERE timestamp > ?",
(yesterday,)
).fetchone()[0]
# Open escalations
open_escalations = conn.execute(
"SELECT COUNT(*) FROM escalations WHERE status = 'open'"
).fetchone()[0]
# Last poll
last_poll = conn.execute(
"SELECT polled_at, new_count FROM notification_polls ORDER BY id DESC LIMIT 1"
).fetchone()
print("=" * 50)
print("ARGUS STATUS")
print("=" * 50)
print(f"Pending notifications: {pending}")
print(f"Activity (24h): {recent_activity}")
print(f"Open escalations: {open_escalations}")
if last_poll:
print(f"Last poll: {last_poll['polled_at']} ({last_poll['new_count']} new)")
else:
print("Last poll: Never")
print("=" * 50)
conn.close()
# ============================================================================
# Commands: Repos
# ============================================================================
def cmd_repo_list(args) -> None:
"""List watched repositories."""
backend = getattr(args, 'backend', None) or get_backend()
repos = list_watched_repos(backend)
if not repos:
print(f"No watched repositories (backend: {backend})")
return
print(f"Watched repositories ({backend}):")
for repo in sorted(repos):
print(f"{repo}")
print(f"\nTotal: {len(repos)}")
def cmd_repo_watch(args) -> None:
"""Watch a repository."""
backend = getattr(args, 'backend', None) or get_backend()
repo = args.repo
if "/" not in repo:
print(f"Error: Invalid repo format. Use 'owner/repo'.", file=sys.stderr)
sys.exit(1)
if watch_repo(backend, repo):
print(f"✓ Now watching: {repo}")
else:
print(f"✗ Failed to watch: {repo}", file=sys.stderr)
sys.exit(1)
def cmd_repo_unwatch(args) -> None:
"""Unwatch a repository."""
backend = getattr(args, 'backend', None) or get_backend()
repo = args.repo
if "/" not in repo:
print(f"Error: Invalid repo format. Use 'owner/repo'.", file=sys.stderr)
sys.exit(1)
if unwatch_repo(backend, repo):
print(f"✓ Unwatched: {repo}")
else:
print(f"✗ Failed to unwatch: {repo}", file=sys.stderr)
sys.exit(1)
# ============================================================================
# Commands: Notifications
# ============================================================================
def cmd_notif_pull(args) -> None:
"""Pull notifications from GitHub/Forgejo."""
conn = get_db()
# Determine backend
backend = getattr(args, 'backend', None) or get_backend()
print(f"Using backend: {backend}")
# Fetch notifications using the appropriate backend
notifications = fetch_notifications(backend)
new_count = 0
for notif in notifications:
notif_id = notif["id"]
# Check if already exists
existing = conn.execute(
"SELECT id FROM notifications WHERE id = ?",
(notif_id,)
).fetchone()
if existing:
continue
if args.dry_run:
print(f"[DRY-RUN] Would add: {notif['title']} ({notif['reason']})")
new_count += 1
continue
conn.execute("""
INSERT INTO notifications (id, repo_id, reason, title, url, seen_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (notif_id, notif["repo_id"], notif["reason"], notif["title"],
notif["url"], datetime.now().isoformat()))
new_count += 1
if not args.dry_run:
# Record poll
conn.execute(
"INSERT INTO notification_polls (new_count) VALUES (?)",
(new_count,)
)
conn.commit()
conn.close()
if args.dry_run:
print(f"\n[DRY-RUN] Would add {new_count} notifications")
else:
print(f"✓ Pulled {new_count} new notifications")
def cmd_notif_list(args) -> None:
"""List notifications."""
conn = get_db()
query = "SELECT * FROM notifications"
conditions = []
params = []
if args.pending:
conditions.append("dismissed = 0 AND acted_at IS NULL")
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY seen_at DESC"
if args.limit:
query += f" LIMIT {args.limit}"
notifications = conn.execute(query, params).fetchall()
if not notifications:
print("No notifications found.")
return
for n in notifications:
status = "" if n["acted_at"] else ("" if n["dismissed"] else "")
print(f"{status} [{n['id'][:8]}] {n['title'][:60]}")
print(f" Repo: {n['repo_id']} | Reason: {n['reason']}")
if n["url"]:
print(f" URL: {n['url']}")
print()
conn.close()
def cmd_notif_act(args) -> None:
"""Mark notification as acted on."""
conn = get_db()
# Find notification by prefix match
notif = conn.execute(
"SELECT * FROM notifications WHERE id LIKE ?",
(f"{args.id}%",)
).fetchone()
if not notif:
print(f"Error: Notification not found: {args.id}", file=sys.stderr)
sys.exit(1)
action = args.action
detail = args.detail or ""
conn.execute("""
UPDATE notifications
SET acted_at = ?, action_taken = ?
WHERE id = ?
""", (datetime.now().isoformat(), f"{action}: {detail}".strip(": "), notif["id"]))
# Log the activity
conn.execute("""
INSERT INTO activity_log (issue_id, action, detail, github_url)
VALUES (?, ?, ?, ?)
""", (notif.get("issue_id"), action, detail or notif["title"], notif.get("url")))
conn.commit()
conn.close()
print(f"✓ Marked notification as acted: {action}")
def cmd_notif_dismiss(args) -> None:
"""Dismiss notification."""
conn = get_db()
# Find notification by prefix match
notif = conn.execute(
"SELECT * FROM notifications WHERE id LIKE ?",
(f"{args.id}%",)
).fetchone()
if not notif:
print(f"Error: Notification not found: {args.id}", file=sys.stderr)
sys.exit(1)
reason = args.reason or "dismissed"
conn.execute("""
UPDATE notifications
SET dismissed = 1, action_taken = ?
WHERE id = ?
""", (f"dismissed: {reason}", notif["id"]))
conn.commit()
conn.close()
print(f"✓ Dismissed notification: {reason}")
def cmd_notif_audit(args) -> None:
"""Audit stale notifications and create escalations."""
conn = get_db()
stale_hours = args.stale_hours or 48
cutoff = (datetime.now() - timedelta(hours=stale_hours)).isoformat()
stale = conn.execute("""
SELECT * FROM notifications
WHERE dismissed = 0 AND acted_at IS NULL AND seen_at < ?
""", (cutoff,)).fetchall()
if not stale:
print("✓ No stale notifications found")
return
print(f"Found {len(stale)} stale notifications (>{stale_hours}h):")
for n in stale:
print(f" - {n['title'][:50]}...")
# Create escalation
conn.execute("""
INSERT INTO escalations (category, title, detail)
VALUES (?, ?, ?)
""", (
"stale-notification",
f"Stale: {n['title'][:100]}",
f"Notification {n['id']} has been pending for >{stale_hours}h.\nURL: {n.get('url', 'N/A')}"
))
conn.commit()
conn.close()
print(f"\n✓ Created {len(stale)} escalations")
# ============================================================================
# Commands: Activity
# ============================================================================
def cmd_activity_log(args) -> None:
"""Log an activity."""
conn = get_db()
conn.execute("""
INSERT INTO activity_log (issue_id, action, detail)
VALUES (?, ?, ?)
""", (args.issue, args.action, args.detail))
conn.commit()
conn.close()
print(f"✓ Logged: {args.action}")
def cmd_activity_list(args) -> None:
"""List recent activity."""
conn = get_db()
limit = args.limit or 20
activities = conn.execute("""
SELECT * FROM activity_log
ORDER BY timestamp DESC
LIMIT ?
""", (limit,)).fetchall()
if not activities:
print("No activity found.")
return
for a in activities:
ts = a["timestamp"][:16].replace("T", " ")
issue = f"[{a['issue_id']}] " if a["issue_id"] else ""
print(f"{ts} | {issue}{a['action']}: {a['detail'] or ''}")
conn.close()
# ============================================================================
# Commands: Escalations
# ============================================================================
def cmd_escalate_create(args) -> None:
"""Create an escalation."""
conn = get_db()
conn.execute("""
INSERT INTO escalations (category, priority, title, detail)
VALUES (?, ?, ?, ?)
""", (args.category, args.priority or "normal", args.title, args.detail))
conn.commit()
conn.close()
print(f"✓ Created escalation: {args.title}")
def cmd_escalate_list(args) -> None:
"""List escalations."""
conn = get_db()
query = "SELECT * FROM escalations"
params = []
if args.status:
query += " WHERE status = ?"
params.append(args.status)
query += " ORDER BY created_at DESC"
escalations = conn.execute(query, params).fetchall()
if not escalations:
print("No escalations found.")
return
for e in escalations:
status_icon = "🔴" if e["status"] == "open" else ""
priority_icon = "⚠️" if e["priority"] == "high" else ""
print(f"{status_icon} [{e['id']}] {priority_icon}{e['title']}")
print(f" Category: {e['category']} | Created: {e['created_at'][:10]}")
if e["status"] == "resolved":
print(f" Resolved by: {e['resolved_by']} | {e['resolution']}")
print()
conn.close()
def cmd_escalate_ack(args) -> None:
"""Acknowledge an escalation."""
conn = get_db()
conn.execute("""
UPDATE escalations
SET status = 'acknowledged'
WHERE id = ?
""", (args.id,))
if conn.total_changes == 0:
print(f"Error: Escalation not found: {args.id}", file=sys.stderr)
sys.exit(1)
conn.commit()
conn.close()
print(f"✓ Acknowledged escalation #{args.id}")
def cmd_escalate_resolve(args) -> None:
"""Resolve an escalation."""
conn = get_db()
conn.execute("""
UPDATE escalations
SET status = 'resolved', resolved_at = ?, resolved_by = ?, resolution = ?
WHERE id = ?
""", (datetime.now().isoformat(), args.by, args.resolution, args.id))
if conn.total_changes == 0:
print(f"Error: Escalation not found: {args.id}", file=sys.stderr)
sys.exit(1)
conn.commit()
conn.close()
print(f"✓ Resolved escalation #{args.id}")
# ============================================================================
# Commands: Serve
# ============================================================================
def cmd_serve(args) -> None:
"""Start datasette + dashboard servers."""
import http.server
db_path = get_db_path()
dashboard_path = get_dashboard_path()
# Single port config: dashboard on port, datasette on port+1
port = args.port or int(os.environ.get("ARGUS_PORT", DEFAULT_PORT))
datasette_port = port + 1
print(f"Starting Argus servers...")
print(f" Dashboard: http://localhost:{port}")
print(f" Datasette: http://localhost:{datasette_port}")
print()
# Start datasette in background (bind to all interfaces for remote access)
datasette_proc = subprocess.Popen(
["datasette", str(db_path), "-h", "0.0.0.0", "-p", str(datasette_port), "--cors"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
# Custom handler that injects the datasette port into the dashboard
class ArgusHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path == "/" or self.path == "/index.html":
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
with open(dashboard_path) as f:
html = f.read()
# Inject datasette port config
html = html.replace(
"const API_BASE = 'http://localhost:8100'",
f"const API_BASE = 'http://localhost:{datasette_port}'"
)
self.wfile.write(html.encode())
else:
super().do_GET()
os.chdir(dashboard_path.parent)
with http.server.HTTPServer(("", port), ArgusHandler) as httpd:
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...")
datasette_proc.terminate()
# ============================================================================
# Main
# ============================================================================
def main():
parser = argparse.ArgumentParser(
prog="argus",
description="CLI tool for GitHub/Forgejo community awareness"
)
subparsers = parser.add_subparsers(dest="command", help="Commands")
# Core commands
subparsers.add_parser("init", help="Initialize database")
subparsers.add_parser("version", help="Show version")
subparsers.add_parser("status", help="Show status summary")
# Repo commands
repo_parser = subparsers.add_parser("repo", help="Repository watching")
repo_sub = repo_parser.add_subparsers(dest="repo_cmd")
repo_list_parser = repo_sub.add_parser("list", help="List watched repositories")
repo_list_parser.add_argument("--backend", choices=["gh", "tea"], help="Backend: gh (GitHub) or tea (Forgejo)")
repo_watch_parser = repo_sub.add_parser("watch", help="Watch a repository")
repo_watch_parser.add_argument("repo", help="Repository (owner/repo)")
repo_watch_parser.add_argument("--backend", choices=["gh", "tea"], help="Backend: gh (GitHub) or tea (Forgejo)")
repo_unwatch_parser = repo_sub.add_parser("unwatch", help="Unwatch a repository")
repo_unwatch_parser.add_argument("repo", help="Repository (owner/repo)")
repo_unwatch_parser.add_argument("--backend", choices=["gh", "tea"], help="Backend: gh (GitHub) or tea (Forgejo)")
# Notification commands
notif_parser = subparsers.add_parser("notif", help="Notification management")
notif_sub = notif_parser.add_subparsers(dest="notif_cmd")
pull_parser = notif_sub.add_parser("pull", help="Pull notifications from GitHub/Forgejo")
pull_parser.add_argument("--dry-run", action="store_true", help="Show what would be pulled")
pull_parser.add_argument("--backend", choices=["gh", "tea"], help="Backend: gh (GitHub) or tea (Forgejo)")
list_parser = notif_sub.add_parser("list", help="List notifications")
list_parser.add_argument("--pending", action="store_true", help="Show only pending")
list_parser.add_argument("--limit", type=int, help="Limit results")
act_parser = notif_sub.add_parser("act", help="Mark as acted on")
act_parser.add_argument("id", help="Notification ID (prefix match)")
act_parser.add_argument("action", help="Action taken")
act_parser.add_argument("detail", nargs="?", help="Additional detail")
dismiss_parser = notif_sub.add_parser("dismiss", help="Dismiss notification")
dismiss_parser.add_argument("id", help="Notification ID (prefix match)")
dismiss_parser.add_argument("reason", nargs="?", help="Dismissal reason")
audit_parser = notif_sub.add_parser("audit", help="Audit stale notifications")
audit_parser.add_argument("--stale-hours", type=int, help="Hours to consider stale (default: 48)")
# Activity commands
activity_parser = subparsers.add_parser("activity", help="Activity logging")
activity_sub = activity_parser.add_subparsers(dest="activity_cmd")
log_parser = activity_sub.add_parser("log", help="Log an activity")
log_parser.add_argument("action", help="Action type")
log_parser.add_argument("detail", help="Action detail")
log_parser.add_argument("--issue", help="Related issue ID")
list_activity_parser = activity_sub.add_parser("list", help="List recent activity")
list_activity_parser.add_argument("--limit", type=int, help="Limit results")
# Escalation commands
escalate_parser = subparsers.add_parser("escalate", help="Escalation management")
escalate_sub = escalate_parser.add_subparsers(dest="escalate_cmd")
create_parser = escalate_sub.add_parser("create", help="Create escalation")
create_parser.add_argument("--category", required=True, help="Escalation category")
create_parser.add_argument("--title", required=True, help="Escalation title")
create_parser.add_argument("--detail", help="Additional detail")
create_parser.add_argument("--priority", choices=["low", "normal", "high"], help="Priority")
list_esc_parser = escalate_sub.add_parser("list", help="List escalations")
list_esc_parser.add_argument("--status", choices=["open", "acknowledged", "resolved"], help="Filter by status")
ack_parser = escalate_sub.add_parser("ack", help="Acknowledge escalation")
ack_parser.add_argument("id", type=int, help="Escalation ID")
resolve_parser = escalate_sub.add_parser("resolve", help="Resolve escalation")
resolve_parser.add_argument("id", type=int, help="Escalation ID")
resolve_parser.add_argument("--by", required=True, help="Who resolved")
resolve_parser.add_argument("--resolution", required=True, help="Resolution description")
# Serve command
serve_parser = subparsers.add_parser("serve", help="Start servers")
serve_parser.add_argument("--port", type=int, help=f"Dashboard port (default: {DEFAULT_PORT}); datasette runs on port+1")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(0)
# Route to command handlers
if args.command == "init":
cmd_init(args)
elif args.command == "version":
cmd_version(args)
elif args.command == "status":
cmd_status(args)
elif args.command == "repo":
if args.repo_cmd == "list":
cmd_repo_list(args)
elif args.repo_cmd == "watch":
cmd_repo_watch(args)
elif args.repo_cmd == "unwatch":
cmd_repo_unwatch(args)
else:
repo_parser.print_help()
elif args.command == "notif":
if args.notif_cmd == "pull":
cmd_notif_pull(args)
elif args.notif_cmd == "list":
cmd_notif_list(args)
elif args.notif_cmd == "act":
cmd_notif_act(args)
elif args.notif_cmd == "dismiss":
cmd_notif_dismiss(args)
elif args.notif_cmd == "audit":
cmd_notif_audit(args)
else:
notif_parser.print_help()
elif args.command == "activity":
if args.activity_cmd == "log":
cmd_activity_log(args)
elif args.activity_cmd == "list":
cmd_activity_list(args)
else:
activity_parser.print_help()
elif args.command == "escalate":
if args.escalate_cmd == "create":
cmd_escalate_create(args)
elif args.escalate_cmd == "list":
cmd_escalate_list(args)
elif args.escalate_cmd == "ack":
cmd_escalate_ack(args)
elif args.escalate_cmd == "resolve":
cmd_escalate_resolve(args)
else:
escalate_parser.print_help()
elif args.command == "serve":
cmd_serve(args)
else:
parser.print_help()
if __name__ == "__main__":
main()