-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement core notification copilot — classifier, executor, orchestrator + CI #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Comment on lines
+38
to
+41
|
||
| run: | | ||
| pytest tests/ -v \ | ||
| --cov=. \ | ||
| --cov-report=term-missing \ | ||
| --ignore=tests/integration | ||
| env: | ||
| GITHUB_TOKEN: fake-token-for-tests | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| .venv/ | ||
| __pycache__/ |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 | ||||||
|
||||||
| # Mark unread so it stays prominent; optionally add P1 label | |
| # Highlight for immediate review by adding a P1 label (does not change read/unread state) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lint step runs flake8 with
--exit-zero, which forces a zero exit code even when violations are found, making the lint job ineffective. Remove--exit-zero(and optionally keep a separate non-blocking report step if desired) so CI actually fails on lint errors.