diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11bc966 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main, "feat/**"] + pull_request: + branches: [main] + +env: + PYTHON_VERSION: "3.11" + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + - run: pip install flake8 + - run: flake8 . --max-line-length=120 --exclude=__pycache__,.venv,tests --exit-zero + + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install dependencies + run: | + pip install requests pyyaml python-dotenv pytest pytest-cov + - name: Run tests + run: | + pytest tests/ -v \ + --cov=. \ + --cov-report=term-missing \ + --ignore=tests/integration + env: + GITHUB_TOKEN: fake-token-for-tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a230a78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv/ +__pycache__/ diff --git a/__pycache__/actions_executor.cpython-312.pyc b/__pycache__/actions_executor.cpython-312.pyc new file mode 100644 index 0000000..478f696 Binary files /dev/null and b/__pycache__/actions_executor.cpython-312.pyc differ diff --git a/__pycache__/github_api_client.cpython-312.pyc b/__pycache__/github_api_client.cpython-312.pyc new file mode 100644 index 0000000..d03845c Binary files /dev/null and b/__pycache__/github_api_client.cpython-312.pyc differ diff --git a/__pycache__/llm_classifier.cpython-312.pyc b/__pycache__/llm_classifier.cpython-312.pyc new file mode 100644 index 0000000..486913f Binary files /dev/null and b/__pycache__/llm_classifier.cpython-312.pyc differ diff --git a/__pycache__/notification_copilot.cpython-312.pyc b/__pycache__/notification_copilot.cpython-312.pyc new file mode 100644 index 0000000..f1cc7ff Binary files /dev/null and b/__pycache__/notification_copilot.cpython-312.pyc differ diff --git a/actions_executor.py b/actions_executor.py new file mode 100644 index 0000000..719cc2e --- /dev/null +++ b/actions_executor.py @@ -0,0 +1,106 @@ +"""Executes triage actions on GitHub notifications.""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List + +from github_api_client import GitHubAPIClient + +logger = logging.getLogger(__name__) + +# Map priority+action combinations to concrete GitHub operations +_ACTION_HANDLERS = { + "mute": "_mute", + "archive": "_archive", + "review_now": "_flag_review_now", + "review_later": "_noop", +} + + +class ActionsExecutor: + """Executes triage actions returned by the LLM classifier.""" + + def __init__(self, client: GitHubAPIClient, dry_run: bool = False): + self._client = client + self._dry_run = dry_run + self._executed: List[Dict] = [] + + def execute(self, notification: Dict[str, Any], classification: Dict[str, Any]) -> Dict: + """Execute the appropriate action for a classified notification. + + Args: + notification: Raw GitHub notification dict + classification: Result from LLMClassifier.classify() + + Returns: + Dict with keys: thread_id, action, priority, executed, dry_run + """ + thread_id = notification["id"] + action = classification.get("action", "review_later") + priority = classification.get("priority", "P2") + + result = { + "thread_id": thread_id, + "action": action, + "priority": priority, + "executed": False, + "dry_run": self._dry_run, + } + + handler_name = _ACTION_HANDLERS.get(action, "_noop") + handler = getattr(self, handler_name, self._noop) + + if self._dry_run: + logger.info("[DRY RUN] Would execute %s on thread %s", action, thread_id) + result["executed"] = True + else: + try: + handler(notification, thread_id) + result["executed"] = True + logger.info("Executed %s on thread %s (priority=%s)", action, thread_id, priority) + except Exception as exc: + logger.error("Action %s failed on thread %s: %s", action, thread_id, exc) + result["error"] = str(exc) + + self._executed.append(result) + return result + + def get_execution_summary(self) -> Dict: + """Return summary stats for this session.""" + total = len(self._executed) + by_action = {} + for r in self._executed: + by_action[r["action"]] = by_action.get(r["action"], 0) + 1 + return {"total": total, "by_action": by_action} + + # ----------------------------------------------------------------------- + # Private action handlers + # ----------------------------------------------------------------------- + + def _mute(self, notification: Dict, thread_id: str) -> None: + self._client.mute_thread(thread_id) + self._client.mark_thread_read(thread_id) + + def _archive(self, notification: Dict, thread_id: str) -> None: + self._client.mark_thread_read(thread_id) + + def _flag_review_now(self, notification: Dict, thread_id: str) -> None: + # Mark unread so it stays prominent; optionally add P1 label + repo = notification.get("repository", {}) + owner_repo = repo.get("full_name", "") + subject = notification.get("subject", {}) + ntype = subject.get("type", "") + if owner_repo and ntype in ("PullRequest", "Issue"): + try: + parts = owner_repo.split("/") + # Extract issue/PR number from URL + url = subject.get("url", "") + if url: + number = int(url.rstrip("/").split("/")[-1]) + self._client.add_label(parts[0], parts[1], number, "P1-review-now") + except Exception as exc: + logger.debug("Labelling skipped: %s", exc) + + def _noop(self, notification: Dict, thread_id: str) -> None: + pass diff --git a/github_api_client.py b/github_api_client.py new file mode 100644 index 0000000..1463889 --- /dev/null +++ b/github_api_client.py @@ -0,0 +1,71 @@ +"""GitHub API client for fetching and managing notifications.""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +import requests + +logger = logging.getLogger(__name__) + +GITHUB_API = "https://api.github.com" + + +class GitHubAPIClient: + """Minimal GitHub REST API client focused on notifications.""" + + def __init__(self, token: str): + self._session = requests.Session() + self._session.headers.update( + { + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + ) + + def _get(self, path: str, params: Optional[Dict] = None) -> Any: + resp = self._session.get(f"{GITHUB_API}{path}", params=params, timeout=15) + resp.raise_for_status() + return resp.json() + + def _patch(self, path: str, data: Dict) -> Any: + resp = self._session.patch(f"{GITHUB_API}{path}", json=data, timeout=15) + resp.raise_for_status() + return resp.json() if resp.content else {} + + def _delete(self, path: str) -> None: + resp = self._session.delete(f"{GITHUB_API}{path}", timeout=15) + resp.raise_for_status() + + def get_notifications( + self, all_: bool = False, participating: bool = False, per_page: int = 50 + ) -> List[Dict]: + """Fetch unread (or all) notifications.""" + return self._get( + "/notifications", + params={"all": str(all_).lower(), "participating": str(participating).lower(), "per_page": per_page}, + ) + + def mark_thread_read(self, thread_id: str) -> None: + """Mark a notification thread as read.""" + self._patch(f"/notifications/threads/{thread_id}", {}) + + def mute_thread(self, thread_id: str) -> None: + """Mute (unsubscribe) a notification thread.""" + resp = self._session.put( + f"{GITHUB_API}/notifications/threads/{thread_id}/subscription", + json={"ignored": True}, + timeout=15, + ) + resp.raise_for_status() + + def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> None: + """Add a label to an issue or PR.""" + resp = self._session.post( + f"{GITHUB_API}/repos/{owner}/{repo}/issues/{issue_number}/labels", + json={"labels": [label]}, + timeout=15, + ) + resp.raise_for_status() diff --git a/llm_classifier.py b/llm_classifier.py new file mode 100644 index 0000000..ecf91c0 --- /dev/null +++ b/llm_classifier.py @@ -0,0 +1,102 @@ +"""LLM-powered notification classifier using Anthropic Claude.""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + +# Soft-import so the module loads even without anthropic installed +try: + import anthropic + _ANTHROPIC_AVAILABLE = True +except ImportError: + _ANTHROPIC_AVAILABLE = False + logger.warning("anthropic package not installed — LLM classification disabled") + +SYSTEM_PROMPT = """You are an expert GitHub notification triage assistant. +Given a GitHub notification, classify it and suggest an action. + +Respond with a JSON object containing: +- priority: "P1" (critical/urgent), "P2" (important), or "P3" (low/noise) +- action: one of "review_now", "review_later", "mute", "archive" +- reason: one sentence explaining why +- summary: a 10-word max description of the notification + +Examples of P1: security vulnerabilities, CI failures on your PRs, direct review requests. +Examples of P2: mentions in issues you care about, new PRs in repos you maintain. +Examples of P3: bot comments, automated dependency updates, watched repo activity you didn't author. +""" + + +class LLMClassifier: + """Classifies notifications using Anthropic Claude.""" + + def __init__(self, api_key: str, model: str = "claude-3-haiku-20240307"): + if not _ANTHROPIC_AVAILABLE: + raise RuntimeError( + "anthropic package required. Install with: pip install anthropic" + ) + self._client = anthropic.Anthropic(api_key=api_key) + self._model = model + self._feedback_log: list = [] + + def classify(self, notification: Dict[str, Any]) -> Dict[str, Any]: + """Classify a single notification. + + Args: + notification: Raw GitHub notification dict from the API + + Returns: + Dict with keys: priority, action, reason, summary + """ + subject = notification.get("subject", {}) + repo = notification.get("repository", {}).get("full_name", "unknown/repo") + reason = notification.get("reason", "unknown") + title = subject.get("title", "No title") + ntype = subject.get("type", "Unknown") + + user_message = f"""Notification details: +- Repository: {repo} +- Type: {ntype} +- Title: {title} +- Reason: {reason} +- Unread: {notification.get('unread', True)} + +Classify this notification and respond with the JSON object only.""" + + try: + import json + msg = self._client.messages.create( + model=self._model, + max_tokens=256, + system=SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_message}], + ) + text = msg.content[0].text.strip() + # Strip markdown code fences if present + if text.startswith("```"): + text = text.split("```")[1] + if text.startswith("json"): + text = text[4:] + return json.loads(text) + except Exception as exc: + logger.exception("Classification failed for %s: %s", title, exc) + return { + "priority": "P2", + "action": "review_later", + "reason": f"Classification failed: {exc}", + "summary": title[:50], + } + + def record_feedback(self, notification_id: str, correct_priority: str) -> None: + """Record user feedback for continuous improvement.""" + self._feedback_log.append( + {"notification_id": notification_id, "correct_priority": correct_priority} + ) + logger.info("Feedback recorded for %s: %s", notification_id, correct_priority) + + def get_feedback_log(self) -> list: + """Return all recorded feedback.""" + return list(self._feedback_log) diff --git a/notification_copilot.py b/notification_copilot.py new file mode 100644 index 0000000..c3540cc --- /dev/null +++ b/notification_copilot.py @@ -0,0 +1,131 @@ +"""Main orchestration entry point for GitHub Notification Copilot.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List, Optional + +import yaml +from dotenv import load_dotenv + +from actions_executor import ActionsExecutor +from github_api_client import GitHubAPIClient +from llm_classifier import LLMClassifier + +load_dotenv() +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s — %(message)s", +) +logger = logging.getLogger(__name__) + + +def load_config(path: str = "config.yaml") -> Dict[str, Any]: + if not os.path.exists(path): + return {} + with open(path) as f: + return yaml.safe_load(f) or {} + + +class NotificationCopilot: + """Orchestrates notification fetching, classification, and action execution.""" + + def __init__( + self, + github_token: Optional[str] = None, + anthropic_api_key: Optional[str] = None, + config_path: str = "config.yaml", + dry_run: bool = False, + ): + self._config = load_config(config_path) + token = github_token or os.getenv("GITHUB_TOKEN") + if not token: + raise ValueError("GITHUB_TOKEN required (env var or github_token param)") + + self._github = GitHubAPIClient(token=token) + self._executor = ActionsExecutor(self._github, dry_run=dry_run) + self._dry_run = dry_run + + llm_key = anthropic_api_key or os.getenv("ANTHROPIC_API_KEY") + if llm_key: + try: + self._classifier: Optional[LLMClassifier] = LLMClassifier(api_key=llm_key) + except RuntimeError as exc: + logger.warning("LLM classifier unavailable: %s", exc) + self._classifier = None + else: + logger.warning("No ANTHROPIC_API_KEY — will use rule-based fallback classification") + self._classifier = None + + def triage(self, limit: int = 50) -> List[Dict]: + """Fetch, classify, and act on notifications. Returns list of results.""" + notifications = self._github.get_notifications(per_page=limit) + logger.info("Fetched %d notifications", len(notifications)) + + results = [] + for notif in notifications: + classification = ( + self._classifier.classify(notif) + if self._classifier + else self._rule_based_classify(notif) + ) + action_result = self._executor.execute(notif, classification) + results.append( + { + "id": notif["id"], + "title": notif.get("subject", {}).get("title", ""), + "repo": notif.get("repository", {}).get("full_name", ""), + **classification, + "executed": action_result.get("executed", False), + } + ) + + summary = self._executor.get_execution_summary() + logger.info("Triage complete — %s", summary) + return results + + # ------------------------------------------------------------------ + # Rule-based fallback (no LLM) + # ------------------------------------------------------------------ + + def _rule_based_classify(self, notification: Dict) -> Dict: + """Simple heuristic classifier used when LLM is unavailable.""" + reason = notification.get("reason", "") + subject = notification.get("subject", {}) + ntype = subject.get("type", "") + title = subject.get("title", "") + + # P1: direct review requests or mentions + if reason in ("review_requested", "assign"): + return {"priority": "P1", "action": "review_now", "reason": "Direct review request", "summary": title[:50]} + + # P3: bot-authored or automated + lower_title = title.lower() + if any(kw in lower_title for kw in ["dependabot", "renovate", "[bot]", "security advisory"]): + return {"priority": "P3", "action": "mute", "reason": "Automated/bot notification", "summary": title[:50]} + + # P2: everything else + return {"priority": "P2", "action": "review_later", "reason": f"Reason: {reason}", "summary": title[:50]} + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description="GitHub Notification Copilot") + parser.add_argument("--limit", type=int, default=50, help="Max notifications to triage") + parser.add_argument("--dry-run", action="store_true", help="Print actions without executing") + args = parser.parse_args() + + copilot = NotificationCopilot(dry_run=args.dry_run) + results = copilot.triage(limit=args.limit) + + for r in results: + flag = "šŸ”“" if r["priority"] == "P1" else "🟔" if r["priority"] == "P2" else "⚪" + print(f"{flag} [{r['priority']}] {r['repo']} — {r['summary']} ({r['action']})") + + print(f"\nāœ… Triaged {len(results)} notifications") + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..cbf0e80 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/tests/__pycache__/test_copilot.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_copilot.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..ede8b76 Binary files /dev/null and b/tests/__pycache__/test_copilot.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/test_copilot.py b/tests/test_copilot.py new file mode 100644 index 0000000..2fba91b --- /dev/null +++ b/tests/test_copilot.py @@ -0,0 +1,189 @@ +"""Tests for github-notifications-copilot — fully offline.""" + +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +SAMPLE_NOTIF = { + "id": "thread-123", + "unread": True, + "reason": "review_requested", + "subject": {"title": "feat: add payment gateway", "type": "PullRequest", "url": "https://api.github.com/repos/owner/repo/pulls/42"}, + "repository": {"full_name": "owner/repo"}, +} + +BOT_NOTIF = { + "id": "thread-456", + "unread": True, + "reason": "subscribed", + "subject": {"title": "Dependabot: bump lodash from 4.17.20 to 4.17.21", "type": "PullRequest", "url": ""}, + "repository": {"full_name": "owner/repo"}, +} + + +# --------------------------------------------------------------------------- +# GitHubAPIClient +# --------------------------------------------------------------------------- + +class TestGitHubAPIClient: + def _make_client(self): + from github_api_client import GitHubAPIClient + client = GitHubAPIClient(token="fake-token") + return client + + def test_instantiates(self): + client = self._make_client() + assert client is not None + + def test_auth_header_set(self): + client = self._make_client() + assert "Authorization" in client._session.headers + assert client._session.headers["Authorization"] == "token fake-token" + + def test_get_notifications_calls_correct_url(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.json.return_value = [SAMPLE_NOTIF] + mock_resp.raise_for_status = MagicMock() + client._session.get = MagicMock(return_value=mock_resp) + result = client.get_notifications() + assert result == [SAMPLE_NOTIF] + client._session.get.assert_called_once() + call_url = client._session.get.call_args[0][0] + assert "/notifications" in call_url + + def test_mark_thread_read(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.content = b"" + client._session.patch = MagicMock(return_value=mock_resp) + client.mark_thread_read("thread-123") + client._session.patch.assert_called_once() + + def test_mute_thread(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + client._session.put = MagicMock(return_value=mock_resp) + client.mute_thread("thread-123") + call_args = client._session.put.call_args + assert "ignored" in call_args[1]["json"] + assert call_args[1]["json"]["ignored"] is True + + +# --------------------------------------------------------------------------- +# LLMClassifier +# --------------------------------------------------------------------------- + +class TestLLMClassifier: + def test_unavailable_when_no_anthropic(self, monkeypatch): + import llm_classifier as lc + monkeypatch.setattr(lc, "_ANTHROPIC_AVAILABLE", False) + with pytest.raises(RuntimeError, match="anthropic package required"): + lc.LLMClassifier(api_key="x") + + def test_classify_returns_fallback_on_error(self, monkeypatch): + import llm_classifier as lc + mock_anthropic = MagicMock() + mock_client = MagicMock() + mock_client.messages.create.side_effect = Exception("network error") + mock_anthropic.Anthropic.return_value = mock_client + monkeypatch.setattr(lc, "_ANTHROPIC_AVAILABLE", True) + monkeypatch.setattr(lc, "anthropic", mock_anthropic, raising=False) + classifier = lc.LLMClassifier(api_key="x") + result = classifier.classify(SAMPLE_NOTIF) + assert result["priority"] in ("P1", "P2", "P3") + assert "action" in result + + def test_feedback_log(self, monkeypatch): + import llm_classifier as lc + mock_anthropic = MagicMock() + monkeypatch.setattr(lc, "_ANTHROPIC_AVAILABLE", True) + monkeypatch.setattr(lc, "anthropic", mock_anthropic, raising=False) + classifier = lc.LLMClassifier(api_key="x") + classifier.record_feedback("thread-123", "P1") + log = classifier.get_feedback_log() + assert len(log) == 1 + assert log[0]["correct_priority"] == "P1" + + +# --------------------------------------------------------------------------- +# ActionsExecutor +# --------------------------------------------------------------------------- + +class TestActionsExecutor: + def _make_executor(self, dry_run=False): + from actions_executor import ActionsExecutor + mock_client = MagicMock() + return ActionsExecutor(mock_client, dry_run=dry_run) + + def test_dry_run_does_not_call_api(self): + executor = self._make_executor(dry_run=True) + result = executor.execute(SAMPLE_NOTIF, {"action": "mute", "priority": "P3"}) + assert result["dry_run"] is True + assert result["executed"] is True + executor._client.mute_thread.assert_not_called() + + def test_mute_action_calls_api(self): + executor = self._make_executor(dry_run=False) + result = executor.execute(BOT_NOTIF, {"action": "mute", "priority": "P3"}) + assert result["executed"] is True + executor._client.mute_thread.assert_called_once_with("thread-456") + + def test_archive_action_marks_read(self): + executor = self._make_executor(dry_run=False) + executor.execute(SAMPLE_NOTIF, {"action": "archive", "priority": "P2"}) + executor._client.mark_thread_read.assert_called_once_with("thread-123") + + def test_execution_summary(self): + executor = self._make_executor(dry_run=True) + executor.execute(SAMPLE_NOTIF, {"action": "review_now", "priority": "P1"}) + executor.execute(BOT_NOTIF, {"action": "mute", "priority": "P3"}) + summary = executor.get_execution_summary() + assert summary["total"] == 2 + assert summary["by_action"]["review_now"] == 1 + assert summary["by_action"]["mute"] == 1 + + +# --------------------------------------------------------------------------- +# NotificationCopilot rule-based fallback +# --------------------------------------------------------------------------- + +class TestNotificationCopilotRuleBased: + def _make_copilot(self): + from notification_copilot import NotificationCopilot + with patch.object(NotificationCopilot, "__init__", lambda self, **kw: None): + copilot = NotificationCopilot.__new__(NotificationCopilot) + copilot._classifier = None + copilot._dry_run = True + copilot._config = {} + copilot._github = MagicMock() + copilot._executor = MagicMock() + copilot._executor.get_execution_summary.return_value = {} + return copilot + + def test_rule_based_p1_review_request(self): + from notification_copilot import NotificationCopilot + copilot = self._make_copilot() + result = copilot._rule_based_classify(SAMPLE_NOTIF) + assert result["priority"] == "P1" + assert result["action"] == "review_now" + + def test_rule_based_p3_bot(self): + from notification_copilot import NotificationCopilot + copilot = self._make_copilot() + result = copilot._rule_based_classify(BOT_NOTIF) + assert result["priority"] == "P3" + assert result["action"] == "mute" + + def test_rule_based_p2_default(self): + from notification_copilot import NotificationCopilot + copilot = self._make_copilot() + generic = {**SAMPLE_NOTIF, "reason": "subscribed", "subject": {"title": "Some issue", "type": "Issue", "url": ""}} + result = copilot._rule_based_classify(generic) + assert result["priority"] == "P2"