diff --git a/.cursor/commands/fetch_notifications.py b/.cursor/commands/fetch_notifications.py new file mode 100644 index 000000000..9a39a22ee --- /dev/null +++ b/.cursor/commands/fetch_notifications.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +# Copyright 2026 - 2026 The PyMC Labs Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +#!/usr/bin/env python3 +""" +Fetch and enrich GitHub notifications. + +This script fetches notifications, enriches them with PR/Issue details, +assigns tags and categories, and saves to JSON. It does NOT generate summaries - +those are generated by the Cursor agent. + +Usage: + python .cursor/commands/fetch_notifications.py [--days 7] + +Output: + .scratch/notifications-enriched.json - Ready for agent summary generation +""" + +import argparse +import json +import subprocess +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from notification_config import get_bot_usernames, get_default_days, get_repo + +# Load configuration (auto-detects from git remote or config file) +REPO = get_repo() +BOT_USERNAMES = get_bot_usernames() + + +def run_gh_command(args): + """Run a gh command and return parsed JSON""" + try: + result = subprocess.run( + ["gh"] + args, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + return None + return json.loads(result.stdout) if result.stdout else None + except Exception as e: + print(f"Warning: gh command failed: {e}", file=sys.stderr) + return None + + +def get_relative_time(updated_at_str): + """Convert ISO timestamp to relative time string""" + updated = datetime.fromisoformat(updated_at_str.replace("Z", "+00:00")) + now = datetime.now(UTC) + diff = now - updated + + if diff.days == 0: + hours = diff.seconds // 3600 + if hours == 0: + minutes = diff.seconds // 60 + return f"{minutes} min ago" if minutes != 1 else "1 min ago" + return f"{hours}h ago" if hours != 1 else "1h ago" + elif diff.days == 1: + return "yesterday" + elif diff.days < 7: + return f"{diff.days}d ago" + else: + return f"{diff.days // 7}w ago" + + +def get_number_from_url(url): + """Extract issue/PR number from API URL""" + if not url: + return None + return url.rstrip("/").split("/")[-1] + + +def get_html_url(notification): + """Generate the GitHub HTML URL for a notification""" + url = notification.get("url") + ntype = notification["type"] + + if not url: + if ntype in ["WorkflowRun", "CheckSuite"]: + return f"https://github.com/{REPO}/actions" + return f"https://github.com/{REPO}" + + number = get_number_from_url(url) + if ntype == "PullRequest": + return f"https://github.com/{REPO}/pull/{number}" + else: + return f"https://github.com/{REPO}/issues/{number}" + + +def get_tags(notification, details): + """Determine smart tags for a notification""" + tags = [] + reason = notification.get("reason", "") + ntype = notification.get("type", "") + + # Check resolved status first + state = details.get("state", "") + is_merged = details.get("mergedAt") is not None + is_resolved = state in ["CLOSED", "MERGED", "closed", "merged"] or is_merged + + # Status from details + if ntype == "PullRequest": + if is_merged: + tags.append(("๐ŸŽ‰", "PR MERGED")) + elif state in ["CLOSED", "closed"]: + tags.append(("โ›”", "PR CLOSED")) + + if details.get("isDraft"): + tags.append(("๐Ÿ“", "DRAFT PR")) + + # Reviews + reviews = details.get("reviews", []) + if reviews: + states = [r.get("state", "") for r in reviews] + if "APPROVED" in states: + tags.append(("โœ…", "PR APPROVED")) + if "CHANGES_REQUESTED" in states: + tags.append(("๐Ÿ”ถ", "CHANGES REQUESTED")) + + # CI status + checks = details.get("statusCheckRollup", []) + if checks: + conclusions = [c.get("conclusion", "") for c in checks if c] + if "FAILURE" in conclusions: + tags.append(("๐Ÿ”ด", "CI FAILED")) + elif "SUCCESS" in conclusions and "FAILURE" not in conclusions: + tags.append(("๐ŸŸข", "CI PASSING")) + elif "PENDING" in conclusions or None in conclusions: + tags.append(("๐ŸŸก", "CI PENDING")) + + elif ntype == "Issue": + if state in ["CLOSED", "closed"]: + tags.append(("โœ…", "ISSUE CLOSED")) + + # Check for human comments (non-bot activity) + comments = details.get("comments", []) + reviews = details.get("reviews", []) + human_comments = [ + c for c in comments if c.get("author", {}).get("login", "") not in BOT_USERNAMES + ] + human_reviews = [ + r + for r in reviews + if r.get("author", {}).get("login", "") not in BOT_USERNAMES and r.get("body") + ] + + has_human_feedback = bool(human_comments or human_reviews) + + # Reason-based tags + if reason == "mention" and not is_resolved: + tags.append(("๐Ÿ”ด", "NEEDS RESPONSE")) + if reason == "review_requested" and not is_resolved: + tags.append(("๐Ÿ”ด", "NEEDS RESPONSE")) + if reason == "author": + tags.append(("๐Ÿ‘ค", "YOUR PR" if ntype == "PullRequest" else "YOUR ISSUE")) + if has_human_feedback: + tags.append(("๐Ÿ’ฌ", "NEW REVIEW COMMENT")) + if reason == "comment": + tags.append(("๐Ÿ’ฌ", "NEW COMMENT")) + if reason == "subscribed": + tags.append(("๐Ÿ‘€", "FYI ONLY")) + + if is_resolved: + tags.append(("๐Ÿ", "RESOLVED")) + + return tags + + +def categorize_notification(notification, tags): + """Determine the category for a notification""" + reason = notification.get("reason", "") + ntype = notification.get("type", "") + tag_names = [t[1] for t in tags] + + # Resolved items go to resolved category + if "RESOLVED" in tag_names: + return "resolved" + + # CI workflow runs go to hidden + if ntype in ["WorkflowRun", "CheckSuite"]: + return "hidden" + + # Requires action + if reason in ["mention", "approval_requested"]: + return "action" + if reason == "review_requested": + return "action" + if "CHANGES REQUESTED" in tag_names: + return "action" + if reason == "author" and any( + t in tag_names for t in ["NEW COMMENT", "NEW REVIEW COMMENT"] + ): + return "action" + + # By type + if ntype == "PullRequest": + return "pr" + elif ntype == "Issue": + return "issue" + + return "watching" + + +def fetch_notifications(days=7): + """Fetch notifications from GitHub API""" + since_date = (datetime.now(UTC) - timedelta(days=days)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + print(f"๐Ÿ“ก Fetching notifications since {since_date}...") + + result = subprocess.run( + [ + "gh", + "api", + "/notifications", + "-X", + "GET", + "-f", + f"since={since_date}", + "--paginate", + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print(f"Error fetching notifications: {result.stderr}", file=sys.stderr) + return [] + + try: + all_notifications = json.loads(result.stdout) + except json.JSONDecodeError: + print("Error parsing notifications JSON", file=sys.stderr) + return [] + + # Filter to CausalPy + notifications = [ + n for n in all_notifications if n.get("repository", {}).get("full_name") == REPO + ] + + print(f"๐Ÿ“ฌ Found {len(notifications)} CausalPy notifications") + return notifications + + +def enrich_notification(notification): + """Enrich a single notification with PR/Issue details""" + ntype = notification.get("subject", {}).get("type", "") + url = notification.get("subject", {}).get("url", "") + number = get_number_from_url(url) if url else None + + # Extract basic fields + enriched = { + "id": notification["id"], + "reason": notification.get("reason", ""), + "type": ntype, + "title": notification.get("subject", {}).get("title", ""), + "url": url, + "updated_at": notification.get("updated_at", ""), + "unread": notification.get("unread", False), + "number": number, + } + + # Fetch details + details = {} + if number: + print(f" ๐Ÿ“‹ Enriching #{number}...") + if ntype == "PullRequest": + details = ( + run_gh_command( + [ + "pr", + "view", + number, + "--repo", + REPO, + "--json", + "state,isDraft,author,reviews,comments,statusCheckRollup,mergedAt,body", + ] + ) + or {} + ) + elif ntype == "Issue": + details = ( + run_gh_command( + [ + "issue", + "view", + number, + "--repo", + REPO, + "--json", + "state,author,labels,comments,body", + ] + ) + or {} + ) + + # Compute derived fields + tags = get_tags(enriched, details) + category = categorize_notification(enriched, tags) + + enriched.update( + { + "details": details, + "tags": tags, + "category": category, + "html_url": get_html_url(enriched), + "relative_time": get_relative_time(enriched["updated_at"]), + "summary": "", # Will be filled by agent + } + ) + + return enriched + + +def main(): + default_days = get_default_days() + parser = argparse.ArgumentParser( + description=f"Fetch GitHub notifications for {REPO}" + ) + parser.add_argument( + "--days", + type=int, + default=default_days, + help=f"Number of days to look back (default: {default_days})", + ) + args = parser.parse_args() + + # Create scratch directory + Path(".scratch").mkdir(exist_ok=True) + + # Fetch notifications + notifications = fetch_notifications(args.days) + + if not notifications: + print("No notifications found.") + # Save empty file + with open(".scratch/notifications-enriched.json", "w") as f: + json.dump([], f) + return + + # Enrich each notification + print("๐Ÿ” Enriching notifications with details...") + enriched = [] + for n in notifications: + enriched.append(enrich_notification(n)) + + # Save to JSON + output_file = ".scratch/notifications-enriched.json" + with open(output_file, "w") as f: + json.dump(enriched, f, indent=2) + + print(f"\nโœ… Saved {len(enriched)} enriched notifications to {output_file}") + print("\n๐Ÿ“Š Summary by category:") + + categories = {} + for n in enriched: + cat = n["category"] + categories[cat] = categories.get(cat, 0) + 1 + + for cat, count in sorted(categories.items(), key=lambda x: -x[1]): + emoji = { + "action": "๐Ÿ”ฅ", + "pr": "๐Ÿ“‹", + "issue": "๐Ÿ“", + "watching": "๐Ÿ‘€", + "resolved": "๐Ÿ", + "hidden": "๐Ÿ™ˆ", + }.get(cat, "") + print(f" {emoji} {cat}: {count}") + + +if __name__ == "__main__": + main() diff --git a/.cursor/commands/generate_summaries.py b/.cursor/commands/generate_summaries.py new file mode 100644 index 000000000..d38b17ce2 --- /dev/null +++ b/.cursor/commands/generate_summaries.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +# Copyright 2026 - 2026 The PyMC Labs Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +#!/usr/bin/env python3 +""" +Generate comprehensive AI-style summaries for notifications. + +This creates detailed, actionable summaries that help users decide +which notifications to prioritize. +""" + +import json + +from notification_config import get_bot_usernames + +# Load configuration +BOT_USERNAMES = get_bot_usernames() + + +def generate_summary(n): + """Generate a comprehensive summary for a notification.""" + reason = n.get("reason", "") + category = n.get("category", "") + title = n.get("title", "") + ntype = n.get("type", "") + details = n.get("details", {}) + tags = [t[1] for t in n.get("tags", [])] + + body = details.get("body", "") or "" + comments = details.get("comments", []) + reviews = details.get("reviews", []) + + # Filter to human comments only + human_comments = [ + c for c in comments if c.get("author", {}).get("login", "") not in BOT_USERNAMES + ] + human_reviews = [ + r + for r in reviews + if r.get("author", {}).get("login", "") not in BOT_USERNAMES and r.get("body") + ] + + summary_parts = [] + + # === RESOLVED ITEMS === + if category == "resolved": + state = details.get("state", "") + if details.get("mergedAt"): + summary_parts.append("**Status:** โœ… Merged") + summary_parts.append("") + summary_parts.append( + "This PR has been successfully merged. No further action required." + ) + if body: + summary_parts.append("") + summary_parts.append("**What it did:**") + body_preview = body[:300].replace("\n", " ").strip() + summary_parts.append(f"> {body_preview}...") + elif state in ["CLOSED", "closed"]: + summary_parts.append("**Status:** โ›” Closed") + summary_parts.append("") + summary_parts.append( + f"This {ntype.lower()} was closed without merging. No action needed." + ) + else: + summary_parts.append("**Status:** โœ… Resolved") + return "\n".join(summary_parts) + + # === HIDDEN ITEMS === + if category == "hidden": + return "**Status:** ๐Ÿ™ˆ CI/Workflow Activity\n\nThis is automated CI activity. Usually no action needed unless investigating build failures." + + # === ACTION ITEMS === + if reason == "mention": + summary_parts.append("**๐Ÿ”” You were mentioned**") + summary_parts.append("") + + if human_comments: + last = human_comments[-1] + author = last.get("author", {}).get("login", "Someone") + comment_body = last.get("body", "") + summary_parts.append(f"**{author}** said:") + summary_parts.append(f"> {comment_body[:500]}") + summary_parts.append("") + + # Check if it's a question + if "?" in comment_body or "what do you think" in comment_body.lower(): + summary_parts.append( + "**Action Required:** This appears to be a question directed at you. Please review and respond." + ) + else: + summary_parts.append( + "**Action Required:** You were mentioned - review the context and respond if needed." + ) + + elif human_reviews: + last = human_reviews[-1] + author = last.get("author", {}).get("login", "Someone") + review_body = last.get("body", "") + summary_parts.append(f"**{author}** mentioned you in a review:") + summary_parts.append(f"> {review_body[:500]}") + summary_parts.append("") + summary_parts.append( + "**Action Required:** Review the feedback and respond." + ) + else: + summary_parts.append("You were mentioned in this discussion.") + summary_parts.append("") + summary_parts.append( + "**Action Required:** Check the thread for context and respond if needed." + ) + + elif reason == "review_requested": + summary_parts.append("**๐Ÿ“‹ Review Requested**") + summary_parts.append("") + + if body: + # Extract key info from PR body + body_lines = body.split("\n") + body_preview = "\n".join(body_lines[:10]) + summary_parts.append("**What this PR does:**") + summary_parts.append(f"> {body_preview[:600]}") + summary_parts.append("") + + # Check CI status + if "CI FAILED" in tags: + summary_parts.append( + "โš ๏ธ **CI is failing** - you may want to wait for fixes before reviewing." + ) + summary_parts.append("") + + if "DRAFT PR" in tags: + summary_parts.append( + "๐Ÿ“ **This is a draft PR** - the author may still be working on it." + ) + summary_parts.append("") + + summary_parts.append( + "**Action Required:** Review this PR and provide feedback (approve, request changes, or comment)." + ) + + elif reason == "author": + summary_parts.append(f"**๐Ÿ“ฃ Activity on your {ntype}**") + summary_parts.append("") + + # Check for changes requested + if "CHANGES REQUESTED" in tags: + summary_parts.append("**๐Ÿ”ถ Changes Requested**") + summary_parts.append("") + for r in human_reviews[-3:]: + if r.get("state") == "CHANGES_REQUESTED": + author = r.get("author", {}).get("login", "") + review_body = r.get("body", "") + if review_body: + summary_parts.append(f"**{author}** requested changes:") + summary_parts.append(f"> {review_body[:400]}") + summary_parts.append("") + summary_parts.append( + "**Action Required:** Address the requested changes and update the PR." + ) + + elif "PR APPROVED" in tags: + summary_parts.append("**โœ… Your PR has been approved!**") + summary_parts.append("") + approvers = [ + r.get("author", {}).get("login", "") + for r in reviews + if r.get("state") == "APPROVED" + ] + if approvers: + summary_parts.append(f"Approved by: {', '.join(approvers)}") + summary_parts.append("") + summary_parts.append("**Action:** Merge when ready (if CI is passing).") + + elif "CI FAILED" in tags: + summary_parts.append("**๐Ÿ”ด CI Failed**") + summary_parts.append("") + summary_parts.append("Your PR has failing CI checks.") + summary_parts.append("") + summary_parts.append( + "**Action Required:** Check the CI logs and fix the failing tests/checks." + ) + + elif human_comments or human_reviews: + summary_parts.append("**๐Ÿ’ฌ New Feedback**") + summary_parts.append("") + + # Show recent human activity + all_activity = [] + for c in human_comments[-3:]: + all_activity.append(("comment", c)) + for r in human_reviews[-2:]: + if r.get("body"): + all_activity.append(("review", r)) + + for atype, item in all_activity[-3:]: + author = item.get("author", {}).get("login", "Someone") + body_text = item.get("body", "")[:300] + if atype == "review": + state = item.get("state", "") + state_emoji = {"APPROVED": "โœ…", "CHANGES_REQUESTED": "๐Ÿ”ถ"}.get( + state, "๐Ÿ’ฌ" + ) + summary_parts.append(f"**{author}** ({state_emoji} review):") + else: + summary_parts.append(f"**{author}** commented:") + summary_parts.append(f"> {body_text}") + summary_parts.append("") + + summary_parts.append( + "**Action:** Review the feedback and respond if needed." + ) + + else: + summary_parts.append( + f"There's activity on your {ntype.lower()}, but no specific feedback to highlight." + ) + + elif reason == "subscribed": + summary_parts.append("**๐Ÿ‘€ Watching**") + summary_parts.append("") + if body: + body_preview = body[:400].replace("\n", " ") + summary_parts.append(f"> {body_preview}") + summary_parts.append("") + summary_parts.append( + "**FYI only** - you're watching this thread. No action required unless you want to participate." + ) + + else: + # Generic notification + if body: + body_preview = body[:500].replace("\n", " ") + summary_parts.append(body_preview) + else: + summary_parts.append(f"Notification about: {title}") + + return "\n".join(summary_parts) if summary_parts else f"Notification about: {title}" + + +def main(): + from pathlib import Path + + # Get the project root (parent of .cursor/commands) + script_dir = Path(__file__).parent + project_root = script_dir.parent.parent + scratch_dir = project_root / ".scratch" + + # Load enriched notifications + input_file = scratch_dir / "notifications-enriched.json" + with open(input_file) as f: + notifications = json.load(f) + + print( + f"Generating comprehensive summaries for {len(notifications)} notifications..." + ) + + # Generate summaries + for n in notifications: + n["summary"] = generate_summary(n) + preview = n["summary"][:60].replace("\n", " ") + print(f" #{n['number']}: {preview}...") + + # Save with summaries + output_file = scratch_dir / "notifications-with-summaries.json" + with open(output_file, "w") as f: + json.dump(notifications, f, indent=2) + + print(f"\nโœ… Saved to {output_file}") + + +if __name__ == "__main__": + main() diff --git a/.cursor/commands/notification_bots.yml b/.cursor/commands/notification_bots.yml new file mode 100644 index 000000000..36a46b043 --- /dev/null +++ b/.cursor/commands/notification_bots.yml @@ -0,0 +1,32 @@ +# Bot Blacklist for GitHub Notification Summary +# +# Notifications from these usernames are filtered out (ignored). +# This helps reduce noise from automated bot comments. +# +# Format: List of GitHub usernames (case-insensitive matching) +# Bot accounts typically end with [bot] suffix + +# Code coverage bots +- codecov +- codecov[bot] +- codecov-commenter + +# CI/CD bots +- pre-commit-ci +- pre-commit-ci[bot] +- github-actions +- github-actions[bot] + +# Dependency update bots +- dependabot +- dependabot[bot] +- renovate +- renovate[bot] + +# Review/notebook bots +- review-notebook-app +- review-notebook-app[bot] + +# Add your custom bots below: +# - my-custom-bot +# - another-bot[bot] diff --git a/.cursor/commands/notification_config.py b/.cursor/commands/notification_config.py new file mode 100644 index 000000000..1258c5545 --- /dev/null +++ b/.cursor/commands/notification_config.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# Copyright 2026 - 2026 The PyMC Labs Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Configuration for the GitHub notification summary command. + +Provides auto-detection of org/repo from git remote, with optional +config file overrides for customization. + +Configuration files: +- .cursor/commands/notification_bots.yml - Bot blacklist (usernames to IGNORE) +- .cursor/notification_config.json - General settings (optional) +""" + +import json +import re +import subprocess +from pathlib import Path + +# Default settings +DEFAULT_DAYS = 7 +DEFAULT_SERVER_PORT = 8765 + +# Fallback bot blacklist (used if YAML file not found) +FALLBACK_BOT_BLACKLIST = { + "codecov", + "codecov[bot]", + "codecov-commenter", + "pre-commit-ci", + "pre-commit-ci[bot]", + "dependabot", + "dependabot[bot]", + "github-actions", + "github-actions[bot]", + "renovate", + "renovate[bot]", + "review-notebook-app[bot]", + "review-notebook-app", +} + + +def get_project_root() -> Path: + """Get the project root directory (where .git is located).""" + # Start from this file's location and go up + current = Path(__file__).resolve().parent + while current != current.parent: + if (current / ".git").exists(): + return current + current = current.parent + # Fallback to cwd + return Path.cwd() + + +def get_commands_dir() -> Path: + """Get the .cursor/commands directory.""" + return Path(__file__).resolve().parent + + +def load_bot_blacklist() -> set[str]: + """ + Load the bot blacklist from notification_bots.yml. + + This is a BLACKLIST - notifications from these usernames are IGNORED. + Helps filter out noise from automated bot comments (codecov, dependabot, etc.) + + Returns: + Set of usernames to ignore (case-sensitive as GitHub usernames are). + """ + commands_dir = get_commands_dir() + bots_file = commands_dir / "notification_bots.yml" + + if bots_file.exists(): + try: + # Simple YAML list parsing (avoids PyYAML dependency) + bots = set() + with open(bots_file) as f: + for line in f: + line = line.strip() + # Skip comments and empty lines + if not line or line.startswith("#"): + continue + # Parse list items (- username) + if line.startswith("- "): + username = line[2:].strip() + # Remove inline comments + if "#" in username: + username = username.split("#")[0].strip() + if username: + bots.add(username) + if bots: + return bots + except Exception as e: + print(f"Warning: Failed to load bot blacklist from {bots_file}: {e}") + + # Fallback to hardcoded defaults + return FALLBACK_BOT_BLACKLIST.copy() + + +def parse_git_remote(remote_url: str) -> tuple[str, str] | None: + """ + Parse org and repo from a git remote URL. + + Supports: + - https://github.com/org/repo.git + - https://github.com/org/repo + - git@github.com:org/repo.git + - git@github.com:org/repo + """ + # HTTPS format + https_match = re.match( + r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?$", remote_url + ) + if https_match: + return https_match.group(1), https_match.group(2) + + # SSH format + ssh_match = re.match(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", remote_url) + if ssh_match: + return ssh_match.group(1), ssh_match.group(2) + + return None + + +def detect_repo_from_git() -> str | None: + """Auto-detect org/repo from git remote origin.""" + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + remote_url = result.stdout.strip() + parsed = parse_git_remote(remote_url) + if parsed: + org, repo = parsed + return f"{org}/{repo}" + except Exception: + pass + return None + + +def load_config() -> dict: + """ + Load configuration with the following priority: + 1. .cursor/notification_config.json (if exists) - for repo, days, port + 2. .cursor/commands/notification_bots.yml - for bot blacklist + 3. Auto-detect repo from git remote + 4. Built-in defaults + + Returns a dict with: + - repo: str (e.g., "pymc-labs/CausalPy") + - bot_blacklist: set[str] - usernames to IGNORE + - default_days: int + - server_port: int + """ + project_root = get_project_root() + config_file = project_root / ".cursor" / "notification_config.json" + + # Start with defaults + config = { + "repo": None, + "bot_blacklist": load_bot_blacklist(), + "default_days": DEFAULT_DAYS, + "server_port": DEFAULT_SERVER_PORT, + } + + # Try to load JSON config file for general settings + if config_file.exists(): + try: + with open(config_file) as f: + user_config = json.load(f) + + # Override with user settings + if "repo" in user_config: + config["repo"] = user_config["repo"] + if "default_days" in user_config: + config["default_days"] = user_config["default_days"] + if "server_port" in user_config: + config["server_port"] = user_config["server_port"] + # Legacy support: bot_usernames in JSON extends the blacklist + if "bot_usernames" in user_config: + config["bot_blacklist"].update(user_config["bot_usernames"]) + except Exception as e: + print(f"Warning: Failed to load config file: {e}") + + # Auto-detect repo if not specified + if not config["repo"]: + detected = detect_repo_from_git() + if detected: + config["repo"] = detected + + return config + + +def get_repo() -> str: + """Get the repository in org/repo format.""" + config = load_config() + repo = config.get("repo") + if not repo: + raise ValueError( + "Could not determine repository. Either:\n" + "1. Run from a git repository with a GitHub remote, or\n" + '2. Create .cursor/notification_config.json with: {"repo": "org/repo"}' + ) + return repo + + +def get_bot_blacklist() -> set[str]: + """ + Get the set of bot usernames to IGNORE (blacklist). + + Notifications from these usernames are filtered out to reduce noise. + Edit .cursor/commands/notification_bots.yml to customize. + """ + return load_config()["bot_blacklist"] + + +# Alias for backwards compatibility +def get_bot_usernames() -> set[str]: + """Alias for get_bot_blacklist() - returns usernames to ignore.""" + return get_bot_blacklist() + + +def get_default_days() -> int: + """Get the default number of days to look back.""" + return load_config()["default_days"] + + +def get_server_port() -> int: + """Get the server port.""" + return load_config()["server_port"] + + +if __name__ == "__main__": + # Test the config + print("=" * 60) + print("GitHub Notification Summary - Configuration") + print("=" * 60) + config = load_config() + print(f"\n๐Ÿ“ฆ Repository: {config['repo'] or '(not detected)'}") + print(f"๐Ÿ“… Default days: {config['default_days']}") + print(f"๐ŸŒ Server port: {config['server_port']}") + print(f"\n๐Ÿค– Bot Blacklist ({len(config['bot_blacklist'])} usernames to IGNORE):") + for bot in sorted(config["bot_blacklist"]): + print(f" - {bot}") + print("\n๐Ÿ“ Config files:") + print(f" - Bot blacklist: {get_commands_dir() / 'notification_bots.yml'}") + print( + f" - General config: {get_project_root() / '.cursor/notification_config.json'}" + ) diff --git a/.cursor/commands/notification_server.py b/.cursor/commands/notification_server.py new file mode 100644 index 000000000..7acd576ed --- /dev/null +++ b/.cursor/commands/notification_server.py @@ -0,0 +1,1066 @@ +#!/usr/bin/env python3 +# Copyright 2026 - 2026 The PyMC Labs Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +#!/usr/bin/env python3 +""" +Local web server for interactive GitHub notification management. + +Serves the notification summary as an HTML page with "Mark Done" buttons +that call the GitHub API to archive notifications. + +Usage: + python .cursor/commands/notification_server.py [--port 8765] [--no-open] + +Prerequisites: + Run fetch_notifications.py first to create the enriched JSON file. + Optionally, have the Cursor agent add AI-generated summaries. + +The server will: +1. Load notifications from .scratch/notifications-with-summaries.json + (or .scratch/notifications-enriched.json as fallback) +2. Serve an interactive HTML page with tabs for each category +3. Handle "Mark Done" API calls via DELETE /notifications/threads/{id} +""" + +import argparse +import json +import subprocess +import sys +import webbrowser +from datetime import UTC, datetime +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +from urllib.parse import urlparse + +from notification_config import get_bot_usernames, get_repo, get_server_port + +# Load configuration (auto-detects from git remote or config file) +REPO = get_repo() +DEFAULT_PORT = get_server_port() +BOT_USERNAMES = get_bot_usernames() + + +def run_gh_command(args): + """Run a gh command and return parsed JSON""" + try: + result = subprocess.run( + ["gh"] + args, capture_output=True, text=True, timeout=30 + ) + if result.returncode != 0: + return None + return json.loads(result.stdout) if result.stdout else None + except Exception: + return None + + +def get_relative_time(updated_at_str): + """Convert ISO timestamp to relative time string""" + updated = datetime.fromisoformat(updated_at_str.replace("Z", "+00:00")) + now = datetime.now(UTC) + diff = now - updated + + if diff.days == 0: + hours = diff.seconds // 3600 + if hours == 0: + minutes = diff.seconds // 60 + return f"{minutes} min ago" if minutes != 1 else "1 min ago" + return f"{hours}h ago" if hours != 1 else "1h ago" + elif diff.days == 1: + return "yesterday" + elif diff.days < 7: + return f"{diff.days}d ago" + else: + return f"{diff.days // 7}w ago" + + +def get_number_from_url(url): + """Extract issue/PR number from API URL""" + if not url: + return None + return url.rstrip("/").split("/")[-1] + + +def get_html_url(notification): + """Generate the GitHub HTML URL for a notification""" + url = notification.get("url") + ntype = notification["type"] + + if not url: + if ntype in ["WorkflowRun", "CheckSuite"]: + return f"https://github.com/{REPO}/actions" + return f"https://github.com/{REPO}" + + number = get_number_from_url(url) + if ntype == "PullRequest": + return f"https://github.com/{REPO}/pull/{number}" + else: + return f"https://github.com/{REPO}/issues/{number}" + + +def get_tags(notification, details): + """Determine smart tags for a notification""" + tags = [] + reason = notification["reason"] + ntype = notification["type"] + + # Handle CI/Workflow notifications + if ntype in ["WorkflowRun", "CheckSuite"]: + title = notification.get("title", "").lower() + if "failed" in title: + tags.append(("๐Ÿ”ด", "CI FAILED")) + elif "cancelled" in title: + tags.append(("โšช", "CI CANCELLED")) + if reason == "approval_requested": + tags.append(("๐Ÿ”ด", "NEEDS RESPONSE")) + else: + tags.append(("๐Ÿ”ง", "CI ACTIVITY")) + return tags + + # Action-required tags + if reason == "mention": + tags.append(("๐Ÿ”ด", "NEEDS RESPONSE")) + if reason == "review_requested": + tags.append(("๐Ÿ”ด", "NEEDS RESPONSE")) + + # Author tag + if reason == "author": + tags.append(("๐Ÿ‘ค", "YOUR PR" if ntype == "PullRequest" else "YOUR ISSUE")) + + if ntype == "PullRequest" and details: + # Merged/Closed status + if details.get("mergedAt"): + tags.append(("๐ŸŽ‰", "PR MERGED")) + tags.append(("๐Ÿ", "RESOLVED")) + elif details.get("state") == "CLOSED": + tags.append(("โ›”", "PR CLOSED")) + tags.append(("๐Ÿ", "RESOLVED")) + + # Review status + reviews = details.get("reviews", []) + if reviews: + if any(r.get("state") == "APPROVED" for r in reviews): + tags.append(("โœ…", "PR APPROVED")) + if any(r.get("state") == "CHANGES_REQUESTED" for r in reviews): + tags.append(("๐Ÿ”ถ", "CHANGES REQUESTED")) + + # CI status + checks = details.get("statusCheckRollup", []) + if checks: + conclusions = [c.get("conclusion") or c.get("state") for c in checks] + conclusions = [ + c for c in conclusions if c and c not in ["SKIPPED", "NEUTRAL"] + ] + if conclusions: + if any(c in ["FAILURE", "ERROR"] for c in conclusions): + tags.append(("๐Ÿ”ด", "CI FAILED")) + elif all(c in ["SUCCESS"] for c in conclusions): + tags.append(("๐ŸŸข", "CI PASSING")) + + # Human feedback + if reason == "author": + reviews = details.get("reviews", []) + human_reviews = [ + r + for r in reviews + if r.get("author", {}).get("login", "") not in BOT_USERNAMES + ] + if human_reviews and human_reviews[-1].get("state") == "COMMENTED": + tags.append(("๐Ÿ’ฌ", "NEW REVIEW COMMENT")) + + elif ntype == "Issue" and details: + if details.get("state") == "CLOSED": + tags.append(("โœ…", "ISSUE CLOSED")) + tags.append(("๐Ÿ", "RESOLVED")) + + # Comment tag + if reason == "comment": + tags.append(("๐Ÿ’ฌ", "NEW COMMENT")) + + # FYI only + if reason == "subscribed" and not any(t[0] in ["๐Ÿ”ด", "๐Ÿ”ถ"] for t in tags): + tags.append(("๐Ÿ‘€", "FYI ONLY")) + + return tags + + +def categorize_notification(notification, tags): + """Determine which category a notification belongs to""" + reason = notification["reason"] + ntype = notification["type"] + tag_names = [t[1] for t in tags] + is_resolved = "RESOLVED" in tag_names + + # Hidden + if ntype in ["WorkflowRun", "CheckSuite"] and reason != "approval_requested": + return "hidden" + + # Bot comments go to watching + if "BOT COMMENT" in tag_names: + return "watching" + + # Mentions always need response + if reason == "mention": + return "action" + + # Resolved items + if is_resolved: + return "resolved" + + # Review requests + if reason == "review_requested": + return "action" + + # Approval requests + if reason == "approval_requested": + return "action" + + # Changes requested + if "CHANGES REQUESTED" in tag_names: + return "action" + + # Author with feedback + if reason == "author" and any( + t in tag_names for t in ["NEW COMMENT", "NEW REVIEW COMMENT"] + ): + return "action" + + # By type + if ntype == "PullRequest": + return "pr" + elif ntype == "Issue": + return "issue" + + return "watching" + + +def load_notifications(): + """Load pre-enriched notifications from JSON. + + Expects notifications to be already enriched by fetch_notifications.py + and summaries to be added by the Cursor agent. + """ + # Try the enriched file first (new workflow) + enriched_file = ".scratch/notifications-with-summaries.json" + fallback_file = ".scratch/notifications-enriched.json" + + try: + with open(enriched_file) as f: + notifications = json.load(f) + print(f"๐Ÿ“‚ Loaded from {enriched_file}") + except FileNotFoundError: + try: + with open(fallback_file) as f: + notifications = json.load(f) + print(f"๐Ÿ“‚ Loaded from {fallback_file} (no summaries yet)") + except FileNotFoundError: + print("โŒ No notification file found. Run fetch_notifications.py first.") + return [] + + # Update relative times (they may be stale) + for n in notifications: + if n.get("updated_at"): + n["relative_time"] = get_relative_time(n["updated_at"]) + + return notifications + + +def is_action_required(n): + """Determine if a notification requires action.""" + reason = n.get("reason", "") + tags = [t[1] for t in n.get("tags", [])] + + # Direct action triggers + if reason in ["mention", "review_requested", "approval_requested"]: + return True + if "CHANGES REQUESTED" in tags: + return True + return reason == "author" and any( + t in tags for t in ["NEW COMMENT", "NEW REVIEW COMMENT"] + ) + + +def generate_html(notifications): + """Generate HTML page for notifications""" + # Categorize by TYPE first, then we'll split by action within each type + categories = {"pr": [], "issue": [], "watching": [], "resolved": [], "hidden": []} + + for n in notifications: + cat = n["category"] + # Map old "action" category back to pr/issue based on type + if cat == "action": + if n["type"] == "PullRequest": + categories["pr"].append(n) + elif n["type"] == "Issue": + categories["issue"].append(n) + else: + categories["watching"].append(n) + else: + categories[cat].append(n) + + # Mark each notification as action_required + for cat in categories.values(): + for n in cat: + n["action_required"] = is_action_required(n) + + # Sort by updated_at + for cat in categories.values(): + cat.sort(key=lambda x: x["updated_at"], reverse=True) + + html = f""" + + + + + ๐Ÿ”” GitHub Notifications: {REPO} + + + + +
+
+

