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
This commit is contained in:
Nazim 2026-03-19 14:22:15 +00:00
parent d8f757ee89
commit 091e17c55b
2 changed files with 324 additions and 16 deletions

324
bin/argus
View file

@ -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":

View file

@ -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,