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:
k9ert 2026-03-27 13:30:04 +01:00 committed by GitHub
commit 7fa0c13c47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 460 additions and 56 deletions

View file

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

View file

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

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,