From 3ad6d63a75523507fbe9ec9db48e16bc10d07799 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 7 Mar 2026 05:41:06 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20implement=20core=20=E2=80=94=20clas?= =?UTF-8?q?sifier,=20executor,=20orchestrator=20+=20CI=20+=2015=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notification_copilot.py: main orchestrator with LLM + rule-based fallback triage - github_api_client.py: GitHub REST API wrapper (notifications, labels, mute) - llm_classifier.py: Claude-powered P1/P2/P3 classifier with feedback loop - actions_executor.py: executes mute/archive/review_now/review_later actions - tests/test_copilot.py: 15 offline unit tests (all passing) - .github/workflows/ci.yml: lint + test matrix (Python 3.11 + 3.12) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 48 +++++ .gitignore | 1 + __pycache__/actions_executor.cpython-312.pyc | Bin 0 -> 5119 bytes __pycache__/github_api_client.cpython-312.pyc | Bin 0 -> 4363 bytes __pycache__/llm_classifier.cpython-312.pyc | Bin 0 -> 5107 bytes .../notification_copilot.cpython-312.pyc | Bin 0 -> 7044 bytes actions_executor.py | 106 ++++++++++ github_api_client.py | 71 +++++++ llm_classifier.py | 102 ++++++++++ notification_copilot.py | 131 ++++++++++++ tests/__init__.py | 0 tests/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 159 bytes .../test_copilot.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 20910 bytes tests/test_copilot.py | 189 ++++++++++++++++++ 14 files changed, 648 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 __pycache__/actions_executor.cpython-312.pyc create mode 100644 __pycache__/github_api_client.cpython-312.pyc create mode 100644 __pycache__/llm_classifier.cpython-312.pyc create mode 100644 __pycache__/notification_copilot.cpython-312.pyc create mode 100644 actions_executor.py create mode 100644 github_api_client.py create mode 100644 llm_classifier.py create mode 100644 notification_copilot.py create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-312.pyc create mode 100644 tests/__pycache__/test_copilot.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/test_copilot.py 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..21d0b89 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv/ diff --git a/__pycache__/actions_executor.cpython-312.pyc b/__pycache__/actions_executor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..478f6969bd73e26a57c3679c5200e308987310ee GIT binary patch literal 5119 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 literal 0 HcmV?d00001 diff --git a/__pycache__/github_api_client.cpython-312.pyc b/__pycache__/github_api_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d03845c081c76a1f863b44209287398f152da469 GIT binary patch literal 4363 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 aXsrve&TRTI)AEG1{$LOsCAL++vi}8*bI;KL literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cbf0e80acd2d93b31dd17e291cb46149e2ea4d25 GIT binary patch literal 159 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 literal 0 HcmV?d00001 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" From 899df9978a4d3c29cbba744d249320d29ff7bf31 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 7 Mar 2026 05:41:29 +0000 Subject: [PATCH 2/2] chore: add __pycache__ to .gitignore Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 21d0b89..a230a78 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .venv/ +__pycache__/