diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a45058 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI +on: + push: + branches: [main] + pull_request: +permissions: + contents: read +jobs: + test: + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: python -m pip install --upgrade pip + - run: pip install -r requirements-dev.txt + - run: ruff check . + - run: pytest -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..815ab52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.env +.venv/ +venv/ +env/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +build/ +dist/ +*.egg-info/ +.vscode/ +.idea/ +.DS_Store diff --git a/README.md b/README.md index b0b79aa..1a1a4ad 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,29 @@ -# Git SMS 📲 +# Git SMS 📲 Idea: Enabling GitHub collaboration, automation, and AI interaction via SMS text messages. > **DISCLAIMER:** Proof-of-concept, insecure by design, do not use in production, not a GitHub product. **As-Is, At Your Own Risk.** +## Current implementation + +The executable proof of concept supports `help`, repository summaries, latest-issue summaries, numbered-issue summaries, optional natural-language interpretation of that read-only set, and TwiML replies for a Twilio webhook. + +Repository and issue creation remain intentionally disabled. The application imports and starts without credentials; AI-dependent commands return a useful configuration message until GitHub Models or OpenAI is configured. + +## Security model + +The current implementation does not yet provide Twilio signature verification, sender authorization, replay protection, idempotency, rate limiting, durable auditing, GitHub App authentication, or SMS-triggered Actions. These controls must exist before enabling writes or workflow dispatch. + +## Roadmap + +1. Authenticate and constrain inbound SMS requests. +2. Add idempotency, rate limits, and audit records. +3. Introduce a least-privilege GitHub App. +4. Demonstrate one allowlisted `workflow_dispatch` command with run tracking. + ## What It Is -Git SMS is an open source project that shows a proof of concept to enable users to interact with GitHub and AI via SMS. Simply by texting a number you could summarize issues, pull requests, repos, and receive updates, and more. +Git SMS is an open source project that shows a proof of concept to enable users to interact with GitHub and AI via SMS. Simply by texting a number you could summarize issues, pull requests, repos, receive updates, and more. This workflow brings the power of open source and AI to people who may not have consistent access to laptops, smartphones, or high-speed internet. @@ -16,66 +33,66 @@ This workflow brings the power of open source and AI to people who may not have Fewer than 65% have reliable internet or a smartphone. -Everyone should be able to participate in global innovation, contribute to the open source community, learn from others and interact -with AI. +Everyone should be able to participate in global innovation, contribute to the open source community, learn from others, and interact with AI. This system enables: -* Participation in open source and AI regardless of location or hardware -* Communication over SMS network -* Interaction with GitHub through a plain text interface + +- Participation in open source and AI regardless of location or hardware +- Communication over the SMS network +- Interaction with GitHub through a plain-text interface ## Use Cases This project can be adapted for a wide variety of scenarios, from individual developer productivity to large-scale community initiatives. -*Bridging the Digital Divide & Empowering Communities* +### Bridging the Digital Divide & Empowering Communities - **Offline First Contribution:** Allow individuals in areas with limited, expensive, or unreliable internet to contribute to open source projects by opening issues, commenting, and receiving updates via SMS. -- **Bug Reporting from the Field:** Enable users of open source software in remote locations (e.g., agricultural tech, water management systems) to report bugs and provide feedback directly from their location using any mobile phone. -- **Community Polling & Governance:** Use SMS to allow community members to vote on project features, governance decisions, or new initiatives, ensuring everyone has a voice regardless of their internet access. -- **Educational Access:** Facilitate coding education by allowing students to interact with repositories, submit assignments as issues, and get feedback from instructors, all without requiring a computer or broadband. -- **Citizen Journalism & Data Collection:** Empower individuals to submit reports, news tips, or data (e.g., local election monitoring, environmental observations) to a centralized repository as structured issues via SMS. +- **Bug Reporting from the Field:** Enable users of open source software in remote locations to report bugs and provide feedback directly from their location using any mobile phone. +- **Community Polling & Governance:** Use SMS to allow community members to vote on project features, governance decisions, or new initiatives. +- **Educational Access:** Facilitate coding education without requiring a computer or broadband. +- **Citizen Journalism & Data Collection:** Empower individuals to submit reports, news tips, or structured data to a centralized repository. -*Developer & Project Manager Productivity* +### Developer & Project Manager Productivity -- **On the Go Issue Management:** Quickly create an issue to capture a bug or idea right when it occurs to you, whether you're commuting, in a meeting, or away from your desk. -- **Emergency Code Review & Merge:** In critical situations, a project maintainer could receive an urgent pull request notification and approve a merge for a hotfix directly from their phone. -- **Quick Status Checks:** Get a quick list of open issues, recent commits, or the status of pull requests for a repository while on the move without needing to open a laptop. -- **Simple Project Scaffolding:** Create a new repository from a predefined template with a single SMS command, streamlining the start of new projects. -- **Team Status Updates:** Allow team members to provide quick end-of-day status updates by commenting on a designated "daily stand-up" issue via text message. +- **On the Go Issue Management:** Capture a bug or idea while away from a desk. +- **Emergency Code Review & Merge:** Support carefully controlled emergency workflows from a phone. +- **Quick Status Checks:** Get repository, issue, or pull-request status without opening a laptop. +- **Simple Project Scaffolding:** Start predefined project workflows with a concise SMS command. +- **Team Status Updates:** Submit lightweight status updates by text. -*DevOps & Automation* +### DevOps & Automation -- **Triggering CI/CD Pipelines:** Initiate a build, test, or deployment process through a GitHub Actions workflow triggered by a specific SMS command (e.g., `DEPLOY to staging`). -- **Infrastructure Status & Control:** Create workflows where you can request the status of production systems (`STATUS `) or trigger a restart of a service (`RESTART `) via SMS. -- **Incident Response & Management:** In the event of a production outage, receive an automated SMS alert and be able to create an incident issue, notify the team, and post updates from your phone. -- **Release Management:** Trigger a release workflow that tags a new version, generates release notes from recent commits, and publishes the release, all initiated by an SMS command. -- **Automated Reporting:** Schedule a GitHub Action to send a summary of weekly repository activity (e.g., new issues, closed PRs) to a list of stakeholders via SMS. +- **Triggering CI/CD Pipelines:** Initiate a build, test, or deployment process through a tightly controlled GitHub Actions workflow. +- **Infrastructure Status & Control:** Request status or invoke predefined operational workflows. +- **Incident Response & Management:** Receive alerts and submit incident updates from a phone. +- **Release Management:** Trigger an allowlisted release workflow. +- **Automated Reporting:** Receive scheduled summaries of repository activity. -*AI-Powered & Natural Language Interaction* +### AI-Powered & Natural Language Interaction -- **Code & Project Summarization:** Get a high-level summary of a complex pull request, a lengthy issue discussion, or even the purpose of an entire repository by sending a simple question like, "Summarize the latest PR in owner/repo." -- **Natural Language Bug Reports:** Allow non-technical users to report issues in plain language (e.g., "The app crashes when I click the blue button"). An LLM backend can parse this, add appropriate labels, and create a well-formatted GitHub issue. -- **Code Generation & Scaffolding:** Request simple code snippets or file creation via natural language (e.g., "Create a new Python file in repo with a function to calculate factorial"). -- **Knowledge Base Queries:** Use SMS to ask questions about a project's documentation. The AI could search the repository's markdown files and return a concise answer. -- **AI-Assisted Task Management:** Tell the system, "Remind me to review the auth PR tomorrow," and have it create a new issue assigned to you with a due date. +- **Code & Project Summarization:** Get a concise summary of a repository or issue discussion. +- **Natural Language Bug Reports:** Convert plain-language reports into structured issue drafts. +- **Code Generation & Scaffolding:** Request bounded code-generation workflows. +- **Knowledge Base Queries:** Ask questions about project documentation. +- **AI-Assisted Task Management:** Turn natural-language requests into carefully constrained tasks. -*Beyond Software Development* +### Beyond Software Development -- **Field Service & Maintenance Reporting:** Technicians in the field can report equipment status, log maintenance tasks, or request parts by sending an SMS that creates an issue in a project repository. -- **Disaster Response & Coordination:** Enable first responders to report incidents, request resources, and provide status updates via SMS, which would populate a centralized dashboard or issue tracker for coordination. -- **Scientific Research & Citizen Science:** Allow researchers and volunteers in the field without internet to submit data points, observations, or findings to a project repository via text message. -- **Inventory & Asset Management:** Use SMS to log the status or location of physical assets, creating a commit or issue in a repository that serves as a lightweight asset tracking system. +- **Field Service & Maintenance Reporting:** Report equipment status or maintenance tasks by SMS. +- **Disaster Response & Coordination:** Submit incidents, resource requests, and updates over low-bandwidth networks. +- **Scientific Research & Citizen Science:** Submit observations or findings from the field. +- **Inventory & Asset Management:** Log status or location changes through controlled workflows. -## Demo +## Demo [Prototype Simulation](https://gh.io/git-sms-simulation) ## Setup -[Step-by-step Proof of Concept](https://github.com/github/git-sms/blob/main/SETUP.md) +[Step-by-step Proof of Concept](SETUP.md) ## Maintainers -* [Ashley Wolf](https://github.com/ashleywolf) -* [Dan Velton](https://github.com/dvelton) +- [Ashley Wolf](https://github.com/ashleywolf) +- [Dan Velton](https://github.com/dvelton) diff --git a/SETUP.md b/SETUP.md index 303acd3..87e7b10 100644 --- a/SETUP.md +++ b/SETUP.md @@ -1,83 +1,46 @@ -## 🚀 Setup Instructions +## Setup Instructions -This project lets you interact with GitHub (summarize repos, issues, PRs, etc.) via SMS using Twilio and OpenAI. +Git SMS is a read-only proof of concept for summarizing public GitHub repositories and issues through an SMS webhook. Write operations remain intentionally disabled. ---- - -### 1. Clone the Repo +### Install ```bash git clone https://github.com/your-username/git-sms.git cd git-sms -``` - -### 2. Set Up Python Environment - -```bash python3 -m venv venv source venv/bin/activate pip install -r requirements.txt ``` -### 3. Create a `.env` File - -Create a file named `.env` in the project root and add your credentials: +Tested on Python 3.10, 3.11, and 3.12. -```env +### Optional credentials -# OpenAI -OPENAI_API_KEY=your_openai_api_key +Create `.env` when you want AI summaries: -# GitHub authentication (optional, read-only) -# Only public read-only access is supported; write operations are disabled. +```env +GITHUB_OPENAI_API_KEY=your_github_models_token +# OPENAI_API_KEY=your_openai_api_key +# GITHUB_TOKEN=your_optional_read_only_token ``` ---- +The application imports and starts without credentials. AI-dependent commands return a clear SMS response until a provider key is configured. -### 4. Run the Server +### Run ```bash uvicorn main:app --reload ``` -Your server will be available at: [http://127.0.0.1:8000](http://127.0.0.1:8000) - ---- - -### 5. Expose with ngrok - -In a separate terminal window: - -```bash -ngrok http 8000 -``` - -Copy the HTTPS URL it provides (e.g., `https://abc123.ngrok.io`). - ---- - -### 6. Configure Twilio Webhook - -1. Go to your [Twilio Console](https://console.twilio.com/) -2. Navigate to your **Phone Number > Messaging > Webhook** -3. Set the webhook URL to: - -``` -https://abc123.ngrok.io/webhook -``` - -4. Set the method to `POST` - ---- +Expose port 8000 and configure a Twilio incoming-message webhook to POST to `/webhook`. -### ✅ Done +> This proof of concept does not validate Twilio signatures or authorize senders. Do not deploy it in production. -Try texting your Twilio number with: +### Commands ```text help -summarize github/github +summarize github/git-sms +summarize owner/repo issue summarize owner/repo issue 123 ``` - -Let the bots do the rest. diff --git a/commands.py b/commands.py index f45bbc5..9a5f4e4 100644 --- a/commands.py +++ b/commands.py @@ -1,17 +1,47 @@ -# commands.py +"""GitHub command utilities and intentionally disabled write operations.""" + +from __future__ import annotations import os +from dataclasses import dataclass + import requests -def get_headers(): - # Only read-only access with no authentication token - return { - "Accept": "application/vnd.github+json" - } +GITHUB_API = "https://api.github.com" +REQUEST_TIMEOUT = 10 + + +@dataclass(frozen=True) +class CommandOutcome: + enabled: bool + message: str + + +def get_headers() -> dict[str, str]: + headers = {"Accept": "application/vnd.github+json"} + token = os.getenv("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + return headers -def get_authenticated_username(): - user_url = "https://api.github.com/user" - user_res = requests.get(user_url, headers=get_headers()) - if not user_res.ok: + +def get_authenticated_username() -> str | None: + if not os.getenv("GITHUB_TOKEN"): + return None + try: + response = requests.get(f"{GITHUB_API}/user", headers=get_headers(), timeout=REQUEST_TIMEOUT) + if not response.ok: + return None + payload = response.json() + except (requests.RequestException, ValueError): return None - return user_res.json().get("login") + login = payload.get("login") if isinstance(payload, dict) else None + return str(login) if login else None + + +def create_repo(name: str) -> CommandOutcome: # noqa: ARG001 + return CommandOutcome(False, "Repository creation is disabled in this proof of concept.") + + +def create_issue(repo: str, title: str, body: str) -> CommandOutcome: # noqa: ARG001 + return CommandOutcome(False, "Issue creation is disabled in this proof of concept.") diff --git a/llm_client.py b/llm_client.py new file mode 100644 index 0000000..31a8b11 --- /dev/null +++ b/llm_client.py @@ -0,0 +1,56 @@ +"""Lazy LLM provider selection for Git SMS.""" + +from __future__ import annotations + +import os +from typing import Any + + +class LLMNotConfiguredError(RuntimeError): + """Raised when no supported LLM credential is configured.""" + + +def get_llm_client() -> tuple[str, Any]: + github_models_key = os.getenv("GITHUB_OPENAI_API_KEY") + if github_models_key: + from azure.ai.inference import ChatCompletionsClient + from azure.core.credentials import AzureKeyCredential + + return "github-models", ChatCompletionsClient( + endpoint="https://models.github.ai/inference", + credential=AzureKeyCredential(github_models_key), + ) + + openai_key = os.getenv("OPENAI_API_KEY") + if openai_key: + from openai import OpenAI + + return "openai", OpenAI(api_key=openai_key) + + raise LLMNotConfiguredError("Set GITHUB_OPENAI_API_KEY or OPENAI_API_KEY to enable AI features.") + + +def complete_text(*, system_prompt: str, user_prompt: str, temperature: float, max_tokens: int) -> str: + provider, client = get_llm_client() + if provider == "github-models": + from azure.ai.inference.models import SystemMessage, UserMessage + + response = client.complete( + model=os.getenv("GITHUB_MODELS_MODEL", "openai/gpt-4o"), + messages=[SystemMessage(content=system_prompt), UserMessage(content=user_prompt)], + temperature=temperature, + max_tokens=max_tokens, + ) + else: + response = client.chat.completions.create( + model=os.getenv("OPENAI_MODEL", "gpt-4o"), + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + temperature=temperature, + max_tokens=max_tokens, + ) + if not response or not response.choices or not response.choices[0].message.content: + raise RuntimeError("The configured LLM returned no content.") + return str(response.choices[0].message.content).strip() diff --git a/main.py b/main.py index 896b6b6..88d0eee 100644 --- a/main.py +++ b/main.py @@ -1,209 +1,87 @@ +"""FastAPI webhook edge for Git SMS.""" + +from __future__ import annotations + +import html +import logging +from typing import Annotated + +from dotenv import load_dotenv from fastapi import FastAPI, Form from fastapi.responses import Response -import requests, os, html -from dotenv import load_dotenv + +from commands import create_issue, create_repo from natural_language_router import route_natural_command -from azure.ai.inference import ChatCompletionsClient -from azure.ai.inference.models import SystemMessage, UserMessage -from azure.core.credentials import AzureKeyCredential +from summarizers import summarize_any_repo, summarize_latest_issue, summarize_specific_issue load_dotenv() -app = FastAPI() - -# Azure OpenAI configuration -endpoint = "https://models.github.ai/inference" -model_name = "openai/gpt-4o" -token = os.getenv("GITHUB_OPENAI_API_KEY") - -client = ChatCompletionsClient( - endpoint=endpoint, - credential=AzureKeyCredential(token), +log = logging.getLogger(__name__) +app = FastAPI(title="Git SMS", version="0.1.0") + +HELP_TEXT = ( + "Available commands:\n" + "- summarize owner/repo\n" + "- summarize owner/repo issue\n" + "- summarize owner/repo issue 123\n" + "Write commands remain disabled in this proof of concept." ) +SAFE_ERROR = "Something went wrong while processing that message. Please try again later." -def ask_openai(prompt: str): - prompt += "\n\nLimit the summary to no more than 1,000 characters. Return plain text only. No formatting." - print("\n[ask_openai] Prompt being sent:\n", prompt) - - try: - response = client.complete( - messages=[ - SystemMessage("You are a helpful assistant."), - UserMessage(prompt), - ], - temperature=0.7, - top_p=1.0, - max_tokens=700, - model=model_name - ) - if not response or not response.choices: - print("[ask_openai] No choices returned.") - return "Sorry, no summary was generated." - - result = response.choices[0].message.content.strip() - print(f"[ask_openai] Response length: {len(result)}") - if len(result) > 1000: - print("[ask_openai] Response too long.") - return "The summary was too long to send. Try summarizing a smaller repo or issue." - - return result - - except Exception as e: - print("[ask_openai] Exception:", e) - return "AI summarization failed. Please try again later." - -@app.post("/webhook") -async def sms_webhook(From: str = Form(...), Body: str = Form(...)): - text = Body.strip() - print(f"\n[webhook] Incoming from {From}: {text}") +def handle_message(*, sender: str, body: str) -> str: # noqa: ARG001 + """Handle one normalized inbound message and return reply text.""" + text = body.strip() parts = text.split() + lower = text.lower() - if text.lower().startswith("create repo") and len(parts) >= 3: - repo_name = parts[2] - success = create_repo(repo_name) - return twilio_reply(f"Created repo '{repo_name}'" if success else f"Failed to create repo '{repo_name}'.") - - if text.lower().startswith("create issue") and len(parts) >= 4: + if lower == "help": + return HELP_TEXT + if lower.startswith("create repo") and len(parts) >= 3: + return create_repo(parts[2]).message + if lower.startswith("create issue") and len(parts) >= 4: try: - pre, body = text.split(" -- ", 1) + pre, issue_body = text.split(" -- ", 1) _, _, repo, *title_parts = pre.split() - title = " ".join(title_parts) - success = create_issue(repo, title, body) - return twilio_reply(f"Issue created in '{repo}'" if success else f"Failed to create issue in '{repo}'.") except ValueError: - return twilio_reply("Usage: create issue -- <body>") + return "Usage: create issue <owner/repo> <title> -- <body>" + return create_issue(repo, " ".join(title_parts), issue_body).message + if lower.startswith("summarize") and len(parts) >= 2 and "/" in parts[1]: + repo = parts[1] + if len(parts) >= 4 and parts[2].lower() == "issue": + try: + issue_number = int(parts[3]) + except ValueError: + return "Issue number must be a positive integer." + return summarize_specific_issue(repo, issue_number) + if len(parts) >= 3 and parts[2].lower() == "issue": + return summarize_latest_issue(repo) + return summarize_any_repo(repo) - if text.lower() == "help": - return twilio_reply( - "Available commands:\n" - "- summarize owner/repo\n" - "- summarize owner/repo issue [#]" - ) - - if text.lower().startswith("summarize") and len(parts) >= 2 and "/" in parts[1]: - owner_repo = parts[1] - if len(parts) >= 4 and parts[2].lower() == "issue" and parts[3].isdigit(): - issue_number = int(parts[3]) - summary = summarize_specific_issue(owner_repo, issue_number) - return twilio_reply(summary or "Could not summarize issue.") - elif len(parts) >= 3 and parts[2].lower() == "issue": - summary = summarize_latest_issue(owner_repo) - return twilio_reply(summary or "Could not summarize the latest issue.") - else: - summary = summarize_any_repo(owner_repo) - return twilio_reply(summary or "Could not summarize that repo.") - - # 🔁 Fall back to natural language routing - print("[fallback] Attempting natural language parse") fallback = route_natural_command(text) - if fallback: - return twilio_reply(fallback) - - return twilio_reply("Unrecognized command. Text 'help' for available options.") - -def get_headers(): - # Only read-only access with no authentication token - return { - "Accept": "application/vnd.github+json" - } - -def create_repo(name): - # Write operations are disabled in this prototype. - print("[create_repo] Disabled: write operations are disabled in this prototype.") - return False + return fallback or "Unrecognized command. Text HELP for available commands." -def create_issue(repo, title, body): - # Write operations are disabled in this prototype. - print("[create_issue] Disabled: write operations are disabled in this prototype.") - return False -def summarize_any_repo(owner_repo): - try: - owner, repo = owner_repo.split("/") - except ValueError: - return "Invalid format. Use <owner>/<repo>" - - readme_url = f"https://api.github.com/repos/{owner}/{repo}/readme" - repo_url = f"https://api.github.com/repos/{owner}/{repo}" - headers = {"Accept": "application/vnd.github+json"} - - readme_res = requests.get(readme_url, headers=headers) - repo_res = requests.get(repo_url, headers=headers) - - if not readme_res.ok or not repo_res.ok: - return None - - try: - download_url = readme_res.json().get("download_url") - readme_content = requests.get(download_url).text if download_url else "No README found." - if len(readme_content) > 4000: - readme_content = readme_content[:4000] - except Exception as e: - print("[summarize_any_repo] Error downloading README:", e) - readme_content = "No README content available." - - repo_data = repo_res.json() - prompt = f""" -Summarize this GitHub repo: - -Repo Name: {repo_data.get('name') or 'Unknown'} -Owner: {owner} -Description: {repo_data.get('description') or 'No description'} -Stars: {repo_data.get('stargazers_count', 0)} -Forks: {repo_data.get('forks_count', 0)} -Primary Language: {repo_data.get('language') or 'Unknown'} - -README: -{readme_content} -""" - return ask_openai(prompt) - -def summarize_latest_issue(owner_repo): - try: - owner, repo = owner_repo.split("/") - except ValueError: - return "Invalid format. Use <owner>/<repo>" - - issues_url = f"https://api.github.com/repos/{owner}/{repo}/issues" - res = requests.get(issues_url, headers=get_headers(), params={"state": "open", "per_page": 1}) - if not res.ok or not res.json(): - print("[summarize_latest_issue] Issue fetch failed:", res.status_code) - return None +def twilio_reply(message: str) -> Response: + """Return a valid TwiML message response.""" + escaped = html.escape(message or "No content.") + xml = ( + '<?xml version="1.0" encoding="UTF-8"?>\n' + "<Response>\n" + f" <Message>{escaped}</Message>\n" + "</Response>" + ) + return Response(content=xml, media_type="application/xml", status_code=200) - issue = res.json()[0] - return summarize_issue_thread(owner, repo, issue["number"]) -def summarize_specific_issue(owner_repo, issue_number): +@app.post("/webhook") +async def sms_webhook( + From: Annotated[str, Form()], # noqa: N803 + Body: Annotated[str, Form()], # noqa: N803 +) -> Response: + """Translate Twilio form fields at the edge and always return useful TwiML.""" try: - owner, repo = owner_repo.split("/") - except ValueError: - return "Invalid format. Use <owner>/<repo>" - - return summarize_issue_thread(owner, repo, issue_number) - -def summarize_issue_thread(owner, repo, issue_number): - issue_url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}" - comments_url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments" - issue_res = requests.get(issue_url, headers=get_headers()) - comments_res = requests.get(comments_url, headers=get_headers()) - - if not issue_res.ok or not comments_res.ok: - print("[summarize_issue_thread] Failed to fetch issue or comments.") - return None - - issue = issue_res.json() - thread = f"Issue #{issue_number}: {issue.get('title')}\n{issue.get('body', '')}" - for comment in comments_res.json(): - thread += "\n" + comment.get("body", "") - - prompt = f"Summarize this GitHub issue thread:\n{thread}" - return ask_openai(prompt) - -def twilio_reply(message: str): - print("[twilio_reply] Responding with message:", message) - escaped = html.escape(message or "No content.") - xml = f"""<?xml version="1.0" encoding="UTF-8"?> -<Response> - <Message>{escaped}</Message> -</Response>""" - return Response(content=xml, media_type="application/xml") + message = handle_message(sender=From, body=Body) + except Exception: + log.exception("Unexpected dispatch failure") + message = SAFE_ERROR + return twilio_reply(message) diff --git a/natural_language_router.py b/natural_language_router.py index 577a895..0806b70 100644 --- a/natural_language_router.py +++ b/natural_language_router.py @@ -1,129 +1,110 @@ -import os -import json -import requests -from dotenv import load_dotenv -from azure.ai.inference import ChatCompletionsClient -from azure.ai.inference.models import SystemMessage, UserMessage -from azure.core.credentials import AzureKeyCredential -from summarizers import summarize_any_repo, summarize_latest_issue -from commands import create_repo, create_issue - -load_dotenv() - -# Azure OpenAI config -endpoint = "https://models.github.ai/inference" -model_name = "openai/gpt-4o" -token = os.getenv("GITHUB_OPENAI_API_KEY") - -client = ChatCompletionsClient( - endpoint=endpoint, - credential=AzureKeyCredential(token), -) - -def github_search_repo(query): - headers = { - "Authorization": f"Bearer {os.getenv('GITHUB_TOKEN')}", - "Accept": "application/vnd.github+json" - } - - terms = "+".join(query.strip().split()) - url = f"https://api.github.com/search/repositories?q={terms}+in:name,description&sort=stars&order=desc&per_page=10" - - try: - res = requests.get(url, headers=headers) - if res.ok: - items = res.json().get("items", []) - query_lower = query.lower() - - # Prefer exact or substring match in full_name - for item in items: - if item["name"].lower() == query_lower or query_lower in item["full_name"].lower(): - print("[github_search_repo] Strong match:", item["full_name"]) - return item["full_name"] - - # Fallback: check description/name contains query - for item in items: - if query_lower in (item.get("description") or "").lower() or query_lower in item["name"].lower(): - print("[github_search_repo] Soft match:", item["full_name"]) - return item["full_name"] - - if items: - print("[github_search_repo] Weak fallback match:", items[0]["full_name"]) - return items[0]["full_name"] - except Exception as e: - print("[github_search_repo] Error:", e) +"""Optional natural-language routing for deterministic Git SMS commands.""" - return None - -def parse_command_naturally(user_input: str): - prompt = f''' -You are a GitHub command interpreter. Your job is to extract structured command intents from natural language inputs. - -Output your result as JSON with keys: action, repo, title, body, issue_number (use null for any missing fields). - -Examples: -Input: "What is internet in a box?" -{{"action": "summarize_repo", "repo": null, "title": null, "body": null, "issue_number": null}} +from __future__ import annotations -Input: "Summarize the latest issue in GitHub's OSPO repo" -{{"action": "summarize_latest_issue", "repo": "github/github-ospo", "title": null, "body": null, "issue_number": null}} +import json +import logging +from typing import Any -Input: "Create a repo called test-ai-bot" -{{"action": "create_repo", "repo": null, "title": null, "body": null, "issue_number": null, "repo_name": "test-ai-bot"}} +import requests -Input: "I want to file a bug in next.js" -{{"action": "create_issue", "repo": "vercel/next.js", "title": "Bug report", "body": null, "issue_number": null}} +from commands import REQUEST_TIMEOUT, create_issue, create_repo, get_headers +from llm_client import LLMNotConfiguredError, complete_text +from summarizers import summarize_any_repo, summarize_latest_issue, summarize_specific_issue + +log = logging.getLogger(__name__) +SUPPORTED_ACTIONS = { + "summarize_repo", + "summarize_latest_issue", + "summarize_specific_issue", + "create_repo", + "create_issue", + "unknown", +} +FALLBACK_NOT_CONFIGURED = ( + "I did not recognize that command, and AI command parsing is not configured. " + "Text HELP for deterministic commands." +) -Now extract the intent from: "{user_input}" -'''.strip() +def github_search_repo(query: str) -> str | None: try: - response = client.complete( - model=model_name, - temperature=0.2, - max_tokens=300, - messages=[ - SystemMessage("You extract structured commands from natural GitHub-related messages."), - UserMessage(prompt) - ] + response = requests.get( + "https://api.github.com/search/repositories", + headers=get_headers(), + params={ + "q": f"{query} in:name,description", + "sort": "stars", + "per_page": 10, + }, + timeout=REQUEST_TIMEOUT, ) - raw = response.choices[0].message.content.strip() - print("[parse_command_naturally] Raw output:", raw) - - if raw.startswith("```"): - raw = raw.strip("`").strip() - if raw.startswith("json"): - raw = raw[len("json"):].strip() - - parsed = json.loads(raw) - - # Guess repo if needed - if parsed.get("action") in ["summarize_repo", "summarize_latest_issue", "create_issue"] and not parsed.get("repo"): - guess = github_search_repo(user_input) - if guess: - parsed["repo"] = guess - print("[parse_command_naturally] Guessed repo:", guess) - - return parsed - except Exception as e: - print("[parse_command_naturally] Parsing failed:", e) - return {"action": "unknown", "repo": None, "title": None, "body": None, "issue_number": None} + payload = response.json() if response.ok else {} + except (requests.RequestException, ValueError): + return None + + items = payload.get("items", []) if isinstance(payload, dict) else [] + return str(items[0].get("full_name")) if items and isinstance(items[0], dict) else None + + +def parse_command_naturally(user_input: str) -> dict[str, Any]: + prompt = ( + "Return JSON only with keys action, repo, repo_name, title, body, issue_number. " + "Allowed actions: summarize_repo, summarize_latest_issue, summarize_specific_issue, " + "create_repo, create_issue, unknown. Use null for missing values.\n\n" + f"Input: {user_input}" + ) + raw = complete_text( + system_prompt="Extract a candidate GitHub command. Never execute it.", + user_prompt=prompt, + temperature=0.0, + max_tokens=300, + ) + if raw.startswith("```"): + raw = raw.strip("`").strip() + if raw.startswith("json"): + raw = raw[4:].strip() + + parsed = json.loads(raw) + if not isinstance(parsed, dict) or parsed.get("action") not in SUPPORTED_ACTIONS: + return {"action": "unknown"} + + summary_actions = { + "summarize_repo", + "summarize_latest_issue", + "summarize_specific_issue", + } + if parsed.get("action") in summary_actions and not parsed.get("repo"): + parsed["repo"] = github_search_repo(user_input) + return parsed -def route_natural_command(user_text: str): - intent = parse_command_naturally(user_text) - print("[route_natural_command] Parsed intent:", intent) - action = intent.get("action") - repo = intent.get("repo") - if action == "summarize_repo" and repo: +def route_natural_command(user_text: str) -> str | None: + try: + intent = parse_command_naturally(user_text) + except LLMNotConfiguredError: + return FALLBACK_NOT_CONFIGURED + except (json.JSONDecodeError, TypeError, ValueError): + return "I could not parse that request. Text HELP for available commands." + except Exception: + log.exception("Natural-language parsing failed") + return "AI command parsing failed. Text HELP for deterministic commands." + + action, repo = intent.get("action"), intent.get("repo") + if action == "summarize_repo" and isinstance(repo, str): return summarize_any_repo(repo) - if action == "summarize_latest_issue" and repo: + if action == "summarize_latest_issue" and isinstance(repo, str): return summarize_latest_issue(repo) - if action == "create_repo" and intent.get("repo_name"): - return "Created repo." if create_repo(intent["repo_name"]) else "Failed to create repo." - if action == "create_issue" and repo: - title = intent.get("title") or "Issue" - body = intent.get("body") or "" - return "Issue created." if create_issue(repo.split("/")[-1], title, body) else "Failed to create issue." - + if ( + action == "summarize_specific_issue" + and isinstance(repo, str) + and isinstance(intent.get("issue_number"), int) + ): + return summarize_specific_issue(repo, intent["issue_number"]) + if action == "create_repo" and isinstance(intent.get("repo_name"), str): + return create_repo(intent["repo_name"]).message + if action == "create_issue" and isinstance(repo, str): + title = str(intent.get("title") or "Issue") + body = str(intent.get("body") or "") + return create_issue(repo, title, body).message return None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c07fb1c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] +ignore = ["B008"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..e7e93a0 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==9.0.0 +ruff==0.14.4 diff --git a/requirements.txt b/requirements.txt index 84d2293..f2f08a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -fastapi -uvicorn -python-multipart -requests -python-dotenv -openai -azure-ai-inference -tiktoken>=0.5.1 \ No newline at end of file +azure-ai-inference==1.0.0b9 +fastapi==0.121.1 +openai==2.7.1 +python-dotenv==1.2.1 +python-multipart==0.0.20 +requests==2.32.5 +tiktoken==0.12.0 +uvicorn==0.38.0 diff --git a/summarizers.py b/summarizers.py index e6651f8..8db0bef 100644 --- a/summarizers.py +++ b/summarizers.py @@ -1,156 +1,212 @@ -import os -import requests -import tiktoken +"""Read-only GitHub repository and issue summarization.""" + +from __future__ import annotations + +import base64 import logging -from dotenv import load_dotenv +from functools import lru_cache +from typing import Any + +import requests -load_dotenv() +from commands import REQUEST_TIMEOUT, get_headers +from llm_client import LLMNotConfiguredError, complete_text -# Setup logging log = logging.getLogger(__name__) +GITHUB_API = "https://api.github.com" +MAX_TOKENS = 16000 +SUMMARY_NOT_CONFIGURED = ( + "AI summarization is not configured. Set GITHUB_OPENAI_API_KEY or OPENAI_API_KEY." +) -# OpenAI configuration (Azure preferred if available) -USE_AZURE = bool(os.getenv("GITHUB_OPENAI_API_KEY")) -if USE_AZURE: - from azure.ai.inference import ChatCompletionsClient - from azure.ai.inference.models import SystemMessage, UserMessage - from azure.core.credentials import AzureKeyCredential - AZURE_ENDPOINT = "https://models.github.ai/inference" - MODEL_NAME = "openai/gpt-4o" - AZURE_TOKEN = os.getenv("GITHUB_OPENAI_API_KEY") +@lru_cache(maxsize=1) +def get_encoder() -> Any: + import tiktoken - client = ChatCompletionsClient( - endpoint=AZURE_ENDPOINT, - credential=AzureKeyCredential(AZURE_TOKEN), - ) -else: - from openai import OpenAI - MODEL_NAME = "gpt-4o" - OPENAI_TOKEN = os.getenv("OPENAI_API_KEY") - client = OpenAI(api_key=OPENAI_TOKEN) + return tiktoken.get_encoding("cl100k_base") -ENCODER = tiktoken.get_encoding("cl100k_base") -MAX_TOKENS = 16000 -def num_tokens(text): - return len(ENCODER.encode(text)) +def num_tokens(text: str) -> int: + return len(get_encoder().encode(text)) -def truncate_text(text, token_limit): - tokens = ENCODER.encode(text) - return ENCODER.decode(tokens[:token_limit]) -def ask_openai(prompt): +def truncate_text(text: str, token_limit: int) -> str: + encoder = get_encoder() + return encoder.decode(encoder.encode(text)[: max(0, token_limit)]) + + +def _json(response: requests.Response) -> Any | None: try: - if USE_AZURE: - response = client.complete( - model=MODEL_NAME, - temperature=0.2, - max_tokens=700, - messages=[ - SystemMessage(content="Summarize GitHub repo content in under 1000 characters."), - UserMessage(content=prompt) - ] - ) - return response.choices[0].message.content.strip() - else: - response = client.chat.completions.create( - model=MODEL_NAME, - temperature=0.2, - max_tokens=700, - messages=[ - {"role": "system", "content": "Summarize GitHub repo content in under 1000 characters."}, - {"role": "user", "content": prompt} - ] - ) - return response.choices[0].message.content.strip() - except Exception as e: - log.error("[ask_openai] Error: %s", e) + return response.json() + except ValueError: return None -def summarize_any_repo(repo_full_name): - print(f"[summarize_any_repo] Summarizing {repo_full_name}") - headers = { - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {os.getenv('GITHUB_TOKEN')}" - } - repo_url = f"https://api.github.com/repos/{repo_full_name}" - readme_url = f"{repo_url}/readme" +def _error(response: requests.Response, noun: str) -> str: + if response.status_code == 404: + return f"Could not find that {noun}." + if response.status_code in {403, 429}: + return "GitHub API rate limit or access restriction encountered. Try again later." + return f"GitHub could not return that {noun}." + +def _summarize(prompt: str) -> str: try: - repo_res = requests.get(repo_url, headers=headers) - readme_res = requests.get(readme_url, headers=headers) - - if not repo_res.ok: - return "Could not find repo." - - repo = repo_res.json() - readme = readme_res.json().get("content", "") if readme_res.ok else "" - - readme = readme.encode("utf-8") - import base64 - readme_text = base64.b64decode(readme).decode("utf-8", errors="ignore") - - prompt = f""" -Summarize this GitHub repo: - -Repo Name: {repo.get("name")} -Owner: {repo.get("owner", {}).get("login")} -Description: {repo.get("description")} -Stars: {repo.get("stargazers_count")} -Forks: {repo.get("forks_count")} -Primary Language: {repo.get("language")} - -README: -{readme_text} - """.strip() - - total_tokens = num_tokens(prompt) - if total_tokens > MAX_TOKENS: - print(f"[summarize_any_repo] Trimming README to fit {MAX_TOKENS} token budget.") - allowable_tokens = MAX_TOKENS - num_tokens(prompt) + num_tokens(readme_text) - readme_text = truncate_text(readme_text, allowable_tokens) - prompt = f""" -Summarize this GitHub repo: - -Repo Name: {repo.get("name")} -Owner: {repo.get("owner", {}).get("login")} -Description: {repo.get("description")} -Stars: {repo.get("stargazers_count")} -Forks: {repo.get("forks_count")} -Primary Language: {repo.get("language")} - -README: -{readme_text} - """.strip() - - summary = ask_openai(prompt) - return summary or "AI summarization failed." - - except Exception as e: - log.error("[summarize_any_repo] Error: %s", e) - return "Something went wrong." - -def summarize_latest_issue(repo_full_name): - print(f"[summarize_latest_issue] Summarizing latest issue in {repo_full_name}") - headers = { - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {os.getenv('GITHUB_TOKEN')}" - } - - issues_url = f"https://api.github.com/repos/{repo_full_name}/issues" + result = complete_text( + system_prompt="Summarize GitHub content in plain text under 1,000 characters.", + user_prompt=prompt, + temperature=0.2, + max_tokens=700, + ) + except LLMNotConfiguredError: + return SUMMARY_NOT_CONFIGURED + except Exception: + log.exception("LLM summarization failed") + return "AI summarization failed. Please try again later." + return result if len(result) <= 1000 else ( + "The summary was too long to send. Try summarizing less content." + ) + + +def _bounded_prompt(prefix: str, content: str) -> str | None: try: - res = requests.get(issues_url, headers=headers, params={"state": "open", "per_page": 1}) - if not res.ok or not res.json(): - return "No issues found." - issue = res.json()[0] - - issue_text = f"Issue #{issue['number']}: {issue['title']}\n{issue.get('body', '')}" - prompt = f"Summarize this GitHub issue thread:\n{issue_text}\n\nLimit the summary to no more than 1,000 characters. Return plain text only. No formatting." - - summary = ask_openai(prompt) - return summary or "AI summarization failed." - except Exception as e: - log.error("[summarize_latest_issue] Error: %s", e) - return "Failed to summarize issue." + available = max(0, MAX_TOKENS - num_tokens(prefix)) + if num_tokens(content) > available: + content = truncate_text(content, available) + except Exception: + log.exception("Tokenizer failed") + return None + return prefix + content + + +def summarize_any_repo(repo_full_name: str) -> str: + if repo_full_name.count("/") != 1: + return "Invalid format. Use <owner>/<repo>." + + repo_url = f"{GITHUB_API}/repos/{repo_full_name}" + try: + repo_response = requests.get(repo_url, headers=get_headers(), timeout=REQUEST_TIMEOUT) + if not repo_response.ok: + return _error(repo_response, "repository") + repo = _json(repo_response) + if not isinstance(repo, dict): + return "GitHub returned an invalid repository response." + + readme_response = requests.get( + f"{repo_url}/readme", + headers=get_headers(), + timeout=REQUEST_TIMEOUT, + ) + readme_text = "No README content available." + if readme_response.ok: + readme = _json(readme_response) + encoded = readme.get("content") if isinstance(readme, dict) else None + if encoded: + try: + readme_text = base64.b64decode(encoded).decode("utf-8", errors="ignore") + except (ValueError, TypeError): + readme_text = "README content could not be decoded." + except requests.Timeout: + return "GitHub did not respond in time. Please try again." + except requests.RequestException: + log.exception("GitHub repository request failed") + return "Could not reach GitHub. Please try again later." + + owner = (repo.get("owner") or {}).get("login") or "Unknown" + prefix = ( + "Summarize this GitHub repository:\n\n" + f"Repo Name: {repo.get('name') or 'Unknown'}\n" + f"Owner: {owner}\n" + f"Description: {repo.get('description') or 'No description'}\n" + f"Stars: {repo.get('stargazers_count', 0)}\n" + f"Forks: {repo.get('forks_count', 0)}\n" + f"Primary Language: {repo.get('language') or 'Unknown'}\n\n" + "README:\n" + ) + prompt = _bounded_prompt(prefix, readme_text) + if prompt is None: + return "AI summarization is temporarily unavailable. Please try again later." + return _summarize(prompt) + + +def summarize_latest_issue(repo_full_name: str) -> str: + if repo_full_name.count("/") != 1: + return "Invalid format. Use <owner>/<repo>." + + try: + response = requests.get( + f"{GITHUB_API}/repos/{repo_full_name}/issues", + headers=get_headers(), + params={"state": "open", "per_page": 10}, + timeout=REQUEST_TIMEOUT, + ) + if not response.ok: + return _error(response, "issue list") + payload = _json(response) + if not isinstance(payload, list): + return "GitHub returned an invalid issue response." + except requests.Timeout: + return "GitHub did not respond in time. Please try again." + except requests.RequestException: + return "Could not reach GitHub. Please try again later." + + issue = next( + (item for item in payload if isinstance(item, dict) and "pull_request" not in item), + None, + ) + if not issue: + return "No open issues found." + try: + issue_number = int(issue["number"]) + except (KeyError, TypeError, ValueError): + return "GitHub returned an invalid issue response." + return summarize_specific_issue(repo_full_name, issue_number) + + +def summarize_specific_issue(repo_full_name: str, issue_number: int) -> str: + if repo_full_name.count("/") != 1: + return "Invalid format. Use <owner>/<repo>." + if issue_number < 1: + return "Issue number must be positive." + + base = f"{GITHUB_API}/repos/{repo_full_name}/issues/{issue_number}" + try: + issue_response = requests.get(base, headers=get_headers(), timeout=REQUEST_TIMEOUT) + if not issue_response.ok: + return _error(issue_response, "issue") + issue = _json(issue_response) + if not isinstance(issue, dict): + return "GitHub returned an invalid issue response." + if "pull_request" in issue: + return f"#{issue_number} is a pull request, not an issue." + + comments_response = requests.get( + f"{base}/comments", + headers=get_headers(), + timeout=REQUEST_TIMEOUT, + ) + if not comments_response.ok: + return _error(comments_response, "issue comments") + comments = _json(comments_response) + if not isinstance(comments, list): + return "GitHub returned an invalid issue response." + except requests.Timeout: + return "GitHub did not respond in time. Please try again." + except requests.RequestException: + return "Could not reach GitHub. Please try again later." + + thread = [ + f"Issue #{issue_number}: {issue.get('title') or 'Untitled'}", + str(issue.get("body") or ""), + ] + thread.extend( + str(comment.get("body") or "") + for comment in comments + if isinstance(comment, dict) + ) + prefix = "Summarize this GitHub issue thread:\n" + prompt = _bounded_prompt(prefix, "\n".join(thread)) + if prompt is None: + return "AI summarization is temporarily unavailable. Please try again later." + return _summarize(prompt) diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..c0e6cc6 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,22 @@ +from commands import create_issue, create_repo, get_headers + + +def test_headers_do_not_emit_bearer_none(monkeypatch): + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + assert get_headers() == {"Accept": "application/vnd.github+json"} + + +def test_headers_include_configured_token(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "example-token") + assert get_headers()["Authorization"] == "Bearer example-token" + + +def test_disabled_write_outcomes_are_explicit(): + assert ( + create_repo("demo").message + == "Repository creation is disabled in this proof of concept." + ) + assert ( + create_issue("o/r", "title", "body").message + == "Issue creation is disabled in this proof of concept." + ) diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 0000000..c242056 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,29 @@ +import os +import subprocess +import sys +from pathlib import Path + + +def test_modules_import_without_configuration(): + repo_root = Path(__file__).resolve().parents[1] + env = os.environ.copy() + for name in ("GITHUB_TOKEN", "GITHUB_OPENAI_API_KEY", "OPENAI_API_KEY"): + env.pop(name, None) + env["PYTHONDONTWRITEBYTECODE"] = "1" + + code = ( + "import os, sys; " + "sys.path.insert(0, os.getcwd()); " + "import commands, llm_client, summarizers, natural_language_router, main" + ) + result = subprocess.run( + [sys.executable, "-I", "-c", code], + cwd=repo_root, + env=env, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/test_natural_language_router.py b/tests/test_natural_language_router.py new file mode 100644 index 0000000..7c6b9fd --- /dev/null +++ b/tests/test_natural_language_router.py @@ -0,0 +1,52 @@ +import json + +import natural_language_router + + +def test_natural_language_create_repo_still_disabled(monkeypatch): + monkeypatch.setattr( + natural_language_router, + "complete_text", + lambda **_: json.dumps({"action": "create_repo", "repo_name": "demo"}), + ) + assert ( + natural_language_router.route_natural_command("create a repo called demo") + == "Repository creation is disabled in this proof of concept." + ) + + +def test_natural_language_create_issue_still_disabled(monkeypatch): + monkeypatch.setattr( + natural_language_router, + "complete_text", + lambda **_: json.dumps( + { + "action": "create_issue", + "repo": "owner/repo", + "title": "Bug", + "body": "Details", + } + ), + ) + assert ( + natural_language_router.route_natural_command("file a bug in owner/repo") + == "Issue creation is disabled in this proof of concept." + ) + + +def test_parse_command_naturally_rejects_unsupported_action(monkeypatch): + monkeypatch.setattr( + natural_language_router, + "complete_text", + lambda **_: json.dumps({"action": "delete_repo", "repo": "owner/repo"}), + ) + assert natural_language_router.parse_command_naturally("delete it") == {"action": "unknown"} + + +def test_route_natural_command_rejects_non_string_repo(monkeypatch): + monkeypatch.setattr( + natural_language_router, + "complete_text", + lambda **_: json.dumps({"action": "create_issue", "repo": ["owner/repo"]}), + ) + assert natural_language_router.route_natural_command("file an issue") is None diff --git a/tests/test_summarizers.py b/tests/test_summarizers.py new file mode 100644 index 0000000..899e149 --- /dev/null +++ b/tests/test_summarizers.py @@ -0,0 +1,102 @@ +import summarizers + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + self.ok = 200 <= status_code < 300 + + def json(self): + return self._payload + + +class FakeEncoder: + def encode(self, text): + return list(text) + + def decode(self, tokens): + return "".join(tokens) + + +def test_latest_issue_skips_pull_requests(monkeypatch): + calls = [] + + def fake_get(url, **kwargs): + calls.append(url) + if url.endswith("/issues"): + return FakeResponse( + [ + {"number": 9, "pull_request": {}}, + {"number": 8, "title": "Real issue"}, + ] + ) + if url.endswith("/issues/8"): + return FakeResponse({"number": 8, "title": "Real issue", "body": "body"}) + if url.endswith("/issues/8/comments"): + return FakeResponse([]) + raise AssertionError(url) + + monkeypatch.setattr(summarizers.requests, "get", fake_get) + monkeypatch.setattr(summarizers, "get_encoder", lambda: FakeEncoder()) + monkeypatch.setattr(summarizers, "complete_text", lambda **_: "summary") + assert summarizers.summarize_latest_issue("owner/repo") == "summary" + assert any(url.endswith("/issues/8") for url in calls) + + +def test_specific_issue_rejects_pull_request(monkeypatch): + calls = [] + + def fake_get(url, **kwargs): + calls.append(url) + return FakeResponse( + { + "number": 12, + "title": "This is actually a PR", + "pull_request": {"url": "https://api.github.com/example"}, + } + ) + + monkeypatch.setattr(summarizers.requests, "get", fake_get) + + assert ( + summarizers.summarize_specific_issue("owner/repo", 12) + == "#12 is a pull request, not an issue." + ) + assert not any(url.endswith("/comments") for url in calls) + + +def test_repo_summary_without_llm_credentials_is_safe(monkeypatch): + def fake_get(url, **kwargs): + if url.endswith("/readme"): + return FakeResponse({"content": ""}) + return FakeResponse({"name": "repo", "owner": {"login": "owner"}}) + + def raise_not_configured(**kwargs): + raise summarizers.LLMNotConfiguredError + + monkeypatch.setattr(summarizers.requests, "get", fake_get) + monkeypatch.setattr(summarizers, "num_tokens", lambda _: 1) + monkeypatch.setattr(summarizers, "complete_text", raise_not_configured) + assert summarizers.summarize_any_repo("owner/repo") == summarizers.SUMMARY_NOT_CONFIGURED + + +def test_specific_issue_summary_truncates_large_thread(monkeypatch): + captured = {} + + def fake_get(url, **kwargs): + if url.endswith("/comments"): + return FakeResponse([{"body": "comment " * 100}]) + return FakeResponse({"title": "Large issue", "body": "body " * 100}) + + def fake_complete_text(**kwargs): + captured["prompt"] = kwargs["user_prompt"] + return "summary" + + monkeypatch.setattr(summarizers.requests, "get", fake_get) + monkeypatch.setattr(summarizers, "get_encoder", lambda: FakeEncoder()) + monkeypatch.setattr(summarizers, "MAX_TOKENS", 80) + monkeypatch.setattr(summarizers, "complete_text", fake_complete_text) + + assert summarizers.summarize_specific_issue("owner/repo", 4) == "summary" + assert len(captured["prompt"]) <= 80 diff --git a/tests/test_twiml.py b/tests/test_twiml.py new file mode 100644 index 0000000..b1cb87f --- /dev/null +++ b/tests/test_twiml.py @@ -0,0 +1,7 @@ +from main import twilio_reply + + +def test_twiml_escapes_message_content(): + response = twilio_reply('<tag attr="x">&') + assert response.status_code == 200 + assert "<tag attr="x">&" in response.body.decode() diff --git a/tests/test_webhook.py b/tests/test_webhook.py new file mode 100644 index 0000000..c9a77d9 --- /dev/null +++ b/tests/test_webhook.py @@ -0,0 +1,44 @@ +from fastapi.testclient import TestClient + +import main + +client = TestClient(main.app) + + +def post(body: str): + return client.post( + "/webhook", + data={"From": "+15555550123", "Body": body}, + ) + + +def test_help_returns_valid_twiml(): + response = post("help") + assert response.status_code == 200 + assert "Available commands" in response.text + + +def test_deterministic_write_commands_are_disabled(): + assert "Repository creation is disabled" in post("create repo demo").text + assert ( + "Issue creation is disabled" + in post("create issue owner/repo title -- body").text + ) + + +def test_missing_llm_credentials_returns_useful_twiml(monkeypatch): + monkeypatch.delenv("GITHUB_OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + response = post("something unrecognized") + assert response.status_code == 200 + assert "AI command parsing is not configured" in response.text + + +def test_unexpected_exception_returns_safe_twiml(monkeypatch): + def raise_runtime_error(**kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(main, "handle_message", raise_runtime_error) + response = post("help") + assert response.status_code == 200 + assert main.SAFE_ERROR in response.text