commit 90361fbe16a44ec84342e0367609f5a1c9f0ff82 Author: Zeus Date: Sun Feb 22 10:25:46 2026 +0000 feat: initial Argus implementation - CLI with notification, activity, and escalation commands - SQLite database with schema - Datasette + static HTML dashboard - Systemd service for background operation - Install/uninstall scripts - SKILL.md and procedures for sub-agent usage diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..43955ba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-02-22 + +### Added +- Initial release +- CLI with notification, activity, and escalation commands +- SQLite database for persistent state +- Datasette integration for API access +- Static HTML dashboard +- Systemd service for background operation +- Install/uninstall scripts diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3b388de --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zeus @ Olymp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0c8be8 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Argus šŸ‘ļø + +CLI tool for GitHub/Forgejo community awareness — tracking notifications, logging activity, managing escalations. + +Named after **Argus Panoptes**, the all-seeing giant of Greek mythology. + +## Features + +- **Notification triage** — Pull, act, or dismiss GitHub notifications +- **Activity logging** — Track community actions +- **Escalation management** — Flag and track issues needing attention +- **Dashboard** — Visual overview via Datasette + static HTML +- **Systemd service** — Run in background + +## Quick Start + +```bash +# Install +./scripts/install.sh + +# Initialize database +argus init + +# Set up GitHub token +export GH_TOKEN=your_github_token + +# Pull notifications +argus notif pull + +# Check status +argus status + +# Start dashboard +systemctl start argus +``` + +## Commands + +### Core + +| Command | Description | +|---------|-------------| +| `argus init` | Create database | +| `argus version` | Show version | +| `argus status` | Summary overview | + +### Notifications + +| Command | Description | +|---------|-------------| +| `argus notif pull [--dry-run]` | Fetch from GitHub | +| `argus notif list [--pending]` | List notifications | +| `argus notif act ` | Mark as acted | +| `argus notif dismiss [reason]` | Dismiss | +| `argus notif audit` | Auto-escalate stale | + +### Activity + +| Command | Description | +|---------|-------------| +| `argus activity log ` | Log action | +| `argus activity list` | Show recent | + +### Escalations + +| Command | Description | +|---------|-------------| +| `argus escalate create --category --title` | Create | +| `argus escalate list [--status]` | List | +| `argus escalate ack ` | Acknowledge | +| `argus escalate resolve --by --resolution` | Resolve | + +### Service + +| Command | Description | +|---------|-------------| +| `argus serve` | Start Datasette + dashboard | + +## Configuration + +Environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `ARGUS_DB` | `~/.argus/argus.db` | Database path | +| `ARGUS_PORT` | `8100` | Datasette API port | +| `ARGUS_DASHBOARD_PORT` | `8101` | Dashboard port | +| `GH_TOKEN` | — | GitHub API token | + +## Architecture + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ argus CLI │ +│ notif pull | activity log | escalate | serve │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ ~/.argus/argus.db │ +│ repos | issues | notifications | activity │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + ā–¼ ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Datasette │ │ Dashboard │ +│ :8100 (API) │ │ :8101 (HTML) │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +## Installation + +### Prerequisites + +- Python 3.9+ +- pip +- gh CLI (for GitHub integration) + +### Install + +```bash +git clone https://forgejo.tail593e12.ts.net/Zeus/argus.git /opt/argus +cd /opt/argus && ./scripts/install.sh +``` + +### Uninstall + +```bash +/opt/argus/scripts/uninstall.sh +``` + +## Systemd Service + +```bash +systemctl enable argus # Start on boot +systemctl start argus # Start now +systemctl status argus # Check status +journalctl -u argus -f # View logs +``` + +## License + +MIT + +## Author + +Zeus @ Olymp diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/bin/argus b/bin/argus new file mode 100755 index 0000000..d3fdf0b --- /dev/null +++ b/bin/argus @@ -0,0 +1,635 @@ +#!/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.1.0" +DEFAULT_DB_PATH = Path.home() / ".argus" / "argus.db" +DEFAULT_PORT = 8100 +DEFAULT_DASHBOARD_PORT = 8101 + +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}") + +# ============================================================================ +# GitHub API +# ============================================================================ + +def get_gh_token() -> Optional[str]: + return os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + +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) + +# ============================================================================ +# 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: Notifications +# ============================================================================ + +def cmd_notif_pull(args) -> None: + """Pull notifications from GitHub.""" + conn = get_db() + + # Fetch notifications from GitHub + notifications = gh_api("notifications") + + 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 + + # Extract repo info + repo_full = notif["repository"]["full_name"] + reason = notif["reason"] + title = notif["subject"]["title"] + 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/") + + if args.dry_run: + print(f"[DRY-RUN] Would add: {title} ({reason})") + new_count += 1 + continue + + conn.execute(""" + INSERT INTO notifications (id, repo_id, reason, title, url, seen_at) + VALUES (?, ?, ?, ?, ?, ?) + """, (notif_id, repo_full, reason, title, web_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 + import threading + + db_path = get_db_path() + dashboard_path = get_dashboard_path() + + port = args.port or int(os.environ.get("ARGUS_PORT", DEFAULT_PORT)) + dashboard_port = args.dashboard_port or int(os.environ.get("ARGUS_DASHBOARD_PORT", DEFAULT_DASHBOARD_PORT)) + + print(f"Starting Argus servers...") + print(f" Datasette: http://localhost:{port}") + print(f" Dashboard: http://localhost:{dashboard_port}") + print() + + # Start datasette in background + datasette_proc = subprocess.Popen( + ["datasette", str(db_path), "-p", str(port), "--cors"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + # Start simple HTTP server for dashboard + os.chdir(dashboard_path.parent) + + handler = http.server.SimpleHTTPRequestHandler + with http.server.HTTPServer(("", dashboard_port), handler) 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") + + # 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") + pull_parser.add_argument("--dry-run", action="store_true", help="Show what would be pulled") + + 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"Datasette port (default: {DEFAULT_PORT})") + serve_parser.add_argument("--dashboard-port", type=int, help=f"Dashboard port (default: {DEFAULT_DASHBOARD_PORT})") + + 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 == "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() diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..44e429c --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,349 @@ + + + + + + Argus Dashboard + + + +

