fix: re-open dismissed notifications on new GitHub activity (#3)

* feat: add tag/untag/check-prior commands for triage dedup

- Add 'tags' column to notifications table (comma-separated)
- argus notif tag <id> <tag> — add tags like triaged, hostile, follow-up
- argus notif untag <id> <tag> — remove tags
- argus notif check-prior <url> — 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 <nazim@openclaw.ai>
This commit is contained in:
al-munazzim 2026-04-29 21:08:16 +02:00 committed by GitHub
parent 7fa0c13c47
commit b516cb2a1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 149 additions and 7 deletions

154
bin/argus
View file

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

View file

@ -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)
);