- 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
965 lines
32 KiB
Python
Executable file
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()
|