šŸ‘ļø Argus Dashboard

+ + + +
+
+

Pending Notifications

+
-
+
Awaiting action
+
+
+

Activity (24h)

+
-
+
Actions logged
+
+
+

Open Escalations

+
-
+
Need attention
+
+
+ +
+
+

+ Recent Notifications + +

+
+
Loading...
+
+
+
+ +
+
+

Recent Activity

+
+
Loading...
+
+
+
+ +
+
+

Open Escalations

+
+
Loading...
+
+
+
+ + + + diff --git a/docs/SKILL.md b/docs/SKILL.md new file mode 100644 index 0000000..bcff9af --- /dev/null +++ b/docs/SKILL.md @@ -0,0 +1,97 @@ +--- +name: argus +description: "GitHub/Forgejo community awareness tool. TRIGGERS: notifications, community triage, PR reviews, issue tracking, escalations. Use when managing GitHub notifications, logging community actions, or tracking escalations." +--- + +# Argus Skill + +CLI tool for GitHub/Forgejo community awareness — tracking notifications, logging activity, managing escalations. + +## Quick Start + +```bash +# Initialize database +argus init + +# Pull notifications from GitHub +argus notif pull + +# View status +argus status + +# Start dashboard +argus serve +``` + +## Commands + +### Status & Info + +```bash +argus version # Show version +argus status # Summary: pending, activity, escalations +``` + +### Notifications + +```bash +argus notif pull [--dry-run] # Fetch from GitHub API +argus notif list [--pending] # List notifications +argus notif act # Mark as acted +argus notif dismiss [reason] # Dismiss +argus notif audit [--stale-hours] # Auto-escalate stale +``` + +### Activity Logging + +```bash +argus activity log [--issue ID] +argus activity list [--limit N] +``` + +### Escalations + +```bash +argus escalate create --category CAT --title "..." +argus escalate list [--status open] +argus escalate ack +argus escalate resolve --by WHO --resolution "..." +``` + +### Service + +```bash +argus serve [--port 8100] [--dashboard-port 8101] +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ARGUS_DB` | `~/.argus/argus.db` | Database path | +| `ARGUS_PORT` | `8100` | Datasette port | +| `ARGUS_DASHBOARD_PORT` | `8101` | Dashboard port | +| `GH_TOKEN` | — | GitHub API token | + +## Workflow + +1. **Pull notifications:** `argus notif pull` +2. **Review pending:** `argus notif list --pending` +3. **Act or dismiss:** `argus notif act reviewed` or `argus notif dismiss duplicate` +4. **Log activity:** `argus activity log commented "Answered question"` +5. **Check dashboard:** Open http://localhost:8101 + +## Sub-Agent Usage + +For automated community management: + +``` +Read notifications with argus notif list --pending. +For each notification: +- If PR: review changes, comment if needed +- If issue: assess priority, respond or escalate +- Log action with argus activity log +After processing: argus notif audit +``` + +See `procedures/subagent-completion.md` for detailed patterns. diff --git a/docs/procedures/subagent-completion.md b/docs/procedures/subagent-completion.md new file mode 100644 index 0000000..9f96dd5 --- /dev/null +++ b/docs/procedures/subagent-completion.md @@ -0,0 +1,120 @@ +# Argus Sub-Agent Procedures + +Standard procedures for AI agents performing community management tasks. + +## Notification Triage + +### Input +- Pending notifications from `argus notif list --pending` + +### Process + +1. **Classify** each notification: + - `review-request`: Someone requested your review + - `mention`: You were mentioned + - `assign`: Issue/PR was assigned to you + - `comment`: New comment on subscribed thread + - `ci-failure`: CI/CD failure notification + +2. **Prioritize**: + - P1 (immediate): Security issues, prod failures + - P2 (same day): Review requests, direct mentions + - P3 (when possible): General comments, updates + +3. **Act**: + - For each notification, either: + - Take action (review, respond, fix) + - Escalate (create escalation) + - Dismiss (with reason) + +### Output + +```bash +# After processing each notification +argus notif act "" + +# Or dismiss +argus notif dismiss "" + +# Log activity +argus activity log "" [--issue ] +``` + +## Pull Request Review + +### Input +- PR notification or explicit request + +### Process + +1. **Read PR**: Check title, description, changes +2. **Assess scope**: Is this a minor fix or major change? +3. **Review code**: Look for issues, suggest improvements +4. **Leave feedback**: Comment on GitHub +5. **Log action**: Record what you did + +### Output + +```bash +argus notif act reviewed "Approved with comments" +argus activity log pr-review "Reviewed PR #123: Added caching layer" +``` + +## Escalation Handling + +### When to Escalate + +- Notification pending > 48 hours +- Security-related issues +- Unclear requirements needing human decision +- Permission/access issues + +### Process + +```bash +# Create escalation +argus escalate create \ + --category "stale-notification" \ + --title "PR #123 needs maintainer input" \ + --priority high + +# After human acknowledges +argus escalate ack + +# After resolution +argus escalate resolve \ + --by "k9ert" \ + --resolution "Merged after fixes" +``` + +## Daily Routine + +Suggested daily workflow for automated community management: + +```bash +# Morning +argus notif pull +argus status +argus notif list --pending --limit 20 + +# Process notifications (triage, respond, escalate) +# ... actions ... + +# End of day +argus notif audit --stale-hours 48 +argus activity list --limit 10 +``` + +## Reporting + +For summaries: + +```sql +-- Via datasette or direct SQL +SELECT action, COUNT(*) +FROM activity_log +WHERE timestamp > date('now', '-7 days') +GROUP BY action; +``` + +Dashboard at http://localhost:8101 provides visual overview. diff --git a/lib/schema.sql b/lib/schema.sql new file mode 100644 index 0000000..693bcf3 --- /dev/null +++ b/lib/schema.sql @@ -0,0 +1,81 @@ +-- Argus Database Schema +-- Version: 0.1.0 + +-- Repositories being tracked +CREATE TABLE IF NOT EXISTS repos ( + id TEXT PRIMARY KEY, -- owner/repo + description TEXT, + is_primary INTEGER DEFAULT 0, + last_synced_at TEXT +); + +-- Issues and PRs +CREATE TABLE IF NOT EXISTS issues ( + id TEXT PRIMARY KEY, -- owner/repo#number + repo_id TEXT NOT NULL, + number INTEGER NOT NULL, + type TEXT NOT NULL, -- issue | pr + title TEXT NOT NULL, + state TEXT NOT NULL, -- open | closed | merged + author TEXT, + labels TEXT DEFAULT '[]', + created_at TEXT, + updated_at TEXT, + FOREIGN KEY (repo_id) REFERENCES repos(id) +); + +-- GitHub notifications +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + issue_id TEXT, + repo_id TEXT, + reason TEXT, + title TEXT, + url TEXT, + seen_at TEXT, + acted_at TEXT, + action_taken TEXT, + dismissed INTEGER DEFAULT 0, + FOREIGN KEY (issue_id) REFERENCES issues(id), + FOREIGN KEY (repo_id) REFERENCES repos(id) +); + +-- Activity log +CREATE TABLE IF NOT EXISTS activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT DEFAULT (datetime('now')), + issue_id TEXT, + action TEXT NOT NULL, + detail TEXT, + github_url TEXT, + FOREIGN KEY (issue_id) REFERENCES issues(id) +); + +-- Escalations +CREATE TABLE IF NOT EXISTS escalations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT DEFAULT (datetime('now')), + category TEXT NOT NULL, + priority TEXT DEFAULT 'normal', + title TEXT NOT NULL, + detail TEXT, + status TEXT DEFAULT 'open', + resolved_at TEXT, + resolved_by TEXT, + resolution TEXT +); + +-- Poll history +CREATE TABLE IF NOT EXISTS notification_polls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + polled_at TEXT DEFAULT (datetime('now')), + new_count INTEGER DEFAULT 0 +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_notifications_dismissed ON notifications(dismissed); +CREATE INDEX IF NOT EXISTS idx_notifications_acted ON notifications(acted_at); +CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity_log(timestamp); +CREATE INDEX IF NOT EXISTS idx_escalations_status ON escalations(status); +CREATE INDEX IF NOT EXISTS idx_issues_state ON issues(state); +CREATE INDEX IF NOT EXISTS idx_issues_repo ON issues(repo_id); diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..077f087 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# +# Argus Installation Script +# +set -e + +INSTALL_DIR="/opt/argus" +SKILL_LINK="/olymp/shared/skills/argus" + +echo "╔════════════════════════════════════════╗" +echo "ā•‘ Argus Installation ā•‘" +echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" +echo + +# Check dependencies +echo "Checking dependencies..." + +if ! command -v python3 &> /dev/null; then + echo "Error: python3 is required but not installed." + exit 1 +fi + +if ! command -v gh &> /dev/null; then + echo "Warning: gh CLI not found. 'argus notif pull' will not work." +fi + +# Install datasette if not present +if ! command -v datasette &> /dev/null; then + echo "Installing datasette..." + pip3 install datasette --quiet +fi + +# Clone or update repo +if [ -d "$INSTALL_DIR" ]; then + echo "Updating existing installation..." + cd "$INSTALL_DIR" + git pull --quiet +else + echo "Installing to $INSTALL_DIR..." + # If running from repo directory + if [ -f "bin/argus" ]; then + cp -r . "$INSTALL_DIR" + else + echo "Error: Run this script from the argus repository directory." + exit 1 + fi +fi + +# Create symlink for CLI +echo "Creating CLI symlink..." +ln -sf "$INSTALL_DIR/bin/argus" /usr/local/bin/argus + +# Install systemd service +echo "Installing systemd service..." +cp "$INSTALL_DIR/systemd/argus.service" /etc/systemd/system/ +systemctl daemon-reload + +# Create skill symlink if /olymp/shared/skills exists +if [ -d "/olymp/shared/skills" ]; then + echo "Creating skill symlink..." + ln -sf "$INSTALL_DIR/docs" "$SKILL_LINK" +fi + +# Initialize database if not exists +if [ ! -f "$HOME/.argus/argus.db" ]; then + echo "Initializing database..." + argus init +fi + +echo +echo "āœ“ Installation complete!" +echo +echo "Next steps:" +echo " 1. Set up GitHub token: export GH_TOKEN=your_token" +echo " 2. Pull notifications: argus notif pull" +echo " 3. Start dashboard: systemctl start argus" +echo +echo "Dashboard will be available at:" +echo " - Datasette API: http://localhost:8100" +echo " - Dashboard: http://localhost:8101" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..d279b06 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# +# Argus Uninstallation Script +# +set -e + +INSTALL_DIR="/opt/argus" +SKILL_LINK="/olymp/shared/skills/argus" + +echo "╔════════════════════════════════════════╗" +echo "ā•‘ Argus Uninstallation ā•‘" +echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" +echo + +# Stop and disable service +echo "Stopping argus service..." +systemctl stop argus 2>/dev/null || true +systemctl disable argus 2>/dev/null || true + +# Remove systemd service +echo "Removing systemd service..." +rm -f /etc/systemd/system/argus.service +systemctl daemon-reload + +# Remove CLI symlink +echo "Removing CLI symlink..." +rm -f /usr/local/bin/argus + +# Remove skill symlink +echo "Removing skill symlink..." +rm -f "$SKILL_LINK" + +# Remove installation directory +echo "Removing installation directory..." +rm -rf "$INSTALL_DIR" + +echo +echo "āœ“ Uninstallation complete!" +echo +echo "Note: User data at ~/.argus/ was preserved." +echo "To remove it: rm -rf ~/.argus" +echo +echo "Datasette was not uninstalled." +echo "To remove it: pip3 uninstall datasette" diff --git a/systemd/argus.service b/systemd/argus.service new file mode 100644 index 0000000..c60baa2 --- /dev/null +++ b/systemd/argus.service @@ -0,0 +1,15 @@ +[Unit] +Description=Argus Community Dashboard +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/argus serve +Restart=on-failure +RestartSec=5 +Environment=ARGUS_DB=/root/.argus/argus.db +Environment=ARGUS_PORT=8100 +Environment=ARGUS_DASHBOARD_PORT=8101 + +[Install] +WantedBy=multi-user.target