๐Ÿ”” GitHub Notifications: {REPO}

+ +
+ +
+ + + + +
+""" + + def render_notification(n, idx): + import html as html_module + + tags_html = "" + for emoji, name in n["tags"]: + css_class = "tag" + if "FAILED" in name or "NEEDS" in name: + css_class += " danger" + elif "CHANGES" in name: + css_class += " warning" + elif "PASSING" in name or "APPROVED" in name or "MERGED" in name: + css_class += " success" + tags_html += f'{emoji} {name}' + + type_prefix = "PR" if n["type"] == "PullRequest" else "Issue" + title = ( + f"{type_prefix} #{n['number']}: {n['title']}" + if n.get("number") + else n["title"] + ) + + # Get summary - store as data attribute for markdown rendering + summary = n.get("summary", "") + # Escape for HTML attribute (not content) + summary_escaped = html_module.escape(summary).replace('"', """) + action_class = " action-required" if n.get("category") == "action" else "" + summary_html = ( + f'
' + if summary + else "" + ) + + return f''' +
+
+
+ {title} +
{n["relative_time"]} ยท {n.get("reason", "").replace("_", " ")}
+
{tags_html}
+ {summary_html} +
+
+ +
+
+
+''' + + def render_tab_with_subgroups(tab_id, items, is_active=False): + """Render a tab panel with Action Required and FYI subgroups.""" + active_class = " active" if is_active else "" + html_out = f'
' + + if not items: + html_out += '
No notifications
' + html_out += "
" + return html_out + + # Split into action required vs FYI + action_items = [n for n in items if n.get("action_required")] + fyi_items = [n for n in items if not n.get("action_required")] + + # Sort each by updated_at + action_items.sort(key=lambda x: x["updated_at"], reverse=True) + fyi_items.sort(key=lambda x: x["updated_at"], reverse=True) + + if action_items: + html_out += f'
๐Ÿ”ด Action Required {len(action_items)}
' + for i, n in enumerate(action_items): + html_out += render_notification(n, i) + + if fyi_items: + html_out += f'
๐Ÿ‘€ FYI {len(fyi_items)}
' + for i, n in enumerate(fyi_items): + html_out += render_notification(n, i) + + html_out += "
" + return html_out + + # Tab: PRs + html += render_tab_with_subgroups("pr", categories["pr"], is_active=True) + + # Tab: Issues + html += render_tab_with_subgroups("issue", categories["issue"]) + + # Tab: Watching (all FYI by nature) + html += '
' + if categories["watching"]: + categories["watching"].sort(key=lambda x: x["updated_at"], reverse=True) + for i, n in enumerate(categories["watching"]): + html += render_notification(n, i) + else: + html += '
No watched notifications
' + html += "
" + + # Tab: Resolved + html += '
' + if categories["resolved"]: + html += """ + +""" + for n in categories["resolved"]: + number = n.get("number", "?") + title_truncated = n["title"][:50] + ("..." if len(n["title"]) > 50 else "") + html += f''' + + + + ''' + html += "
#TitleActions
#{number}{title_truncated}
" + else: + html += '
No resolved items
' + html += "
" + + # Hidden info + if categories["hidden"]: + html += f'
๐Ÿ™ˆ {len(categories["hidden"])} notification(s) hidden (CI activity)
' + + html += """ + + + + +""" + return html + + +class NotificationHandler(SimpleHTTPRequestHandler): + """HTTP handler for notification server""" + + notifications = [] + + def do_GET(self): + if self.path == "/" or self.path == "/notifications": + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + html = generate_html(self.notifications) + self.wfile.write(html.encode()) + elif self.path == "/api/refresh-stream": + self.handle_refresh_stream() + else: + self.send_error(404) + + def send_sse(self, status, message): + """Send a Server-Sent Event.""" + import json + + data = json.dumps({"status": status, "message": message}) + self.wfile.write(f"data: {data}\n\n".encode()) + self.wfile.flush() + + def handle_refresh_stream(self): + """Handle refresh with streaming progress updates.""" + import os + + self.send_response(200) + self.send_header("Content-type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.end_headers() + + # Get the project root (parent of .cursor/commands) + project_root = Path(__file__).parent.parent.parent + + try: + # Step 1: Fetching from GitHub + self.send_sse("progress", "โณ Fetching from GitHub...") + + result = subprocess.run( + [ + "python", + str(project_root / ".cursor/commands/fetch_notifications.py"), + "--days", + "7", + ], + capture_output=True, + text=True, + cwd=str(project_root), + env=os.environ.copy(), # Pass full environment for gh auth + ) + if result.returncode != 0: + print(f"Fetch error: {result.stderr}") + self.send_sse("error", "โŒ Failed to fetch") + return + + # Step 2: Enriching notifications + self.send_sse("progress", "๐Ÿ“‹ Enriching notifications...") + + # Count notifications + try: + with open(project_root / ".scratch/notifications-enriched.json") as f: + import json + + notifications = json.load(f) + count = len(notifications) + except Exception: + count = "?" + + # Step 3: Generating summaries + self.send_sse("progress", f"๐Ÿค– Generating summaries ({count} items)...") + + result = subprocess.run( + [ + "python", + str(project_root / ".cursor/commands/generate_summaries.py"), + ], + capture_output=True, + text=True, + cwd=str(project_root), + env=os.environ.copy(), + ) + if result.returncode != 0: + print(f"Summary error: {result.stderr}") + self.send_sse("error", "โŒ Failed to generate summaries") + return + + # Step 4: Reloading + self.send_sse("progress", "๐Ÿ”„ Reloading data...") + + # Reload notifications + NotificationHandler.notifications = load_notifications() + + # Done! + self.send_sse("done", "โœ… Done! Reloading...") + + except Exception as e: + print(f"Error during refresh: {e}") + self.send_sse("error", f"โŒ Error: {str(e)[:30]}") + + def do_POST(self): + parsed = urlparse(self.path) + if parsed.path.startswith("/api/done/"): + thread_id = parsed.path.split("/")[-1] + success = self.mark_notification_done(thread_id) + if success: + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(b'{"status": "ok"}') + else: + self.send_error(500, "Failed to mark as done") + elif parsed.path == "/api/refresh": + success = self.refresh_notifications() + if success: + # Reload the notifications + NotificationHandler.notifications = load_notifications() + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + count = len(NotificationHandler.notifications) + self.wfile.write(f'{{"status": "ok", "count": {count}}}'.encode()) + else: + self.send_error(500, "Failed to refresh notifications") + else: + self.send_error(404) + + def refresh_notifications(self): + """Fetch fresh notifications from GitHub and regenerate summaries.""" + print("๐Ÿ”„ Refreshing notifications from GitHub...") + + # Run fetch script + result = subprocess.run( + ["python", ".cursor/commands/fetch_notifications.py", "--days", "7"], + capture_output=True, + text=True, + cwd=Path(".").resolve(), + ) + if result.returncode != 0: + print(f"โŒ Fetch failed: {result.stderr}") + return False + + print("๐Ÿ“ Generating summaries...") + + # Run summary generation + result = subprocess.run( + ["python", "generate_summaries.py"], + capture_output=True, + text=True, + cwd=Path(".scratch").resolve(), + ) + if result.returncode != 0: + print(f"โŒ Summary generation failed: {result.stderr}") + return False + + print("โœ… Refresh complete!") + return True + + def mark_notification_done(self, thread_id): + """Mark a notification as Done (archived) via GitHub API. + + Uses: DELETE /notifications/threads/{thread_id} + Docs: https://docs.github.com/en/rest/activity/notifications#mark-a-thread-as-done + + Discussion about this API feature: + https://github.com/orgs/community/discussions/50224 + """ + print(f"๐Ÿ“ค Marking notification {thread_id} as done...") + + # DELETE marks the thread as "Done" (archived) + result = subprocess.run( + ["gh", "api", "-X", "DELETE", f"/notifications/threads/{thread_id}"], + capture_output=True, + ) + print(f" DELETE result: returncode={result.returncode}") + + # DELETE returns 204 No Content on success + success = result.returncode == 0 + print(f" Success: {success}") + return success + + def log_message(self, format, *args): + # Suppress default logging + pass + + +def main(): + parser = argparse.ArgumentParser(description="Notification server") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--no-open", action="store_true", help="Don't open browser") + args = parser.parse_args() + + print("๐Ÿ”„ Loading notifications...", file=sys.stderr) + NotificationHandler.notifications = load_notifications() + print( + f"๐Ÿ“Š Loaded {len(NotificationHandler.notifications)} notifications", + file=sys.stderr, + ) + + server = HTTPServer(("localhost", args.port), NotificationHandler) + url = f"http://localhost:{args.port}/" + + print(f"๐Ÿš€ Server running at {url}", file=sys.stderr) + print(" Press Ctrl+C to stop", file=sys.stderr) + + if not args.no_open: + webbrowser.open(url) + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n๐Ÿ‘‹ Server stopped", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/.cursor/commands/notification_summary.md b/.cursor/commands/notification_summary.md new file mode 100644 index 000000000..869821b5d --- /dev/null +++ b/.cursor/commands/notification_summary.md @@ -0,0 +1,237 @@ +# GitHub Notifications Summary + +Generate a morning briefing of GitHub notifications for the current repository with AI-generated summaries. + +## Purpose + +This command fetches your GitHub notifications, analyzes them for actionability, generates intelligent AI summaries, and presents an interactive web UI. Think of it as your "inbox zero" tool for GitHub. + +## Configuration + +The command **auto-detects** the repository from your git remote. No configuration needed for most users! + +### Bot Blacklist (YAML) + +Edit `.cursor/commands/notification_bots.yml` to customize which bots to **ignore**: + +```yaml +# Bot Blacklist - notifications from these usernames are IGNORED +# (This is a blacklist, not a whitelist) + +# Code coverage bots +- codecov +- codecov[bot] + +# CI/CD bots +- pre-commit-ci +- github-actions[bot] + +# Add your custom bots: +- my-custom-bot +``` + +### General Settings (JSON) + +Create `.cursor/notification_config.json` for other settings: + +```json +{ + "repo": "org/repo", + "default_days": 7, + "server_port": 8765 +} +``` + +| Setting | Default | Description | +|---------|---------|-------------| +| `repo` | Auto-detected from git remote | Override repository (e.g., "pymc-labs/CausalPy") | +| `default_days` | 7 | Days to look back for notifications | +| `server_port` | 8765 | Local server port | + +### Config Files Summary + +| File | Purpose | +|------|---------| +| `.cursor/commands/notification_bots.yml` | **Bot blacklist** - usernames to IGNORE | +| `.cursor/notification_config.json` | General settings (repo, days, port) | + +## Parameters + +When invoking this command, the user may optionally specify: +- **Time window**: How far back to look (e.g., "last 24 hours", "last 7 days") + - Default: Last 7 days if not specified + +## Prerequisites + +Before starting, verify the GitHub CLI is available: + +```bash +gh --version +gh auth status +``` + +If not authenticated, guide: `gh auth login` + +**Important:** All `gh` commands require `required_permissions: ["all"]` + +## Workflow + +### Phase 1: Parse User Request + +Determine the time window from the user's request: +- "last 24 hours" or "today" โ†’ 1 day +- "last 3 days" โ†’ 3 days +- "last week" or "last 7 days" โ†’ 7 days (default) +- "last 2 weeks" โ†’ 14 days +- "last month" โ†’ 30 days + +### Phase 2: Fetch and Enrich Notifications + +Run the fetch script to get notifications and enrich them with PR/Issue details: + +```bash +python .cursor/commands/fetch_notifications.py --days DAYS +``` + +This creates `.scratch/notifications-enriched.json` with: +- Basic notification info (id, reason, type, title) +- PR/Issue details (state, reviews, comments, CI status) +- Smart tags (NEEDS RESPONSE, PR MERGED, CI FAILED, etc.) +- Category assignments (action, pr, issue, watching, resolved) + +### Phase 3: Generate AI Summaries โญ + +This is where you add value! Read the enriched JSON and generate a summary for each notification. + +1. **Read the file:** + ```bash + cat .scratch/notifications-enriched.json + ``` + +2. **For each notification, generate a summary** (max 500 words) that answers: + - What is this about? + - What action (if any) is needed from me? + - What's the current status? + - Key discussion points or decisions made + +3. **Consider the context:** + - For `reason: "mention"` โ†’ Focus on what was asked/discussed + - For `reason: "review_requested"` โ†’ Summarize the PR changes and what to review + - For `reason: "author"` โ†’ Summarize feedback received on your PR/issue + - For `category: "resolved"` โ†’ Brief status (merged, closed, etc.) + +4. **Use the details field** which contains: + - `body`: The PR/issue description + - `comments`: Array of comments with `author.login` and `body` + - `reviews`: Array of reviews with `author.login`, `state`, and `body` + +5. **Save the enriched data** with summaries added: + +After generating all summaries, create `.scratch/notifications-with-summaries.json` with the same structure but with the `summary` field populated for each notification. + +**Example format for saving:** +```json +[ + { + "id": "12345", + "title": "Add new feature", + "type": "PullRequest", + "reason": "review_requested", + "category": "action", + "tags": [["๐Ÿ”ด", "NEEDS RESPONSE"]], + "html_url": "https://github.com/org/repo/pull/123", + "summary": "Your AI-generated summary here (max 500 words)...", + ... + } +] +``` + +### Phase 4: Launch Interactive Web UI + +```bash +python .cursor/commands/notification_server.py +``` + +This will: +1. Load notifications from `.scratch/notifications-with-summaries.json` +2. Start a local server (default: http://localhost:8765) +3. Auto-open your browser +4. Show notifications in tabs with your AI summaries +5. Provide "Done" buttons that mark notifications as done on GitHub + +### Phase 5: Present to User + +After launching the web UI, offer follow-up actions: + +> "I've analyzed your notifications and launched the interactive dashboard. +> +> **Quick Summary:** +> - ๐Ÿ”ฅ X items need your action +> - ๐Ÿ“‹ Y pull requests +> - ๐Ÿ“ Z issues +> - ๐Ÿ N resolved items +> +> Would you like me to: +> 1. **Deep dive** into any specific notification? +> 2. **Draft a response** to a comment or review? +> 3. **Help review** a specific PR?" + +## Smart Tags + +| Tag | Emoji | Criteria | +|-----|-------|----------| +| `NEEDS RESPONSE` | ๐Ÿ”ด | Mentioned or review requested | +| `YOUR PR` / `YOUR ISSUE` | ๐Ÿ‘ค | You are the author | +| `PR APPROVED` | โœ… | Has approving reviews | +| `PR MERGED` | ๐ŸŽ‰ | PR was merged | +| `CHANGES REQUESTED` | ๐Ÿ”ถ | Reviews request changes | +| `CI FAILED` | ๐Ÿ”ด | Status checks failed | +| `CI PASSING` | ๐ŸŸข | Status checks passed | +| `NEW REVIEW COMMENT` | ๐Ÿ’ฌ | Human feedback on your PR | +| `RESOLVED` | ๐Ÿ | Closed or merged | + +## Categories (Priority Order) + +1. **๐Ÿ”ฅ Requires Your Action** - Mentions, review requests, changes requested +2. **๐Ÿ“‹ Pull Requests** - PR activity (not requiring immediate action) +3. **๐Ÿ“ Issues** - Issue activity +4. **๐Ÿ‘€ Watching** - Subscribed/FYI items +5. **๐Ÿ Resolved** - Closed/merged items + +## Files Used + +| File | Purpose | +|------|---------| +| `.cursor/commands/notification_config.py` | Configuration auto-detection | +| `.cursor/commands/fetch_notifications.py` | Fetches and enriches notifications | +| `.cursor/commands/generate_summaries.py` | Template summary generator | +| `.cursor/commands/notification_server.py` | Serves the interactive web UI | +| `.scratch/notifications-enriched.json` | Enriched notifications (no summaries) | +| `.scratch/notifications-with-summaries.json` | With AI summaries added | +| `.cursor/notification_config.json` | (Optional) Custom configuration | + +## Example Summary Generation + +For a notification like: +```json +{ + "title": "Add hierarchical DiD model", + "reason": "mention", + "details": { + "body": "This PR adds support for hierarchical difference-in-differences...", + "comments": [ + {"author": {"login": "reviewer"}, "body": "@you what do you think about the prior choices?"} + ] + } +} +``` + +Generate a summary like: +> "A reviewer is asking for your input on prior choices in a new hierarchical DiD model PR. The PR adds support for panel data with multiple treated units. Specifically, they want your opinion on the priors for the random effects variance. **Action needed:** Review and respond to the question about prior specifications." + +## Error Handling + +- **No notifications:** Report "No notifications found in the specified time window." +- **Repository not detected:** Create `.cursor/notification_config.json` with `{"repo": "org/repo"}` +- **Script not found:** Ensure `.cursor/commands/fetch_notifications.py` exists +- **Empty enriched file:** Run fetch_notifications.py first diff --git a/.gitignore b/.gitignore index 299c39f77..687b10bc0 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,6 @@ docs/source/api/generated/ .github/issue_comments/ .github/issue_summaries/ +# Scratch folder for temporary files (notification summaries, etc.) # Local scratch space (not tracked) .scratch/