Compare commits
10 commits
09f04ea60b
...
43f5a1c64c
| Author | SHA1 | Date | |
|---|---|---|---|
| 43f5a1c64c | |||
| bb6f4df65b | |||
|
|
b516cb2a1e | ||
|
|
7fa0c13c47 | ||
|
|
f0ba7d46b5 | ||
| 27f135c921 | |||
| 31ca165499 | |||
| 34dfdaaa70 | |||
| f37f3d62f6 | |||
| cf2752d807 |
9 changed files with 488 additions and 43 deletions
17
.github/workflows/tests.yml
vendored
Normal file
17
.github/workflows/tests.yml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Run unit tests
|
||||||
|
run: python -m unittest discover -s tests -v
|
||||||
29
AGENTS.md
Normal file
29
AGENTS.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# AGENTS.md — Argus
|
||||||
|
|
||||||
|
If you're an AI agent using Argus, read this first.
|
||||||
|
|
||||||
|
## What Argus Does
|
||||||
|
|
||||||
|
Argus tracks GitHub/Forgejo notifications, logs your community actions, and escalates things you miss. It's your eyes on the repos you care about.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
See [docs/SKILL.md](docs/SKILL.md) for installation and daily usage.
|
||||||
|
|
||||||
|
## Behavioral Guidelines
|
||||||
|
|
||||||
|
**Read [docs/behavioral-guidelines.md](docs/behavioral-guidelines.md) before interacting with any human on GitHub.**
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Don't engage with hostile users — dismiss and escalate to your operator
|
||||||
|
- Be concise, specific, and humble in all public comments
|
||||||
|
- Escalate to a human when unsure — a missed comment beats a bad one
|
||||||
|
- Every notification must be acted on or dismissed with a reason
|
||||||
|
|
||||||
|
## Procedures
|
||||||
|
|
||||||
|
- [Notification triage & PR review workflow](docs/procedures/subagent-completion.md)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
If you find a bug or want to improve Argus, open a PR. Follow the repo's coding style (shell + Python, keep it simple).
|
||||||
4
Makefile
Normal file
4
Makefile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.PHONY: test
|
||||||
|
|
||||||
|
test:
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
31
README.md
31
README.md
|
|
@ -1,5 +1,7 @@
|
||||||
# Argus 👁️
|
# Argus 👁️
|
||||||
|
|
||||||
|
[](https://github.com/al-munazzim/argus/actions/workflows/tests.yml)
|
||||||
|
|
||||||
CLI tool for GitHub/Forgejo community awareness — tracking notifications, logging activity, managing escalations.
|
CLI tool for GitHub/Forgejo community awareness — tracking notifications, logging activity, managing escalations.
|
||||||
|
|
||||||
Named after **Argus Panoptes**, the all-seeing giant of Greek mythology.
|
Named after **Argus Panoptes**, the all-seeing giant of Greek mythology.
|
||||||
|
|
@ -54,6 +56,12 @@ For AI agents using OpenClaw, add this to your `HEARTBEAT.md` for periodic commu
|
||||||
|
|
||||||
The install script outputs this block — just copy it to your HEARTBEAT.md.
|
The install script outputs this block — just copy it to your HEARTBEAT.md.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
@ -172,26 +180,3 @@ journalctl -u argus -f # View logs
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
226
bin/argus
226
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)
|
||||||
|
|
@ -651,6 +678,17 @@ def cmd_notif_pull(args) -> None:
|
||||||
dismissed_count += 1
|
dismissed_count += 1
|
||||||
else:
|
else:
|
||||||
kept_count += 1
|
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:
|
if not args.dry_run:
|
||||||
# Record poll
|
# Record poll
|
||||||
|
|
@ -663,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:
|
||||||
|
|
@ -676,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:
|
||||||
|
|
@ -705,7 +746,7 @@ def cmd_notif_list(args) -> None:
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if args.pending:
|
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):
|
if getattr(args, 'dismissed', False):
|
||||||
conditions.append("dismissed = 1")
|
conditions.append("dismissed = 1")
|
||||||
if getattr(args, 'own', False):
|
if getattr(args, 'own', False):
|
||||||
|
|
@ -726,10 +767,18 @@ def cmd_notif_list(args) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
for n in notifications:
|
for n in notifications:
|
||||||
status = "✓" if n["acted_at"] else ("✗" if n["dismissed"] else "○")
|
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 ""
|
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']}")
|
||||||
|
|
@ -763,16 +812,135 @@ 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()
|
||||||
|
|
||||||
print(f"✓ Marked notification as acted: {action}")
|
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_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()
|
||||||
|
|
@ -795,6 +963,14 @@ def cmd_notif_dismiss(args) -> None:
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""", (f"dismissed: {reason}", notif["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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -822,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()
|
||||||
|
|
@ -1107,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")
|
||||||
|
|
@ -1146,6 +1327,23 @@ def main():
|
||||||
dismiss_parser.add_argument("id", help="Notification ID (prefix match)")
|
dismiss_parser.add_argument("id", help="Notification ID (prefix match)")
|
||||||
dismiss_parser.add_argument("reason", nargs="?", help="Dismissal reason")
|
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)")
|
||||||
|
|
||||||
|
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)")
|
||||||
|
|
||||||
|
|
@ -1217,6 +1415,16 @@ def main():
|
||||||
cmd_notif_act(args)
|
cmd_notif_act(args)
|
||||||
elif args.notif_cmd == "dismiss":
|
elif args.notif_cmd == "dismiss":
|
||||||
cmd_notif_dismiss(args)
|
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 == "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":
|
||||||
|
|
|
||||||
|
|
@ -271,7 +271,7 @@
|
||||||
|
|
||||||
const yesterday = new Date(Date.now() - 86400000).toISOString();
|
const yesterday = new Date(Date.now() - 86400000).toISOString();
|
||||||
const activity = await fetchData('activity_log',
|
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;
|
document.getElementById('activity-count').textContent = activity[0]?.c || 0;
|
||||||
|
|
||||||
const escalations = await fetchData('escalations',
|
const escalations = await fetchData('escalations',
|
||||||
|
|
@ -287,42 +287,61 @@
|
||||||
`(last poll ${timeAgo(p.polled_at)} — ${p.new_count} new)`;
|
`(last poll ${timeAgo(p.polled_at)} — ${p.new_count} new)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifications table — show pending (non-dismissed) by default
|
// Notifications table — show ALL notifications with action taken
|
||||||
const notifications = await fetchData('notifications',
|
const notifications = await fetchData('notifications',
|
||||||
'SELECT * FROM notifications WHERE dismissed = 0 ORDER BY seen_at DESC LIMIT 30');
|
`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) {
|
if (notifications.length === 0) {
|
||||||
document.getElementById('notifications-table').innerHTML =
|
document.getElementById('notifications-table').innerHTML =
|
||||||
'<div class="loading">No pending notifications. ✨</div>';
|
'<div class="loading">No notifications yet. ✨</div>';
|
||||||
} else {
|
} else {
|
||||||
let html = `<table>
|
let html = `<table>
|
||||||
<thead><tr><th>TIME</th><th>ISSUE</th><th>REASON</th><th>TITLE</th><th>FILTER</th></tr></thead>
|
<thead><tr><th>TIME</th><th>ISSUE</th><th>REASON</th><th>TITLE</th><th>ACTION TAKEN</th></tr></thead>
|
||||||
<tbody>`;
|
<tbody>`;
|
||||||
for (const n of notifications) {
|
for (const n of notifications) {
|
||||||
const ownTag = n.is_own_pr ? '🔵 ' : '';
|
const ownTag = n.is_own_pr ? '🔵 ' : '';
|
||||||
const ciTag = n.ci_status ? ` [CI:${n.ci_status}]` : '';
|
const ciTag = n.ci_status ? ` [CI:${n.ci_status}]` : '';
|
||||||
const repo_short = n.repo_id ? n.repo_id.split('/').pop() : '-';
|
const repo_short = n.repo_id ? n.repo_id.split('/').pop() : '-';
|
||||||
const issue = n.url ? `<a href="${n.url}" target="_blank">${repo_short}</a>` : repo_short;
|
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' :
|
const reasonClass = n.reason === 'mention' ? 'accent' :
|
||||||
n.reason === 'author' ? 'success' :
|
n.reason === 'author' ? 'success' :
|
||||||
n.reason === 'review_requested' ? 'warning' : '';
|
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>`;
|
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>`;
|
||||||
const filterShort = (n.filter_reason || '').replace(/^KEEP: /, '');
|
// Action taken column
|
||||||
html += `<tr>
|
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 class="timestamp">${timeAgo(n.seen_at)}</td>
|
||||||
<td>${issue}</td>
|
<td>${issue}</td>
|
||||||
<td>${reasonBadge}</td>
|
<td>${reasonBadge}</td>
|
||||||
<td>${ownTag}${n.title || '-'}${ciTag}</td>
|
<td>${ownTag}${n.title || '-'}${ciTag}</td>
|
||||||
<td style="color:var(--text-dim);font-size:0.85em">${filterShort}</td>
|
<td style="font-size:0.85em">${actionText}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
html += '</tbody></table>';
|
html += '</tbody></table>';
|
||||||
document.getElementById('notifications-table').innerHTML = html;
|
document.getElementById('notifications-table').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activity table
|
// Activity table — filter out dismissals, show only real actions
|
||||||
const activityLog = await fetchData('activity_log',
|
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) {
|
if (activityLog.length === 0) {
|
||||||
document.getElementById('activity-table').innerHTML =
|
document.getElementById('activity-table').innerHTML =
|
||||||
|
|
|
||||||
75
docs/behavioral-guidelines.md
Normal file
75
docs/behavioral-guidelines.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Agent Behavioral Guidelines
|
||||||
|
|
||||||
|
Rules for AI agents using Argus for GitHub/Forgejo community management.
|
||||||
|
|
||||||
|
These guidelines govern **how** agents interact with humans on GitHub — not the mechanics of Argus itself (see [SKILL.md](SKILL.md) for that).
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
**You represent a project.** Every comment you post reflects on the maintainers. Be helpful, be accurate, be kind — or be silent.
|
||||||
|
|
||||||
|
## Engagement Rules
|
||||||
|
|
||||||
|
### Always Respond To
|
||||||
|
- Direct mentions (`@your-username`)
|
||||||
|
- Review requests on PRs you're assigned to
|
||||||
|
- Questions on issues you previously commented on
|
||||||
|
|
||||||
|
### Never Respond To
|
||||||
|
- **Hostile or abusive comments.** Dismiss the notification, alert your operator. Do not engage. Do not defend yourself. Silence starves trolls.
|
||||||
|
- Conversations where you have nothing substantive to add
|
||||||
|
- Heated arguments between humans — stay out
|
||||||
|
|
||||||
|
### Be Careful With
|
||||||
|
- Closing issues — always explain why, link duplicates
|
||||||
|
- Marking things as "won't fix" — defer to human maintainers
|
||||||
|
- Making promises about timelines or features
|
||||||
|
- Speculation about root causes without evidence
|
||||||
|
|
||||||
|
## Tone & Style
|
||||||
|
|
||||||
|
- **Be concise.** Developers read hundreds of notifications. Respect their time.
|
||||||
|
- **Be specific.** "This might be a problem" is useless. "Line 42 in `rpc.py` catches `Exception` instead of `ConnectionError`" is useful.
|
||||||
|
- **Be humble.** Say "I think" not "This is." You're an AI — you can be wrong.
|
||||||
|
- **No filler.** Skip "Great question!", "Thanks for reporting!", "I'd be happy to help!" — just help.
|
||||||
|
- **Use code.** Show diffs, snippets, reproduction steps. Words explain; code proves.
|
||||||
|
|
||||||
|
## Triage Quality
|
||||||
|
|
||||||
|
When assessing issues:
|
||||||
|
|
||||||
|
1. **Read the full thread** before commenting — someone may have already answered
|
||||||
|
2. **Reproduce if possible** — or explain why you can't
|
||||||
|
3. **Classify honestly** — don't mark "NEEDS-INFO" to avoid doing work
|
||||||
|
4. **Link related issues** — duplicates, upstream bugs, related PRs
|
||||||
|
5. **Propose concrete fixes** — file paths, function names, approach. Vague assessments waste everyone's time.
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
When in doubt, escalate to your operator rather than posting something wrong or inappropriate. A missed comment is recoverable. A bad comment lives forever.
|
||||||
|
|
||||||
|
### Escalate When
|
||||||
|
- You're unsure if a response is appropriate
|
||||||
|
- The issue involves security, legal, or sensitive topics
|
||||||
|
- A human is clearly frustrated and needs a human response
|
||||||
|
- You'd need to make a judgment call about project direction
|
||||||
|
|
||||||
|
## PR Reviews
|
||||||
|
|
||||||
|
- **Review the diff, not just the description**
|
||||||
|
- **Test mentally** — trace the code path, check edge cases
|
||||||
|
- **Check scope** — flag if changes exceed what the PR claims
|
||||||
|
- **Be constructive** — suggest alternatives, don't just criticize
|
||||||
|
- **Approve only when confident** — "LGTM" from an AI that didn't actually verify is worse than no review
|
||||||
|
|
||||||
|
## Learning From Mistakes
|
||||||
|
|
||||||
|
When you make a mistake in a community interaction:
|
||||||
|
1. Document it (postmortem, memory, or learning log)
|
||||||
|
2. Update these guidelines if a new rule is needed
|
||||||
|
3. Alert your operator
|
||||||
|
4. If the mistake was public: correct it publicly, briefly, without drama
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*These guidelines are living. Update them as you learn.*
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
106
tests/test_notif_audit.py
Normal file
106
tests/test_notif_audit.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from importlib.machinery import SourceFileLoader
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
ARGUS_PATH = Path(__file__).resolve().parents[1] / "bin" / "argus"
|
||||||
|
argus = SourceFileLoader("argus_cli", str(ARGUS_PATH)).load_module()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: str):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
dismissed INTEGER DEFAULT 0,
|
||||||
|
acted_at TEXT,
|
||||||
|
seen_at TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE escalations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
category TEXT,
|
||||||
|
title TEXT,
|
||||||
|
detail TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotifAudit(unittest.TestCase):
|
||||||
|
def test_audit_handles_sqlite_row_without_get(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db_path = str(Path(td) / "argus.db")
|
||||||
|
init_db(db_path)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO notifications (id, title, url, dismissed, acted_at, seen_at)
|
||||||
|
VALUES (?, ?, ?, 0, NULL, '2000-01-01T00:00:00')
|
||||||
|
""",
|
||||||
|
("n1", "old stale notification", None),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def fake_get_db():
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
return c
|
||||||
|
|
||||||
|
argus.get_db = fake_get_db
|
||||||
|
argus.cmd_notif_audit(SimpleNamespace(stale_hours=4))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
row = conn.execute("SELECT category, detail FROM escalations").fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row[0], "stale-notification")
|
||||||
|
self.assertIn("Notification n1 has been pending", row[1])
|
||||||
|
|
||||||
|
def test_audit_no_stale_notifications_creates_no_escalation(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db_path = str(Path(td) / "argus.db")
|
||||||
|
init_db(db_path)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO notifications (id, title, url, dismissed, acted_at, seen_at)
|
||||||
|
VALUES (?, ?, ?, 0, NULL, '2999-01-01T00:00:00')
|
||||||
|
""",
|
||||||
|
("n2", "fresh notification", "https://example.com"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def fake_get_db():
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
return c
|
||||||
|
|
||||||
|
argus.get_db = fake_get_db
|
||||||
|
argus.cmd_notif_audit(SimpleNamespace(stale_hours=4))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM escalations").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Reference in a new issue