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:
parent
7fa0c13c47
commit
b516cb2a1e
2 changed files with 149 additions and 7 deletions
154
bin/argus
154
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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue