Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
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

Copilot AI Mar 7, 2026

Copy link

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.

Suggested change
- run: flake8 . --max-line-length=120 --exclude=__pycache__,.venv,tests --exit-zero
- run: flake8 . --max-line-length=120 --exclude=__pycache__,.venv,tests

Copilot uses AI. Check for mistakes.

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

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI installs dependencies ad-hoc (pip install requests pyyaml ...) instead of using requirements.txt, so the test environment can drift from the pinned runtime dependencies (e.g., requests/pyyaml/python-dotenv versions and anthropic). Install from requirements.txt (and add a dev/test requirements file if needed) to keep CI consistent with the project’s dependency pins.

Copilot uses AI. Check for mistakes.
run: |
pytest tests/ -v \
--cov=. \
--cov-report=term-missing \
--ignore=tests/integration
env:
GITHUB_TOKEN: fake-token-for-tests
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.venv/
__pycache__/
Binary file added __pycache__/actions_executor.cpython-312.pyc
Binary file not shown.
Binary file added __pycache__/github_api_client.cpython-312.pyc
Binary file not shown.
Binary file added __pycache__/llm_classifier.cpython-312.pyc
Binary file not shown.
Binary file added __pycache__/notification_copilot.cpython-312.pyc
Binary file not shown.
106 changes: 106 additions & 0 deletions actions_executor.py
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

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says the handler will “Mark unread so it stays prominent”, but the method doesn’t (and can’t via the current client) mark the notification unread; it only adds a label. Update the comment to reflect the actual behavior, or implement the intended unread/flagging behavior if supported.

Suggested change
# 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)

Copilot uses AI. Check for mistakes.
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
71 changes: 71 additions & 0 deletions github_api_client.py
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()
102 changes: 102 additions & 0 deletions llm_classifier.py
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)
Loading