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
|
# Configuration
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
VERSION = "0.4.0"
|
VERSION = "0.5.0"
|
||||||
DEFAULT_DB_PATH = Path.home() / ".argus" / "argus.db"
|
DEFAULT_DB_PATH = Path.home() / ".argus" / "argus.db"
|
||||||
DEFAULT_PORT = 8100 # Dashboard port; datasette runs on port+1
|
DEFAULT_PORT = 8100 # Dashboard port; datasette runs on port+1
|
||||||
|
|
||||||
|
|
@ -590,17 +590,44 @@ def cmd_notif_pull(args) -> None:
|
||||||
new_count = 0
|
new_count = 0
|
||||||
kept_count = 0
|
kept_count = 0
|
||||||
dismissed_count = 0
|
dismissed_count = 0
|
||||||
|
reopened_count = 0
|
||||||
|
|
||||||
for notif in notifications:
|
for notif in notifications:
|
||||||
notif_id = notif["id"]
|
notif_id = notif["id"]
|
||||||
|
|
||||||
# Check if already exists
|
# Check if already exists
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id FROM notifications WHERE id = ?",
|
"SELECT id, reason, dismissed FROM notifications WHERE id = ?",
|
||||||
(notif_id,)
|
(notif_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if existing:
|
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
|
continue
|
||||||
|
|
||||||
# Match against pre-fetched own PRs (O(1) lookup, no API call)
|
# Match against pre-fetched own PRs (O(1) lookup, no API call)
|
||||||
|
|
@ -674,7 +701,8 @@ def cmd_notif_pull(args) -> None:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
prefix = "[DRY-RUN] " if args.dry_run else "✓ "
|
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:
|
def _migrate_filter_columns(conn: sqlite3.Connection) -> None:
|
||||||
|
|
@ -687,6 +715,8 @@ def _migrate_filter_columns(conn: sqlite3.Connection) -> None:
|
||||||
"filter_reason": "TEXT",
|
"filter_reason": "TEXT",
|
||||||
"is_own_pr": "INTEGER DEFAULT 0",
|
"is_own_pr": "INTEGER DEFAULT 0",
|
||||||
"ci_status": "TEXT",
|
"ci_status": "TEXT",
|
||||||
|
"processing": "INTEGER DEFAULT 0",
|
||||||
|
"tags": "TEXT DEFAULT ''",
|
||||||
}
|
}
|
||||||
for col, typedef in migrations.items():
|
for col, typedef in migrations.items():
|
||||||
if col not in columns:
|
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 "○"))
|
status = "✓" if n["acted_at"] else ("✗" if n["dismissed"] else ("🔒" if locked else "○"))
|
||||||
own_tag = " 🔵" if n["is_own_pr"] else ""
|
own_tag = " 🔵" if n["is_own_pr"] else ""
|
||||||
ci_tag = f" [CI:{n['ci_status']}]" if n["ci_status"] 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']}")
|
print(f" Repo: {n['repo_id']} | Reason: {n['reason']}")
|
||||||
if n["filter_reason"]:
|
if n["filter_reason"]:
|
||||||
print(f" Filter: {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"]))
|
""", (datetime.now().isoformat(), f"{action}: {detail}".strip(": "), notif["id"]))
|
||||||
|
|
||||||
# Log the activity
|
# Log the activity
|
||||||
|
# sqlite3.Row doesn't have .get() — use dict() or try/except
|
||||||
|
notif_dict = dict(notif)
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO activity_log (issue_id, action, detail, github_url)
|
INSERT INTO activity_log (issue_id, action, detail, github_url)
|
||||||
VALUES (?, ?, ?, ?)
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -823,6 +859,88 @@ def cmd_notif_unlock(args) -> None:
|
||||||
print(f"🔓 Unlocked: {notif['id'][:8]}")
|
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:
|
def cmd_notif_dismiss(args) -> None:
|
||||||
"""Dismiss notification."""
|
"""Dismiss notification."""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
|
@ -880,13 +998,15 @@ def cmd_notif_audit(args) -> None:
|
||||||
print(f" - {n['title'][:50]}...")
|
print(f" - {n['title'][:50]}...")
|
||||||
|
|
||||||
# Create escalation
|
# Create escalation
|
||||||
|
url = n["url"] if "url" in n.keys() and n["url"] else "N/A"
|
||||||
|
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO escalations (category, title, detail)
|
INSERT INTO escalations (category, title, detail)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
"stale-notification",
|
"stale-notification",
|
||||||
f"Stale: {n['title'][:100]}",
|
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()
|
conn.commit()
|
||||||
|
|
@ -1165,7 +1285,10 @@ def main():
|
||||||
|
|
||||||
# Notification commands
|
# Notification commands
|
||||||
notif_parser = subparsers.add_parser("notif", help="Notification management")
|
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 = 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("--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 = notif_sub.add_parser("unlock", help="Unlock notification after processing")
|
||||||
unlock_parser.add_argument("id", help="Notification ID (prefix match)")
|
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 = notif_sub.add_parser("audit", help="Audit stale notifications")
|
||||||
audit_parser.add_argument("--stale-hours", type=int, help="Hours to consider stale (default: 48)")
|
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)
|
cmd_notif_lock(args)
|
||||||
elif args.notif_cmd == "unlock":
|
elif args.notif_cmd == "unlock":
|
||||||
cmd_notif_unlock(args)
|
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":
|
elif args.notif_cmd == "audit":
|
||||||
cmd_notif_audit(args)
|
cmd_notif_audit(args)
|
||||||
elif args.notif_cmd == "filter":
|
elif args.notif_cmd == "filter":
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS notifications (
|
||||||
filter_reason TEXT,
|
filter_reason TEXT,
|
||||||
is_own_pr INTEGER DEFAULT 0,
|
is_own_pr INTEGER DEFAULT 0,
|
||||||
ci_status TEXT,
|
ci_status TEXT,
|
||||||
|
processing INTEGER DEFAULT 0,
|
||||||
|
tags TEXT DEFAULT '',
|
||||||
FOREIGN KEY (issue_id) REFERENCES issues(id),
|
FOREIGN KEY (issue_id) REFERENCES issues(id),
|
||||||
FOREIGN KEY (repo_id) REFERENCES repos(id)
|
FOREIGN KEY (repo_id) REFERENCES repos(id)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue