Merge pull request #1 from al-munazzim/feat/smart-filter
feat: smart notification filtering with reasons, own-PR detection, pagination
This commit is contained in:
commit
7fa0c13c47
4 changed files with 460 additions and 56 deletions
23
README.md
23
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
|
||||
```
|
||||
|
|
|
|||
394
bin/argus
394
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,65 @@ 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 (?, ?, ?, ?, ?, ?)
|
||||
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"], datetime.now().isoformat()))
|
||||
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
|
||||
|
||||
# Mark as done on GitHub (DELETE removes from inbox entirely)
|
||||
if not args.dry_run and backend == "gh":
|
||||
try:
|
||||
subprocess.run(
|
||||
["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 done fails
|
||||
|
||||
if not args.dry_run:
|
||||
# Record poll
|
||||
|
|
@ -493,10 +673,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."""
|
||||
|
|
@ -507,7 +716,11 @@ 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):
|
||||
conditions.append("is_own_pr = 1")
|
||||
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
|
@ -524,9 +737,17 @@ def cmd_notif_list(args) -> None:
|
|||
return
|
||||
|
||||
for n in notifications:
|
||||
status = "✓" if n["acted_at"] else ("✗" if n["dismissed"] else "○")
|
||||
print(f"{status} [{n['id'][:8]}] {n['title'][:60]}")
|
||||
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]}")
|
||||
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()
|
||||
|
|
@ -567,6 +788,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()
|
||||
|
|
@ -589,6 +845,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()
|
||||
|
||||
|
|
@ -630,6 +894,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 +1169,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")
|
||||
|
|
@ -863,6 +1204,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)")
|
||||
|
||||
|
|
@ -934,8 +1281,21 @@ 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":
|
||||
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":
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@
|
|||
<div class="section">
|
||||
<div class="card">
|
||||
<h2>
|
||||
Recent Notifications
|
||||
🔔 Notifications <span id="poll-info" style="font-weight:normal;font-size:0.7em;color:var(--text-dim)"></span>
|
||||
<button class="refresh-btn" onclick="loadData()">↻ Refresh</button>
|
||||
</h2>
|
||||
<div id="notifications-table">
|
||||
|
|
@ -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' };
|
||||
|
|
@ -256,41 +271,77 @@
|
|||
|
||||
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',
|
||||
"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 ALL notifications with action taken
|
||||
const notifications = await fetchData('notifications',
|
||||
'SELECT * FROM notifications ORDER BY seen_at DESC LIMIT 10');
|
||||
`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 =
|
||||
'<div class="loading">No notifications yet.</div>';
|
||||
'<div class="loading">No notifications yet. ✨</div>';
|
||||
} else {
|
||||
let html = `<table>
|
||||
<thead><tr><th>Status</th><th>Title</th><th>Repo</th><th>Seen</th></tr></thead>
|
||||
<thead><tr><th>TIME</th><th>ISSUE</th><th>REASON</th><th>TITLE</th><th>ACTION TAKEN</th></tr></thead>
|
||||
<tbody>`;
|
||||
for (const n of notifications) {
|
||||
const status = getNotificationStatus(n);
|
||||
const title = n.url ? `<a href="${n.url}" target="_blank">${n.title}</a>` : n.title;
|
||||
html += `<tr>
|
||||
<td><span class="status-badge ${status.class}">${status.text}</span></td>
|
||||
<td>${title}</td>
|
||||
<td>${n.repo_id || '-'}</td>
|
||||
<td class="timestamp">${formatDate(n.seen_at)}</td>
|
||||
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() : '-';
|
||||
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 ? `<a href="${n.url}" target="_blank">${issueLabel}</a>` : issueLabel;
|
||||
const reasonClass = n.reason === 'mention' ? 'accent' :
|
||||
n.reason === 'author' ? 'success' :
|
||||
n.reason === 'review_requested' ? 'warning' : '';
|
||||
const reasonBadge = `<span class="status-badge ${reasonClass ? 'status-' + reasonClass : ''}" style="background:var(--${reasonClass || 'text-dim'}); color:#000; padding:2px 8px; border-radius:4px; font-size:0.8em">${n.reason}</span>`;
|
||||
// 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 = '<span style="color:var(--warning)">pending</span>';
|
||||
}
|
||||
html += `<tr style="${n.dismissed ? 'opacity:0.7' : ''}">
|
||||
<td class="timestamp">${timeAgo(n.seen_at)}</td>
|
||||
<td>${issue}</td>
|
||||
<td>${reasonBadge}</td>
|
||||
<td>${ownTag}${n.title || '-'}${ciTag}</td>
|
||||
<td style="font-size:0.85em">${actionText}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue