From b516cb2a1eb5160ebed3c4d643032d6289516696 Mon Sep 17 00:00:00 2001 From: al-munazzim Date: Wed, 29 Apr 2026 21:08:16 +0200 Subject: [PATCH] fix: re-open dismissed notifications on new GitHub activity (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add tag/untag/check-prior commands for triage dedup - Add 'tags' column to notifications table (comma-separated) - argus notif tag — add tags like triaged, hostile, follow-up - argus notif untag — remove tags - argus notif check-prior — check if URL was previously triaged (exit 0 = prior found, exit 1 = first touch) - Show tags in notif list output with šŸ· prefix - Add processing + tags columns to migration function - Bump version to 0.5.0 Motivation: postmortem/2026-03-26-duplicate-triage-2504.md Prevents duplicate triage comments by providing a fast local dedup check before hitting the GitHub API. * fix: re-open dismissed notifications on new GitHub activity Previously, notif pull skipped any notification ID already in the DB, even if GitHub re-triggered it (new @mention, comment, etc). This caused mentions to be silently swallowed - 5 notifications were stuck dismissed for up to 4 days. Now: if a dismissed notification appears in the GitHub API again (meaning it has new unread activity), undismiss it and update the reason field. Reports re-opened count in pull output. * fix: avoid sqlite3.Row.get in notif audit escalation --------- Co-authored-by: Nazim --- bin/argus | 154 ++++++++++++++++++++++++++++++++++++++++++++++--- lib/schema.sql | 2 + 2 files changed, 149 insertions(+), 7 deletions(-) diff --git a/bin/argus b/bin/argus index f94a03d..03e85f6 100755 --- a/bin/argus +++ b/bin/argus @@ -19,7 +19,7 @@ from typing import Optional # Configuration # ============================================================================ -VERSION = "0.4.0" +VERSION = "0.5.0" DEFAULT_DB_PATH = Path.home() / ".argus" / "argus.db" DEFAULT_PORT = 8100 # Dashboard port; datasette runs on port+1 @@ -590,17 +590,44 @@ def cmd_notif_pull(args) -> None: new_count = 0 kept_count = 0 dismissed_count = 0 + reopened_count = 0 for notif in notifications: notif_id = notif["id"] # Check if already exists existing = conn.execute( - "SELECT id FROM notifications WHERE id = ?", + "SELECT id, reason, dismissed FROM notifications WHERE id = ?", (notif_id,) ).fetchone() if existing: + old_reason = existing[1] + was_dismissed = existing[2] + new_reason = notif["reason"] + # Only re-open if the reason actually changed (e.g. subscribed → mention). + # If dismissed with same reason, GitHub is just re-serving the same + # notification — mark it done on GitHub to stop the loop. + if old_reason != new_reason: + conn.execute( + "UPDATE notifications SET reason = ?, dismissed = 0, " + "action_taken = NULL, acted_at = NULL, " + "filter_reason = 'RE-OPENED: new activity detected (was dismissed, reason: ' || ? || ' → ' || ? || ')' " + "WHERE id = ?", + (new_reason, old_reason, new_reason, notif_id) + ) + conn.commit() + reopened_count += 1 + elif was_dismissed and not args.dry_run and backend == "gh": + # Mark as done on GitHub to prevent re-open loop + try: + subprocess.run( + ["gh", "api", "--method", "DELETE", + f"notifications/threads/{notif_id}"], + capture_output=True, text=True, check=False + ) + except Exception: + pass continue # Match against pre-fetched own PRs (O(1) lookup, no API call) @@ -674,7 +701,8 @@ def cmd_notif_pull(args) -> None: conn.close() prefix = "[DRY-RUN] " if args.dry_run else "āœ“ " - print(f"\n{prefix}Pulled {new_count} new ({kept_count} kept, {dismissed_count} auto-dismissed)") + reopen_msg = f", {reopened_count} re-opened" if reopened_count else "" + print(f"\n{prefix}Pulled {new_count} new ({kept_count} kept, {dismissed_count} auto-dismissed{reopen_msg})") def _migrate_filter_columns(conn: sqlite3.Connection) -> None: @@ -687,6 +715,8 @@ def _migrate_filter_columns(conn: sqlite3.Connection) -> None: "filter_reason": "TEXT", "is_own_pr": "INTEGER DEFAULT 0", "ci_status": "TEXT", + "processing": "INTEGER DEFAULT 0", + "tags": "TEXT DEFAULT ''", } for col, typedef in migrations.items(): if col not in columns: @@ -744,7 +774,11 @@ def cmd_notif_list(args) -> None: 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]}") + try: + tags_str = f" šŸ·{n['tags']}" if n["tags"] else "" + except (IndexError, KeyError): + tags_str = "" + print(f"{status}{own_tag}{ci_tag}{tags_str} [{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']}") @@ -778,10 +812,12 @@ def cmd_notif_act(args) -> None: """, (datetime.now().isoformat(), f"{action}: {detail}".strip(": "), notif["id"])) # Log the activity + # sqlite3.Row doesn't have .get() — use dict() or try/except + notif_dict = dict(notif) conn.execute(""" INSERT INTO activity_log (issue_id, action, detail, github_url) VALUES (?, ?, ?, ?) - """, (notif.get("issue_id"), action, detail or notif["title"], notif.get("url"))) + """, (notif_dict.get("issue_id"), action, detail or notif_dict.get("title", ""), notif_dict.get("url"))) conn.commit() conn.close() @@ -823,6 +859,88 @@ def cmd_notif_unlock(args) -> None: print(f"šŸ”“ Unlocked: {notif['id'][:8]}") +def cmd_notif_tag(args) -> None: + """Add a tag to a notification.""" + conn = get_db() + _migrate_filter_columns(conn) + 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) + + existing_tags = set(filter(None, (notif["tags"] or "").split(","))) + existing_tags.add(args.tag) + new_tags = ",".join(sorted(existing_tags)) + + conn.execute("UPDATE notifications SET tags = ? WHERE id = ?", (new_tags, notif["id"])) + conn.commit() + conn.close() + print(f"āœ“ Tagged {notif['id'][:8]} with '{args.tag}' → [{new_tags}]") + + +def cmd_notif_untag(args) -> None: + """Remove a tag from a notification.""" + conn = get_db() + _migrate_filter_columns(conn) + 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) + + existing_tags = set(filter(None, (notif["tags"] or "").split(","))) + existing_tags.discard(args.tag) + new_tags = ",".join(sorted(existing_tags)) + + conn.execute("UPDATE notifications SET tags = ? WHERE id = ?", (new_tags, notif["id"])) + conn.commit() + conn.close() + print(f"āœ“ Untagged {notif['id'][:8]} '{args.tag}' → [{new_tags}]") + + +def cmd_notif_check_prior(args) -> None: + """Check if any notification with the same URL has been triaged before. + + Returns exit code 0 if prior engagement found, 1 if not. + Useful for heartbeat triage to avoid duplicate triage comments. + """ + conn = get_db() + _migrate_filter_columns(conn) + + url = args.url + + # Find any notification with this URL that has been acted on, triaged, or has the 'triaged' tag + prior = conn.execute(""" + SELECT id, title, acted_at, action_taken, tags, seen_at + FROM notifications + WHERE url = ? AND ( + acted_at IS NOT NULL + OR tags LIKE '%triaged%' + ) + ORDER BY seen_at DESC + LIMIT 5 + """, (url,)).fetchall() + + conn.close() + + if prior: + print(f"⚠ PRIOR ENGAGEMENT FOUND for {url}") + for p in prior: + tags = f" [tags: {p['tags']}]" if p["tags"] else "" + action = f" → {p['action_taken']}" if p["action_taken"] else "" + print(f" [{p['id'][:8]}] seen {p['seen_at'][:10]}{action}{tags}") + print(f"\n→ This is a FOLLOW-UP. Do NOT re-triage. Read thread and respond contextually.") + sys.exit(0) + else: + print(f"āœ“ No prior engagement for {url} — first touch, triage OK.") + sys.exit(1) + + def cmd_notif_dismiss(args) -> None: """Dismiss notification.""" conn = get_db() @@ -880,13 +998,15 @@ def cmd_notif_audit(args) -> None: print(f" - {n['title'][:50]}...") # Create escalation + url = n["url"] if "url" in n.keys() and n["url"] else "N/A" + conn.execute(""" INSERT INTO escalations (category, title, detail) VALUES (?, ?, ?) """, ( "stale-notification", f"Stale: {n['title'][:100]}", - f"Notification {n['id']} has been pending for >{stale_hours}h.\nURL: {n.get('url', 'N/A')}" + f"Notification {n['id']} has been pending for >{stale_hours}h.\nURL: {url}" )) conn.commit() @@ -1165,7 +1285,10 @@ def main(): # Notification commands notif_parser = subparsers.add_parser("notif", help="Notification management") - notif_sub = notif_parser.add_subparsers(dest="notif_cmd") + notif_sub = notif_parser.add_subparsers( + dest="notif_cmd", + help="pull|list|act|dismiss|lock|unlock|tag|untag|check-prior|audit|filter" + ) 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") @@ -1210,6 +1333,17 @@ def main(): unlock_parser = notif_sub.add_parser("unlock", help="Unlock notification after processing") unlock_parser.add_argument("id", help="Notification ID (prefix match)") + tag_parser = notif_sub.add_parser("tag", help="Add tag to notification") + tag_parser.add_argument("id", help="Notification ID (prefix match)") + tag_parser.add_argument("tag", help="Tag to add (e.g. triaged, hostile, follow-up)") + + untag_parser = notif_sub.add_parser("untag", help="Remove tag from notification") + untag_parser.add_argument("id", help="Notification ID (prefix match)") + untag_parser.add_argument("tag", help="Tag to remove") + + check_prior_parser = notif_sub.add_parser("check-prior", help="Check for prior engagement on a URL") + check_prior_parser.add_argument("url", help="Issue/PR URL to check") + 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)") @@ -1285,6 +1419,12 @@ def main(): cmd_notif_lock(args) elif args.notif_cmd == "unlock": cmd_notif_unlock(args) + elif args.notif_cmd == "tag": + cmd_notif_tag(args) + elif args.notif_cmd == "untag": + cmd_notif_untag(args) + elif args.notif_cmd == "check-prior": + cmd_notif_check_prior(args) elif args.notif_cmd == "audit": cmd_notif_audit(args) elif args.notif_cmd == "filter": diff --git a/lib/schema.sql b/lib/schema.sql index 6a8066d..56f701c 100644 --- a/lib/schema.sql +++ b/lib/schema.sql @@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS notifications ( filter_reason TEXT, is_own_pr INTEGER DEFAULT 0, ci_status TEXT, + processing INTEGER DEFAULT 0, + tags TEXT DEFAULT '', FOREIGN KEY (issue_id) REFERENCES issues(id), FOREIGN KEY (repo_id) REFERENCES repos(id) );