From 091e17c55bcfef844a268c98a50de3c39188de01 Mon Sep 17 00:00:00 2001 From: Nazim Date: Thu, 19 Mar 2026 14:22:15 +0000 Subject: [PATCH 1/7] feat: smart notification filtering with reasons, own-PR detection, pagination - Add filter_rules table for configurable dismiss/keep rules - Batch own-PR lookup via GitHub search API (1 call vs N) - CI status check only for own PRs (targeted, not blanket) - filter_reason column on every notification explaining why kept/dismissed - --no-filter flag to bypass filtering - notif filter list/add/rm subcommands for rule management - notif list --dismissed/--own flags - gh_api --paginate support (was capped at 50) - Auto-migration for existing databases - Version bump to 0.4.0 --- bin/argus | 324 ++++++++++++++++++++++++++++++++++++++++++++++--- lib/schema.sql | 16 +++ 2 files changed, 324 insertions(+), 16 deletions(-) diff --git a/bin/argus b/bin/argus index a64cdc0..550eafa 100755 --- a/bin/argus +++ b/bin/argus @@ -19,7 +19,7 @@ from typing import Optional # Configuration # ============================================================================ -VERSION = "0.3.0" +VERSION = "0.4.0" DEFAULT_DB_PATH = Path.home() / ".argus" / "argus.db" DEFAULT_PORT = 8100 # Dashboard port; datasette runs on port+1 @@ -84,15 +84,40 @@ def get_backend() -> str: # GitHub API (gh backend) # ============================================================================ -def gh_api(endpoint: str) -> dict: +def gh_api(endpoint: str, paginate: bool = False) -> dict: """Call GitHub API via gh CLI.""" try: + cmd = ["gh", "api"] + if paginate: + cmd.append("--paginate") + cmd.append(endpoint) result = subprocess.run( - ["gh", "api", endpoint], + cmd, capture_output=True, text=True, check=True ) + # --paginate may return multiple JSON arrays concatenated: [...][\n...] + raw = result.stdout.strip() + if paginate and raw.startswith('['): + # Use JSONDecoder to parse successive arrays + decoder = json.JSONDecoder() + merged = [] + idx = 0 + while idx < len(raw): + if raw[idx] in ' \t\n\r': + idx += 1 + continue + try: + obj, end = decoder.raw_decode(raw, idx) + if isinstance(obj, list): + merged.extend(obj) + else: + merged.append(obj) + idx = end + except json.JSONDecodeError: + break + return merged return json.loads(result.stdout) except subprocess.CalledProcessError as e: print(f"Error calling GitHub API: {e.stderr}", file=sys.stderr) @@ -102,8 +127,8 @@ def gh_api(endpoint: str) -> dict: sys.exit(1) def gh_fetch_notifications() -> list: - """Fetch notifications from GitHub.""" - notifications = gh_api("notifications") + """Fetch notifications from GitHub (with pagination).""" + notifications = gh_api("notifications", paginate=True) results = [] for notif in notifications: repo_full = notif["repository"]["full_name"] @@ -119,9 +144,101 @@ def gh_fetch_notifications() -> list: "title": notif["subject"]["title"], "url": web_url, "type": notif["subject"]["type"].lower(), + "subject_url": notif["subject"]["url"] or "", }) return results + +def gh_get_own_prs(agent_user: str = "al-munazzim") -> dict: + """Fetch all open PRs by agent user. Returns {subject_api_url: pr_info}.""" + try: + results = gh_api(f"search/issues?q=author:{agent_user}+type:pr+state:open&per_page=100") + own_prs = {} + for item in results.get("items", []): + html_url = item.get("html_url", "") + if "/pull/" in html_url: + # Build the subject API URL that matches notification subject_url + api_url = html_url.replace("https://github.com/", "https://api.github.com/repos/") + api_url = api_url.replace("/pull/", "/pulls/") + own_prs[api_url] = { + "pr_number": item.get("number"), + "comments": item.get("comments", 0), + "repo": "/".join(html_url.split("/")[3:5]), + } + return own_prs + except Exception as e: + print(f"Warning: Could not fetch own PRs: {e}", file=sys.stderr) + return {} + + +def gh_check_ci_status(repo: str, pr_number: int) -> str: + """Check CI status for a specific PR.""" + try: + pr_data = gh_api(f"repos/{repo}/pulls/{pr_number}") + head_sha = pr_data.get("head", {}).get("sha", "") + if not head_sha: + return "unknown" + check_data = gh_api(f"repos/{repo}/commits/{head_sha}/check-runs") + runs = check_data.get("check_runs", []) + if not runs: + return "none" + statuses = [r.get("conclusion") or r.get("status") for r in runs] + if "failure" in statuses: + return "failing" + if all(s == "success" for s in statuses): + return "passing" + if "in_progress" in statuses or "queued" in statuses: + return "pending" + return "mixed" + except Exception: + return "unknown" + + +def apply_filter_rules(conn: sqlite3.Connection, notif: dict) -> tuple: + """Apply filter rules to a notification. Returns (dismiss: bool, reason: str).""" + # Rule 0: Own PRs with failing CI or review comments are ALWAYS kept + if notif.get("_own_pr"): + own = notif["_own_pr"] + if own.get("ci_status") == "failing": + return (False, f"KEEP: own PR #{own.get('pr_number')} has failing CI") + if own.get("review_comments", 0) > 0: + return (False, f"KEEP: own PR #{own.get('pr_number')} has {own['review_comments']} review comments") + # Own PR but no issues — still keep but note why + return (False, f"KEEP: own PR #{own.get('pr_number')} (ci: {own.get('ci_status', 'unknown')})") + + # Load DB filter rules (ordered by priority desc) + rules = conn.execute( + "SELECT * FROM filter_rules WHERE enabled = 1 ORDER BY priority DESC" + ).fetchall() + + for rule in rules: + field_val = str(notif.get(rule["field"], "")) + match = False + if rule["op"] == "eq": + match = field_val == rule["value"] + elif rule["op"] == "neq": + match = field_val != rule["value"] + elif rule["op"] == "contains": + match = rule["value"].lower() in field_val.lower() + elif rule["op"] == "matches": + import re + match = bool(re.search(rule["value"], field_val, re.IGNORECASE)) + + if match: + desc = rule["description"] or f"{rule['field']} {rule['op']} '{rule['value']}'" + if rule["action"] == "keep": + return (False, f"KEEP (rule): {desc}") + else: + return (True, f"AUTO-DISMISS (rule): {desc}") + + # Default rules (hardcoded fallbacks when no DB rules match) + # Subscribed + not own PR = noise + if notif.get("reason") == "subscribed": + return (True, "AUTO-DISMISS: reason=subscribed (mass watch noise)") + + # Keep everything else + return (False, f"KEEP: reason={notif.get('reason')}") + # ============================================================================ # Forgejo API (tea backend) # ============================================================================ @@ -448,17 +565,32 @@ def cmd_repo_unwatch(args) -> None: # ============================================================================ def cmd_notif_pull(args) -> None: - """Pull notifications from GitHub/Forgejo.""" + """Pull notifications from GitHub/Forgejo with smart filtering.""" conn = get_db() + # Ensure new columns exist (migration for existing DBs) + _migrate_filter_columns(conn) + # Determine backend backend = getattr(args, 'backend', None) or get_backend() + no_filter = getattr(args, 'no_filter', False) print(f"Using backend: {backend}") # Fetch notifications using the appropriate backend notifications = fetch_notifications(backend) + # Pre-fetch own PRs in one API call (instead of per-notification) + own_pr_index = {} + if backend == "gh": + print("Checking own PRs...") + own_pr_index = gh_get_own_prs() + if own_pr_index: + print(f" Found {len(own_pr_index)} open own PRs") + new_count = 0 + kept_count = 0 + dismissed_count = 0 + for notif in notifications: notif_id = notif["id"] @@ -471,17 +603,54 @@ def cmd_notif_pull(args) -> None: if existing: continue + # Match against pre-fetched own PRs (O(1) lookup, no API call) + own_pr = {} + subject_url = notif.get("subject_url", "") + if subject_url in own_pr_index: + own_info = own_pr_index[subject_url] + # Only fetch CI for own PRs (targeted API calls) + ci_status = gh_check_ci_status(own_info["repo"], own_info["pr_number"]) + own_pr = { + "is_own": True, + "pr_number": own_info["pr_number"], + "review_comments": own_info["comments"], + "ci_status": ci_status, + } + notif["_own_pr"] = own_pr + + # Apply filters + if no_filter: + dismiss = False + filter_reason = "KEEP: --no-filter" + else: + dismiss, filter_reason = apply_filter_rules(conn, notif) + + is_own = 1 if own_pr.get("is_own") else 0 + ci_status = own_pr.get("ci_status") + if args.dry_run: - print(f"[DRY-RUN] Would add: {notif['title']} ({notif['reason']})") + status = "DISMISS" if dismiss else "KEEP" + print(f"[DRY-RUN] [{status}] {notif['title'][:60]}") + print(f" → {filter_reason}") new_count += 1 + if dismiss: + dismissed_count += 1 + else: + kept_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())) + INSERT INTO notifications (id, repo_id, reason, title, url, type, seen_at, + dismissed, filter_reason, is_own_pr, ci_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (notif_id, notif["repo_id"], notif["reason"], notif["title"], + notif["url"], notif.get("type"), datetime.now().isoformat(), + 1 if dismiss else 0, filter_reason, is_own, ci_status)) new_count += 1 + if dismiss: + dismissed_count += 1 + else: + kept_count += 1 if not args.dry_run: # Record poll @@ -493,10 +662,39 @@ def cmd_notif_pull(args) -> None: conn.close() - if args.dry_run: - print(f"\n[DRY-RUN] Would add {new_count} notifications") - else: - print(f"✓ Pulled {new_count} new notifications") + prefix = "[DRY-RUN] " if args.dry_run else "✓ " + print(f"\n{prefix}Pulled {new_count} new ({kept_count} kept, {dismissed_count} auto-dismissed)") + + +def _migrate_filter_columns(conn: sqlite3.Connection) -> None: + """Add new columns to existing databases.""" + cursor = conn.execute("PRAGMA table_info(notifications)") + columns = {row[1] for row in cursor.fetchall()} + + migrations = { + "type": "TEXT", + "filter_reason": "TEXT", + "is_own_pr": "INTEGER DEFAULT 0", + "ci_status": "TEXT", + } + for col, typedef in migrations.items(): + if col not in columns: + conn.execute(f"ALTER TABLE notifications ADD COLUMN {col} {typedef}") + + # Create filter_rules table if missing + conn.execute(""" + CREATE TABLE IF NOT EXISTS filter_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + field TEXT NOT NULL, + op TEXT NOT NULL, + value TEXT NOT NULL, + action TEXT DEFAULT 'dismiss', + priority INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1, + description TEXT + ) + """) + conn.commit() def cmd_notif_list(args) -> None: """List notifications.""" @@ -508,6 +706,10 @@ def cmd_notif_list(args) -> None: if args.pending: conditions.append("dismissed = 0 AND acted_at IS NULL") + if getattr(args, 'dismissed', False): + conditions.append("dismissed = 1") + if getattr(args, 'own', False): + conditions.append("is_own_pr = 1") if conditions: query += " WHERE " + " AND ".join(conditions) @@ -525,8 +727,12 @@ def cmd_notif_list(args) -> None: for n in notifications: status = "✓" if n["acted_at"] else ("✗" if n["dismissed"] else "○") - print(f"{status} [{n['id'][:8]}] {n['title'][:60]}") + own_tag = " 🔵" if n["is_own_pr"] else "" + ci_tag = f" [CI:{n['ci_status']}]" if n["ci_status"] else "" + print(f"{status}{own_tag}{ci_tag} [{n['id'][:8]}] {n['title'][:60]}") print(f" Repo: {n['repo_id']} | Reason: {n['reason']}") + if n["filter_reason"]: + print(f" Filter: {n['filter_reason']}") if n["url"]: print(f" URL: {n['url']}") print() @@ -630,6 +836,63 @@ def cmd_notif_audit(args) -> None: print(f"\n✓ Created {len(stale)} escalations") +# ============================================================================ +# Commands: Filter Rules +# ============================================================================ + +def cmd_filter_list(args) -> None: + """List filter rules.""" + conn = get_db() + _migrate_filter_columns(conn) + rules = conn.execute("SELECT * FROM filter_rules ORDER BY priority DESC").fetchall() + conn.close() + + if not rules: + print("No filter rules configured.") + print("\nDefault rules (hardcoded):") + print(" • reason=subscribed → auto-dismiss") + print(" • own PRs (al-munazzim) with failing CI or reviews → always keep") + return + + print("Filter rules (highest priority first):\n") + for r in rules: + status = "✓" if r["enabled"] else "✗" + print(f" {status} [{r['id']}] {r['field']} {r['op']} '{r['value']}' → {r['action']}") + if r["description"]: + print(f" {r['description']}") + + print("\nHardcoded rules (always active):") + print(" • own PRs (al-munazzim) with failing CI or reviews → always keep") + print(" • reason=subscribed → auto-dismiss (fallback)") + + +def cmd_filter_add(args) -> None: + """Add a filter rule.""" + conn = get_db() + _migrate_filter_columns(conn) + conn.execute(""" + INSERT INTO filter_rules (field, op, value, action, priority, description) + VALUES (?, ?, ?, ?, ?, ?) + """, (args.field, args.op, args.value, args.action, args.priority, args.desc)) + conn.commit() + conn.close() + print(f"✓ Added filter rule: {args.field} {args.op} '{args.value}' → {args.action}") + + +def cmd_filter_rm(args) -> None: + """Remove a filter rule.""" + conn = get_db() + _migrate_filter_columns(conn) + deleted = conn.execute("DELETE FROM filter_rules WHERE id = ?", (args.id,)).rowcount + conn.commit() + conn.close() + if deleted: + print(f"✓ Removed filter rule {args.id}") + else: + print(f"Error: Rule {args.id} not found", file=sys.stderr) + sys.exit(1) + + # ============================================================================ # Commands: Activity # ============================================================================ @@ -848,12 +1111,32 @@ def main(): 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("--no-filter", action="store_true", help="Disable filtering, keep all") 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("--dismissed", action="store_true", help="Show only dismissed") + list_parser.add_argument("--own", action="store_true", help="Show only own PRs") list_parser.add_argument("--limit", type=int, help="Limit results") + # Filter rule management + filter_parser = notif_sub.add_parser("filter", help="Manage filter rules") + filter_sub = filter_parser.add_subparsers(dest="filter_cmd") + + filter_list_p = filter_sub.add_parser("list", help="List filter rules") + + filter_add_p = filter_sub.add_parser("add", help="Add filter rule") + filter_add_p.add_argument("--field", required=True, choices=["reason", "repo_id", "title", "type"], help="Field to match") + filter_add_p.add_argument("--op", required=True, choices=["eq", "neq", "contains", "matches"], help="Operator") + filter_add_p.add_argument("--value", required=True, help="Value to match") + filter_add_p.add_argument("--action", choices=["dismiss", "keep"], default="dismiss", help="Action (default: dismiss)") + filter_add_p.add_argument("--priority", type=int, default=0, help="Priority (higher = checked first)") + filter_add_p.add_argument("--desc", help="Human-readable description") + + filter_rm_p = filter_sub.add_parser("rm", help="Remove filter rule") + filter_rm_p.add_argument("id", type=int, help="Rule ID") + 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") @@ -936,6 +1219,15 @@ def main(): cmd_notif_dismiss(args) elif args.notif_cmd == "audit": cmd_notif_audit(args) + elif args.notif_cmd == "filter": + if getattr(args, 'filter_cmd', None) == "list": + cmd_filter_list(args) + elif getattr(args, 'filter_cmd', None) == "add": + cmd_filter_add(args) + elif getattr(args, 'filter_cmd', None) == "rm": + cmd_filter_rm(args) + else: + filter_parser.print_help() else: notif_parser.print_help() elif args.command == "activity": diff --git a/lib/schema.sql b/lib/schema.sql index 693bcf3..6a8066d 100644 --- a/lib/schema.sql +++ b/lib/schema.sql @@ -32,14 +32,30 @@ CREATE TABLE IF NOT EXISTS notifications ( reason TEXT, title TEXT, url TEXT, + type TEXT, seen_at TEXT, acted_at TEXT, action_taken TEXT, dismissed INTEGER DEFAULT 0, + filter_reason TEXT, + is_own_pr INTEGER DEFAULT 0, + ci_status TEXT, FOREIGN KEY (issue_id) REFERENCES issues(id), FOREIGN KEY (repo_id) REFERENCES repos(id) ); +-- Filter rules for auto-dismissing notifications +CREATE TABLE IF NOT EXISTS filter_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + field TEXT NOT NULL, -- reason | repo_id | title | type + op TEXT NOT NULL, -- eq | neq | contains | matches + value TEXT NOT NULL, + action TEXT DEFAULT 'dismiss', -- dismiss | keep + priority INTEGER DEFAULT 0, -- higher = checked first + enabled INTEGER DEFAULT 1, + description TEXT +); + -- Activity log CREATE TABLE IF NOT EXISTS activity_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, From 09f04ea60bb7bc8ff64178252bc7146a9abd12b1 Mon Sep 17 00:00:00 2001 From: Nazim Date: Thu, 19 Mar 2026 14:33:10 +0000 Subject: [PATCH 2/7] feat: dashboard shows last poll time, relative timestamps, filter reasons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header shows 'last poll Xm ago — N new' like Forgejo dashboard - Notifications table: TIME (relative), ISSUE, REASON (badge), TITLE, FILTER - Own PRs tagged with 🔵, CI status shown - Default view: pending only (non-dismissed) - filter_reason column visible in table --- dashboard/index.html | 54 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/dashboard/index.html b/dashboard/index.html index cd4d7d3..224b68b 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -192,7 +192,7 @@

- Recent Notifications + 🔔 Notifications

@@ -238,6 +238,21 @@ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); } + function timeAgo(iso) { + if (!iso) return '-'; + const d = new Date(iso); + const now = new Date(); + const diffMs = now - d; + const mins = Math.floor(diffMs / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + const remMins = mins % 60; + if (hrs < 24) return remMins > 0 ? `${hrs}h ${remMins}m ago` : `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; + } + function getNotificationStatus(row) { if (row.dismissed) return { class: 'status-dismissed', text: 'Dismissed' }; if (row.acted_at) return { class: 'status-acted', text: 'Acted' }; @@ -263,25 +278,42 @@ "SELECT COUNT(*) as c FROM escalations WHERE status = 'open'"); document.getElementById('escalation-count').textContent = escalations[0]?.c || 0; - // Notifications table + // Last poll info + const polls = await fetchData('notification_polls', + 'SELECT polled_at, new_count FROM notification_polls ORDER BY polled_at DESC LIMIT 1'); + if (polls.length > 0) { + const p = polls[0]; + document.getElementById('poll-info').textContent = + `(last poll ${timeAgo(p.polled_at)} — ${p.new_count} new)`; + } + + // Notifications table — show pending (non-dismissed) by default const notifications = await fetchData('notifications', - 'SELECT * FROM notifications ORDER BY seen_at DESC LIMIT 10'); + 'SELECT * FROM notifications WHERE dismissed = 0 ORDER BY seen_at DESC LIMIT 30'); if (notifications.length === 0) { document.getElementById('notifications-table').innerHTML = - '
No notifications yet.
'; + '
No pending notifications. ✨
'; } else { let html = ` - + `; for (const n of notifications) { - const status = getNotificationStatus(n); - const title = n.url ? `${n.title}` : n.title; + const ownTag = n.is_own_pr ? '🔵 ' : ''; + const ciTag = n.ci_status ? ` [CI:${n.ci_status}]` : ''; + const repo_short = n.repo_id ? n.repo_id.split('/').pop() : '-'; + const issue = n.url ? `${repo_short}` : repo_short; + const reasonClass = n.reason === 'mention' ? 'accent' : + n.reason === 'author' ? 'success' : + n.reason === 'review_requested' ? 'warning' : ''; + const reasonBadge = `${n.reason}`; + const filterShort = (n.filter_reason || '').replace(/^KEEP: /, ''); html += ` - - - - + + + + + `; } html += '
StatusTitleRepoSeen
TIMEISSUEREASONTITLEFILTER
${status.text}${title}${n.repo_id || '-'}${formatDate(n.seen_at)}${timeAgo(n.seen_at)}${issue}${reasonBadge}${ownTag}${n.title || '-'}${ciTag}${filterShort}
'; From cf2752d8079ce808b6cb3f8fdf760690fdcb20e6 Mon Sep 17 00:00:00 2001 From: Nazim Date: Thu, 19 Mar 2026 14:41:23 +0000 Subject: [PATCH 3/7] docs: remove Port Convention and Author sections from README --- README.md | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/README.md b/README.md index 3dbafd2..638a4c6 100644 --- a/README.md +++ b/README.md @@ -172,26 +172,3 @@ journalctl -u argus -f # View logs ## License MIT - -## Author - -Zeus @ Olymp - -## Port Convention - -For multi-agent deployments, use this formula: - -``` -ARGUS_PORT = 8100 + (UID mod 100) * 10 -``` - -| Agent | UID | Dashboard | Datasette | -|--------|------|-----------|-----------| -| Zeus | 0 | 8100 | 8101 | -| Doxios | 1002 | 8120 | 8121 | -| Hermes | 1003 | 8130 | 8131 | - -Set in systemd service or environment: -```bash -Environment=ARGUS_PORT=8130 -``` From f37f3d62f66c9710ce9a166715180bcda4ea3bbf Mon Sep 17 00:00:00 2001 From: Nazim Date: Tue, 24 Mar 2026 12:26:30 +0000 Subject: [PATCH 4/7] feat: notif lock/unlock for duplicate processing prevention, dismiss auto-logs activity --- bin/argus | 61 ++++++++++++++++++++++++++++++++++++++++++-- dashboard/index.html | 12 +++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/bin/argus b/bin/argus index 550eafa..1186c49 100755 --- a/bin/argus +++ b/bin/argus @@ -705,7 +705,7 @@ def cmd_notif_list(args) -> None: params = [] if args.pending: - conditions.append("dismissed = 0 AND acted_at IS NULL") + conditions.append("dismissed = 0 AND acted_at IS NULL AND processing = 0") if getattr(args, 'dismissed', False): conditions.append("dismissed = 1") if getattr(args, 'own', False): @@ -726,7 +726,11 @@ def cmd_notif_list(args) -> None: return for n in notifications: - status = "✓" if n["acted_at"] else ("✗" if n["dismissed"] else "○") + try: + locked = n["processing"] + except (IndexError, KeyError): + locked = 0 + status = "✓" if n["acted_at"] else ("✗" if n["dismissed"] else ("🔒" if locked else "○")) own_tag = " 🔵" if n["is_own_pr"] else "" ci_tag = f" [CI:{n['ci_status']}]" if n["ci_status"] else "" print(f"{status}{own_tag}{ci_tag} [{n['id'][:8]}] {n['title'][:60]}") @@ -773,6 +777,41 @@ def cmd_notif_act(args) -> None: print(f"✓ Marked notification as acted: {action}") +def cmd_notif_lock(args) -> None: + """Lock notification to prevent duplicate processing.""" + conn = get_db() + 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) + if notif["processing"]: + print(f"⚠ Already locked: {notif['id'][:8]}") + sys.exit(1) + conn.execute("UPDATE notifications SET processing = 1 WHERE id = ?", (notif["id"],)) + conn.commit() + conn.close() + print(f"🔒 Locked: {notif['id'][:8]}") + + +def cmd_notif_unlock(args) -> None: + """Unlock notification after processing completes.""" + conn = get_db() + 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) + conn.execute("UPDATE notifications SET processing = 0 WHERE id = ?", (notif["id"],)) + conn.commit() + conn.close() + print(f"🔓 Unlocked: {notif['id'][:8]}") + + def cmd_notif_dismiss(args) -> None: """Dismiss notification.""" conn = get_db() @@ -795,6 +834,14 @@ def cmd_notif_dismiss(args) -> None: WHERE id = ? """, (f"dismissed: {reason}", notif["id"])) + # Auto-log activity + title = notif["title"] or "-" + detail = f"Dismissed: {reason} — {title}" + conn.execute( + "INSERT INTO activity_log (issue_id, action, detail, github_url) VALUES (?, ?, ?, ?)", + (notif["repo_id"], "dismiss", detail, notif["url"]) + ) + conn.commit() conn.close() @@ -1146,6 +1193,12 @@ def main(): dismiss_parser.add_argument("id", help="Notification ID (prefix match)") dismiss_parser.add_argument("reason", nargs="?", help="Dismissal reason") + lock_parser = notif_sub.add_parser("lock", help="Lock notification for processing") + lock_parser.add_argument("id", help="Notification ID (prefix match)") + + unlock_parser = notif_sub.add_parser("unlock", help="Unlock notification after processing") + unlock_parser.add_argument("id", help="Notification ID (prefix match)") + 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)") @@ -1217,6 +1270,10 @@ def main(): cmd_notif_act(args) elif args.notif_cmd == "dismiss": cmd_notif_dismiss(args) + elif args.notif_cmd == "lock": + cmd_notif_lock(args) + elif args.notif_cmd == "unlock": + cmd_notif_unlock(args) elif args.notif_cmd == "audit": cmd_notif_audit(args) elif args.notif_cmd == "filter": diff --git a/dashboard/index.html b/dashboard/index.html index 224b68b..ceb1c44 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -289,7 +289,7 @@ // Notifications table — show pending (non-dismissed) by default const notifications = await fetchData('notifications', - 'SELECT * FROM notifications WHERE dismissed = 0 ORDER BY seen_at DESC LIMIT 30'); + 'SELECT * FROM notifications WHERE dismissed = 0 AND processing = 0 ORDER BY seen_at DESC LIMIT 30'); if (notifications.length === 0) { document.getElementById('notifications-table').innerHTML = @@ -302,7 +302,15 @@ const ownTag = n.is_own_pr ? '🔵 ' : ''; const ciTag = n.ci_status ? ` [CI:${n.ci_status}]` : ''; const repo_short = n.repo_id ? n.repo_id.split('/').pop() : '-'; - const issue = n.url ? `${repo_short}` : repo_short; + let issueLabel = repo_short; + if (n.url) { + const m = n.url.match(/\/(pull|issues)\/(\d+)/); + if (m) { + const prefix = m[1] === 'pull' ? 'PR' : 'IS'; + issueLabel = `${repo_short} ${prefix} #${m[2]}`; + } + } + const issue = n.url ? `${issueLabel}` : issueLabel; const reasonClass = n.reason === 'mention' ? 'accent' : n.reason === 'author' ? 'success' : n.reason === 'review_requested' ? 'warning' : ''; From 34dfdaaa7022fb30181b7782cafec69534c6acd0 Mon Sep 17 00:00:00 2001 From: Nazim Date: Thu, 26 Mar 2026 15:36:46 +0000 Subject: [PATCH 5/7] fix: mark GitHub notifications as read after DB insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents accumulation of ~5000 unread notifications on GitHub, which caused argus notif pull to paginate through all of them on every poll (taking 10-20s instead of <1s). Each notification is marked as read via PATCH /notifications/threads/{id} immediately after being inserted into the DB. Best-effort — failures don't break the pull. --- bin/argus | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bin/argus b/bin/argus index 1186c49..c4177c6 100755 --- a/bin/argus +++ b/bin/argus @@ -651,6 +651,17 @@ def cmd_notif_pull(args) -> None: dismissed_count += 1 else: kept_count += 1 + + # Mark as read on GitHub (prevents re-fetching ~5000 stale notifications) + if not args.dry_run and backend == "gh": + try: + subprocess.run( + ["gh", "api", "--method", "PATCH", + f"notifications/threads/{notif_id}"], + capture_output=True, text=True, check=False + ) + except Exception: + pass # Best-effort — don't fail the pull if marking read fails if not args.dry_run: # Record poll From 31ca165499a4b2a537d1907f641e507abe14cddf Mon Sep 17 00:00:00 2001 From: Nazim Date: Thu, 26 Mar 2026 16:23:32 +0000 Subject: [PATCH 6/7] fix: use DELETE (done) instead of PATCH (read) for GitHub notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH only marks as read — notification stays in inbox. DELETE marks as done — removes from inbox entirely. --- bin/argus | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/argus b/bin/argus index c4177c6..f94a03d 100755 --- a/bin/argus +++ b/bin/argus @@ -652,16 +652,16 @@ def cmd_notif_pull(args) -> None: else: kept_count += 1 - # Mark as read on GitHub (prevents re-fetching ~5000 stale notifications) + # Mark as done on GitHub (DELETE removes from inbox entirely) if not args.dry_run and backend == "gh": try: subprocess.run( - ["gh", "api", "--method", "PATCH", + ["gh", "api", "--method", "DELETE", f"notifications/threads/{notif_id}"], capture_output=True, text=True, check=False ) except Exception: - pass # Best-effort — don't fail the pull if marking read fails + pass # Best-effort — don't fail the pull if marking done fails if not args.dry_run: # Record poll From 27f135c921b335203a4add88cdd3b5927ce90fae Mon Sep 17 00:00:00 2001 From: Nazim Date: Thu, 26 Mar 2026 17:21:39 +0000 Subject: [PATCH 7/7] dashboard: show all notifications with action taken, filter dismissals from activity - Notifications table: show ALL (not just pending), add ACTION TAKEN column showing dismiss reason or pending status. Dismissed rows slightly dimmed. - Activity section: filters out 'dismiss' entries, only shows real actions (reviews, comments, fixes) --- dashboard/index.html | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/dashboard/index.html b/dashboard/index.html index ceb1c44..e391a7f 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -271,7 +271,7 @@ const yesterday = new Date(Date.now() - 86400000).toISOString(); const activity = await fetchData('activity_log', - `SELECT COUNT(*) as c FROM activity_log WHERE timestamp > '${yesterday}'`); + `SELECT COUNT(*) as c FROM activity_log WHERE timestamp > '${yesterday}' AND action != 'dismiss'`); document.getElementById('activity-count').textContent = activity[0]?.c || 0; const escalations = await fetchData('escalations', @@ -287,16 +287,19 @@ `(last poll ${timeAgo(p.polled_at)} — ${p.new_count} new)`; } - // Notifications table — show pending (non-dismissed) by default + // Notifications table — show ALL notifications with action taken const notifications = await fetchData('notifications', - 'SELECT * FROM notifications WHERE dismissed = 0 AND processing = 0 ORDER BY seen_at DESC LIMIT 30'); + `SELECT n.*, a.detail as action_detail, a.action as action_type + FROM notifications n + LEFT JOIN activity_log a ON a.github_url = n.url + ORDER BY n.seen_at DESC LIMIT 30`); if (notifications.length === 0) { document.getElementById('notifications-table').innerHTML = - '
No pending notifications. ✨
'; + '
No notifications yet. ✨
'; } else { let html = ` - + `; for (const n of notifications) { const ownTag = n.is_own_pr ? '🔵 ' : ''; @@ -315,22 +318,30 @@ n.reason === 'author' ? 'success' : n.reason === 'review_requested' ? 'warning' : ''; const reasonBadge = `${n.reason}`; - const filterShort = (n.filter_reason || '').replace(/^KEEP: /, ''); - html += ` + // Action taken column + let actionText = ''; + if (n.action_detail) { + actionText = n.action_detail; + } else if (n.dismissed && n.filter_reason) { + actionText = `dismissed: ${n.filter_reason.replace(/^KEEP \(rule\): /, '')}`; + } else if (!n.dismissed) { + actionText = 'pending'; + } + html += ` - + `; } html += '
TIMEISSUEREASONTITLEFILTER
TIMEISSUEREASONTITLEACTION TAKEN
${timeAgo(n.seen_at)} ${issue} ${reasonBadge} ${ownTag}${n.title || '-'}${ciTag}${filterShort}${actionText}
'; document.getElementById('notifications-table').innerHTML = html; } - // Activity table + // Activity table — filter out dismissals, show only real actions const activityLog = await fetchData('activity_log', - 'SELECT * FROM activity_log ORDER BY timestamp DESC LIMIT 10'); + "SELECT * FROM activity_log WHERE action != 'dismiss' ORDER BY timestamp DESC LIMIT 10"); if (activityLog.length === 0) { document.getElementById('activity-table').innerHTML =