From a4aa127e0b754868a1c1be6e13f358c611f2e241 Mon Sep 17 00:00:00 2001 From: ApexAiOfficial Date: Tue, 4 Aug 2026 20:14:37 -0400 Subject: [PATCH 1/8] fix: restore modular prototype baseline --- .github/workflows/ci.yml | 23 ++++ .gitignore | 16 +++ README.md | 76 +++-------- SETUP.md | 71 +++------- commands.py | 52 ++++++-- llm_client.py | 55 ++++++++ main.py | 250 +++++++++--------------------------- natural_language_router.py | 169 +++++++----------------- pyproject.toml | 10 ++ requirements-dev.txt | 4 + requirements.txt | 16 +-- summarizers.py | 256 ++++++++++++++++--------------------- tests/test_commands.py | 9 ++ tests/test_import.py | 7 + tests/test_summarizers.py | 31 +++++ tests/test_twiml.py | 6 + tests/test_webhook.py | 28 ++++ 17 files changed, 496 insertions(+), 583 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 llm_client.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 tests/test_commands.py create mode 100644 tests/test_import.py create mode 100644 tests/test_summarizers.py create mode 100644 tests/test_twiml.py create mode 100644 tests/test_webhook.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..853bea3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI +on: + push: + branches: [main, "revival/**"] + 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..516bb07 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,39 @@ -# 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.** -## What It Is +## Current implementation -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. +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. -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. +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. -## Why It Matters +## Security model -93% of the world has access to SMS. +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. -Fewer than 65% have reliable internet or a smartphone. +## Roadmap -Everyone should be able to participate in global innovation, contribute to the open source community, learn from others and interact -with AI. +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. -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 +## Why it matters -## Use Cases +SMS can provide a low-bandwidth interface for open-source participation, repository status, education, field reporting, and carefully bounded automation. -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* - -- **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. - -*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. - -*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. - -*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. - -*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. - -## 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..d610b1e --- /dev/null +++ b/llm_client.py @@ -0,0 +1,55 @@ +"""Lazy LLM provider selection for Git SMS.""" + +from __future__ import annotations + +import os +from functools import lru_cache +from typing import Any + + +class LLMNotConfiguredError(RuntimeError): + """Raised when no supported LLM credential is configured.""" + + +@lru_cache(maxsize=1) +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..e85e8e1 100644 --- a/main.py +++ b/main.py @@ -1,209 +1,83 @@ +"""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>") - - 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] + 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" 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.") + return summarize_specific_issue(repo, int(parts[3])) + if len(parts) >= 3 and parts[2].lower() == "issue": + return summarize_latest_issue(repo) + return summarize_any_repo(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..dfc03f7 100644 --- a/natural_language_router.py +++ b/natural_language_router.py @@ -1,129 +1,58 @@ -import os +"""Optional natural-language routing for deterministic Git SMS commands.""" +from __future__ import annotations import json +import logging +from typing import Any 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 +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 -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" +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." +def github_search_repo(query: str) -> str | None: 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) - - 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}} - -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}} - -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"}} - -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}} - -Now extract the intent from: "{user_input}" -'''.strip() - + 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) + 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]: + raw = complete_text(system_prompt="Extract a candidate GitHub command. Never execute it.", user_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\nInput: " + user_input), 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"} + if parsed.get("action") in {"summarize_repo", "summarize_latest_issue", "summarize_specific_issue"} and not parsed.get("repo"): + parsed["repo"] = github_search_repo(user_input) + return parsed + +def route_natural_command(user_text: 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) - ] - ) - 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} - -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: + 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): + return create_issue(repo, str(intent.get("title") or "Issue"), str(intent.get("body") or "")).message return None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a3bc858 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] + +[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..3fa1b22 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==9.0.0 +pytest-mock==3.15.1 +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..86971f3 100644 --- a/summarizers.py +++ b/summarizers.py @@ -1,156 +1,126 @@ -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 - -load_dotenv() +from functools import lru_cache +from typing import Any +import requests +from commands import REQUEST_TIMEOUT, get_headers +from llm_client import LLMNotConfiguredError, complete_text -# Setup logging log = logging.getLogger(__name__) - -# 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") - - 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) - -ENCODER = tiktoken.get_encoding("cl100k_base") +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." -def num_tokens(text): - return len(ENCODER.encode(text)) +@lru_cache(maxsize=1) +def get_encoder() -> Any: + import tiktoken + return tiktoken.get_encoding("cl100k_base") -def truncate_text(text, token_limit): - tokens = ENCODER.encode(text) - return ENCODER.decode(tokens[:token_limit]) +def num_tokens(text: str) -> int: + return len(get_encoder().encode(text)) -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 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: - 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." + 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." + prefix = ("Summarize this GitHub repository:\n\n" f"Repo Name: {repo.get('name') or 'Unknown'}\n" f"Owner: {(repo.get('owner') or {}).get('login') or 'Unknown'}\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\nREADME:\n") + try: + available = MAX_TOKENS - num_tokens(prefix) + if num_tokens(readme_text) > available: + readme_text = truncate_text(readme_text, available) + except Exception: + log.exception("Tokenizer failed") + return "AI summarization is temporarily unavailable. Please try again later." + return _summarize(prefix + readme_text) + +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) + return summarize_specific_issue(repo_full_name, int(issue["number"])) if issue else "No open issues found." + +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") + comments_response = requests.get(f"{base}/comments", headers=get_headers(), timeout=REQUEST_TIMEOUT) + if not comments_response.ok: + return _error(comments_response, "issue comments") + issue, comments = _json(issue_response), _json(comments_response) + if not isinstance(issue, dict) or 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)) + return _summarize("Summarize this GitHub issue thread:\n" + "\n".join(thread)) diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..d24c32e --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,9 @@ +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_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..e78d387 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,7 @@ +import importlib + +def test_modules_import_without_configuration(monkeypatch): + for name in ("GITHUB_TOKEN", "GITHUB_OPENAI_API_KEY", "OPENAI_API_KEY"): + monkeypatch.delenv(name, raising=False) + for module_name in ("commands", "llm_client", "summarizers", "natural_language_router", "main"): + importlib.import_module(module_name) diff --git a/tests/test_summarizers.py b/tests/test_summarizers.py new file mode 100644 index 0000000..c151cbd --- /dev/null +++ b/tests/test_summarizers.py @@ -0,0 +1,31 @@ +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 + +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, "complete_text", lambda **_: "summary") + assert summarizers.summarize_latest_issue("owner/repo") == "summary" + assert any(url.endswith("/issues/8") for url in calls) + +def test_repo_summary_without_llm_credentials_is_safe(monkeypatch): + monkeypatch.setattr(summarizers.requests, "get", lambda url, **kwargs: FakeResponse({"content": ""} if url.endswith("/readme") else {"name": "repo", "owner": {"login": "owner"}})) + monkeypatch.setattr(summarizers, "num_tokens", lambda _: 1) + monkeypatch.setattr(summarizers, "complete_text", lambda **_: (_ for _ in ()).throw(summarizers.LLMNotConfiguredError())) + assert summarizers.summarize_any_repo("owner/repo") == summarizers.SUMMARY_NOT_CONFIGURED diff --git a/tests/test_twiml.py b/tests/test_twiml.py new file mode 100644 index 0000000..6fca3be --- /dev/null +++ b/tests/test_twiml.py @@ -0,0 +1,6 @@ +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..1b39266 --- /dev/null +++ b/tests/test_webhook.py @@ -0,0 +1,28 @@ +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): + monkeypatch.setattr(main, "handle_message", lambda **_: (_ for _ in ()).throw(RuntimeError("boom"))) + response = post("help") + assert response.status_code == 200 + assert main.SAFE_ERROR in response.text From 9eb8995bb6d65424a01fc9cdb6bed04ac8d3de84 Mon Sep 17 00:00:00 2001 From: ApexAiOfficial <admin@apexaiofficial.com> Date: Wed, 5 Aug 2026 03:42:11 -0400 Subject: [PATCH 2/8] style: satisfy Ruff checks --- natural_language_router.py | 66 ++++++++++++++++++--- summarizers.py | 114 +++++++++++++++++++++++++++++++------ tests/test_commands.py | 12 +++- tests/test_import.py | 9 ++- tests/test_summarizers.py | 34 +++++++++-- tests/test_twiml.py | 1 + tests/test_webhook.py | 22 ++++++- 7 files changed, 224 insertions(+), 34 deletions(-) diff --git a/natural_language_router.py b/natural_language_router.py index dfc03f7..0806b70 100644 --- a/natural_language_router.py +++ b/natural_language_router.py @@ -1,39 +1,84 @@ """Optional natural-language routing for deterministic Git SMS commands.""" + from __future__ import annotations + import json import logging from typing import Any + import requests + 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." +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." +) + def github_search_repo(query: str) -> str | None: try: - 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) + 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, + ) 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]: - raw = complete_text(system_prompt="Extract a candidate GitHub command. Never execute it.", user_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\nInput: " + user_input), temperature=0.0, max_tokens=300) + 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"} - if parsed.get("action") in {"summarize_repo", "summarize_latest_issue", "summarize_specific_issue"} and not parsed.get("repo"): + + 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) -> str | None: try: intent = parse_command_naturally(user_text) @@ -44,15 +89,22 @@ def route_natural_command(user_text: str) -> str | None: 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 isinstance(repo, str): return summarize_latest_issue(repo) - if action == "summarize_specific_issue" and isinstance(repo, str) and isinstance(intent.get("issue_number"), int): + 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): - return create_issue(repo, str(intent.get("title") or "Issue"), str(intent.get("body") or "")).message + 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/summarizers.py b/summarizers.py index 86971f3..162acde 100644 --- a/summarizers.py +++ b/summarizers.py @@ -1,29 +1,40 @@ """Read-only GitHub repository and issue summarization.""" + from __future__ import annotations + import base64 import logging from functools import lru_cache from typing import Any + import requests + from commands import REQUEST_TIMEOUT, get_headers from llm_client import LLMNotConfiguredError, complete_text 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." +SUMMARY_NOT_CONFIGURED = ( + "AI summarization is not configured. Set GITHUB_OPENAI_API_KEY or OPENAI_API_KEY." +) + @lru_cache(maxsize=1) def get_encoder() -> Any: import tiktoken + return tiktoken.get_encoding("cl100k_base") + def num_tokens(text: str) -> int: return len(get_encoder().encode(text)) + def truncate_text(text: str, token_limit: int) -> str: encoder = get_encoder() - return encoder.decode(encoder.encode(text)[:max(0, token_limit)]) + return encoder.decode(encoder.encode(text)[: max(0, token_limit)]) + def _json(response: requests.Response) -> Any | None: try: @@ -31,6 +42,7 @@ def _json(response: requests.Response) -> Any | None: except ValueError: return None + def _error(response: requests.Response, noun: str) -> str: if response.status_code == 404: return f"Could not find that {noun}." @@ -38,35 +50,57 @@ def _error(response: requests.Response, noun: str) -> str: 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: - result = complete_text(system_prompt="Summarize GitHub content in plain text under 1,000 characters.", user_prompt=prompt, temperature=0.2, max_tokens=700) + 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." + return result if len(result) <= 1000 else ( + "The summary was too long to send. Try summarizing less 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) + 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_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") + readme_text = base64.b64decode(encoded).decode( + "utf-8", + errors="ignore", + ) except (ValueError, TypeError): readme_text = "README content could not be decoded." except requests.Timeout: @@ -74,7 +108,18 @@ def summarize_any_repo(repo_full_name: str) -> str: except requests.RequestException: log.exception("GitHub repository request failed") return "Could not reach GitHub. Please try again later." - prefix = ("Summarize this GitHub repository:\n\n" f"Repo Name: {repo.get('name') or 'Unknown'}\n" f"Owner: {(repo.get('owner') or {}).get('login') or 'Unknown'}\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\nREADME:\n") + + 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" + ) try: available = MAX_TOKENS - num_tokens(prefix) if num_tokens(readme_text) > available: @@ -84,11 +129,18 @@ def summarize_any_repo(repo_full_name: str) -> str: return "AI summarization is temporarily unavailable. Please try again later." return _summarize(prefix + readme_text) + 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) + 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) @@ -98,20 +150,40 @@ def summarize_latest_issue(repo_full_name: str) -> str: 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) - return summarize_specific_issue(repo_full_name, int(issue["number"])) if issue else "No open issues found." + + 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." + return summarize_specific_issue(repo_full_name, int(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) + issue_response = requests.get( + base, + headers=get_headers(), + timeout=REQUEST_TIMEOUT, + ) if not issue_response.ok: return _error(issue_response, "issue") - comments_response = requests.get(f"{base}/comments", headers=get_headers(), timeout=REQUEST_TIMEOUT) + comments_response = requests.get( + f"{base}/comments", + headers=get_headers(), + timeout=REQUEST_TIMEOUT, + ) if not comments_response.ok: return _error(comments_response, "issue comments") issue, comments = _json(issue_response), _json(comments_response) @@ -121,6 +193,16 @@ def summarize_specific_issue(repo_full_name: str, issue_number: int) -> str: 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)) - return _summarize("Summarize this GitHub issue thread:\n" + "\n".join(thread)) + + 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) + ) + return _summarize( + "Summarize this GitHub issue thread:\n" + "\n".join(thread) + ) diff --git a/tests/test_commands.py b/tests/test_commands.py index d24c32e..022a1cf 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,9 +1,17 @@ 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_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." + 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 index e78d387..865b565 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -1,7 +1,14 @@ import importlib + def test_modules_import_without_configuration(monkeypatch): for name in ("GITHUB_TOKEN", "GITHUB_OPENAI_API_KEY", "OPENAI_API_KEY"): monkeypatch.delenv(name, raising=False) - for module_name in ("commands", "llm_client", "summarizers", "natural_language_router", "main"): + for module_name in ( + "commands", + "llm_client", + "summarizers", + "natural_language_router", + "main", + ): importlib.import_module(module_name) diff --git a/tests/test_summarizers.py b/tests/test_summarizers.py index c151cbd..fcb6cca 100644 --- a/tests/test_summarizers.py +++ b/tests/test_summarizers.py @@ -1,31 +1,55 @@ 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 + 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"}]) + 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"}) + 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, "complete_text", lambda **_: "summary") assert summarizers.summarize_latest_issue("owner/repo") == "summary" assert any(url.endswith("/issues/8") for url in calls) + def test_repo_summary_without_llm_credentials_is_safe(monkeypatch): - monkeypatch.setattr(summarizers.requests, "get", lambda url, **kwargs: FakeResponse({"content": ""} if url.endswith("/readme") else {"name": "repo", "owner": {"login": "owner"}})) + 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", lambda **_: (_ for _ in ()).throw(summarizers.LLMNotConfiguredError())) - assert summarizers.summarize_any_repo("owner/repo") == summarizers.SUMMARY_NOT_CONFIGURED + monkeypatch.setattr(summarizers, "complete_text", raise_not_configured) + assert ( + summarizers.summarize_any_repo("owner/repo") + == summarizers.SUMMARY_NOT_CONFIGURED + ) diff --git a/tests/test_twiml.py b/tests/test_twiml.py index 6fca3be..b1cb87f 100644 --- a/tests/test_twiml.py +++ b/tests/test_twiml.py @@ -1,5 +1,6 @@ from main import twilio_reply + def test_twiml_escapes_message_content(): response = twilio_reply('<tag attr="x">&') assert response.status_code == 200 diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 1b39266..c9a77d9 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -1,18 +1,30 @@ from fastapi.testclient import TestClient + import main + client = TestClient(main.app) + def post(body: str): - return client.post("/webhook", data={"From": "+15555550123", "Body": body}) + 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 + 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) @@ -21,8 +33,12 @@ def test_missing_llm_credentials_returns_useful_twiml(monkeypatch): assert response.status_code == 200 assert "AI command parsing is not configured" in response.text + def test_unexpected_exception_returns_safe_twiml(monkeypatch): - monkeypatch.setattr(main, "handle_message", lambda **_: (_ for _ in ()).throw(RuntimeError("boom"))) + 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 From 4bb561ab00471f7b7cbb84430f8cca7514f42c8a Mon Sep 17 00:00:00 2001 From: ApexAiOfficial <admin@apexaiofficial.com> Date: Wed, 5 Aug 2026 04:05:21 -0400 Subject: [PATCH 3/8] test: add repository root to pytest import path --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index a3bc858..c07fb1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["."] [tool.ruff] target-version = "py310" From a218d63164ecdeded2559047ff19c5cb40f34bf8 Mon Sep 17 00:00:00 2001 From: ApexAiOfficial <admin@apexaiofficial.com> Date: Wed, 5 Aug 2026 06:21:51 -0400 Subject: [PATCH 4/8] test: close review gaps before upstream submission --- .github/workflows/ci.yml | 2 +- README.md | 63 ++++++++++++++++++++++++++- llm_client.py | 7 +-- main.py | 8 +++- requirements-dev.txt | 1 - summarizers.py | 58 ++++++++++++------------ tests/test_import.py | 37 +++++++++++----- tests/test_natural_language_router.py | 52 ++++++++++++++++++++++ tests/test_summarizers.py | 37 +++++++++++++--- 9 files changed, 208 insertions(+), 57 deletions(-) create mode 100644 tests/test_natural_language_router.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 853bea3..0a45058 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: push: - branches: [main, "revival/**"] + branches: [main] pull_request: permissions: contents: read diff --git a/README.md b/README.md index 516bb07..1a1a4ad 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,68 @@ The current implementation does not yet provide Twilio signature verification, s 3. Introduce a least-privilege GitHub App. 4. Demonstrate one allowlisted `workflow_dispatch` command with run tracking. -## Why it matters +## What It Is -SMS can provide a low-bandwidth interface for open-source participation, repository status, education, field reporting, and carefully bounded automation. +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. + +## Why It Matters + +93% of the world has access to SMS. + +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. + +This system enables: + +- 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 + +- **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 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 + +- **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 + +- **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 + +- **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 + +- **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 diff --git a/llm_client.py b/llm_client.py index d610b1e..31a8b11 100644 --- a/llm_client.py +++ b/llm_client.py @@ -3,7 +3,6 @@ from __future__ import annotations import os -from functools import lru_cache from typing import Any @@ -11,7 +10,6 @@ class LLMNotConfiguredError(RuntimeError): """Raised when no supported LLM credential is configured.""" -@lru_cache(maxsize=1) def get_llm_client() -> tuple[str, Any]: github_models_key = os.getenv("GITHUB_OPENAI_API_KEY") if github_models_key: @@ -46,7 +44,10 @@ def complete_text(*, system_prompt: str, user_prompt: str, temperature: float, m else: response = client.chat.completions.create( model=os.getenv("OPENAI_MODEL", "gpt-4o"), - messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], temperature=temperature, max_tokens=max_tokens, ) diff --git a/main.py b/main.py index e85e8e1..88d0eee 100644 --- a/main.py +++ b/main.py @@ -47,8 +47,12 @@ def handle_message(*, sender: str, body: str) -> str: # noqa: ARG001 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" and parts[3].isdigit(): - return summarize_specific_issue(repo, int(parts[3])) + 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) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3fa1b22..e7e93a0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,3 @@ -r requirements.txt pytest==9.0.0 -pytest-mock==3.15.1 ruff==0.14.4 diff --git a/summarizers.py b/summarizers.py index 162acde..8608a08 100644 --- a/summarizers.py +++ b/summarizers.py @@ -69,17 +69,24 @@ def _summarize(prompt: str) -> str: ) +def _bounded_prompt(prefix: str, content: str) -> str | None: + try: + 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, - ) + 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) @@ -97,10 +104,7 @@ def summarize_any_repo(repo_full_name: str) -> str: encoded = readme.get("content") if isinstance(readme, dict) else None if encoded: try: - readme_text = base64.b64decode(encoded).decode( - "utf-8", - errors="ignore", - ) + readme_text = base64.b64decode(encoded).decode("utf-8", errors="ignore") except (ValueError, TypeError): readme_text = "README content could not be decoded." except requests.Timeout: @@ -120,14 +124,10 @@ def summarize_any_repo(repo_full_name: str) -> str: f"Primary Language: {repo.get('language') or 'Unknown'}\n\n" "README:\n" ) - try: - available = MAX_TOKENS - num_tokens(prefix) - if num_tokens(readme_text) > available: - readme_text = truncate_text(readme_text, available) - except Exception: - log.exception("Tokenizer failed") + prompt = _bounded_prompt(prefix, readme_text) + if prompt is None: return "AI summarization is temporarily unavailable. Please try again later." - return _summarize(prefix + readme_text) + return _summarize(prompt) def summarize_latest_issue(repo_full_name: str) -> str: @@ -152,16 +152,16 @@ def summarize_latest_issue(repo_full_name: str) -> str: 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 - ), + (item for item in payload if isinstance(item, dict) and "pull_request" not in item), None, ) if not issue: return "No open issues found." - return summarize_specific_issue(repo_full_name, int(issue["number"])) + 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: @@ -172,11 +172,7 @@ def summarize_specific_issue(repo_full_name: str, issue_number: int) -> str: base = f"{GITHUB_API}/repos/{repo_full_name}/issues/{issue_number}" try: - issue_response = requests.get( - base, - headers=get_headers(), - timeout=REQUEST_TIMEOUT, - ) + issue_response = requests.get(base, headers=get_headers(), timeout=REQUEST_TIMEOUT) if not issue_response.ok: return _error(issue_response, "issue") comments_response = requests.get( @@ -203,6 +199,8 @@ def summarize_specific_issue(repo_full_name: str, issue_number: int) -> str: for comment in comments if isinstance(comment, dict) ) - return _summarize( - "Summarize this GitHub issue thread:\n" + "\n".join(thread) - ) + 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_import.py b/tests/test_import.py index 865b565..c242056 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -1,14 +1,29 @@ -import importlib +import os +import subprocess +import sys +from pathlib import Path -def test_modules_import_without_configuration(monkeypatch): +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"): - monkeypatch.delenv(name, raising=False) - for module_name in ( - "commands", - "llm_client", - "summarizers", - "natural_language_router", - "main", - ): - importlib.import_module(module_name) + 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 index fcb6cca..64099b8 100644 --- a/tests/test_summarizers.py +++ b/tests/test_summarizers.py @@ -24,9 +24,7 @@ def fake_get(url, **kwargs): ] ) if url.endswith("/issues/8"): - return FakeResponse( - {"number": 8, "title": "Real issue", "body": "body"} - ) + return FakeResponse({"number": 8, "title": "Real issue", "body": "body"}) if url.endswith("/issues/8/comments"): return FakeResponse([]) raise AssertionError(url) @@ -49,7 +47,32 @@ def raise_not_configured(**kwargs): 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 - ) + 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}) + + class FakeEncoder: + def encode(self, text): + return list(text) + + def decode(self, tokens): + return "".join(tokens) + + 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 From fc036ca1d51f4dd6b0d829c05cd521d416b03b04 Mon Sep 17 00:00:00 2001 From: ApexAiOfficial <admin@apexaiofficial.com> Date: Wed, 5 Aug 2026 08:26:38 -0400 Subject: [PATCH 5/8] test: isolate tokenizer in issue summary tests --- tests/test_summarizers.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/test_summarizers.py b/tests/test_summarizers.py index 64099b8..76b51ce 100644 --- a/tests/test_summarizers.py +++ b/tests/test_summarizers.py @@ -11,6 +11,14 @@ 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 = [] @@ -30,6 +38,7 @@ def fake_get(url, **kwargs): 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) @@ -58,13 +67,6 @@ def fake_get(url, **kwargs): return FakeResponse([{"body": "comment " * 100}]) return FakeResponse({"title": "Large issue", "body": "body " * 100}) - class FakeEncoder: - def encode(self, text): - return list(text) - - def decode(self, tokens): - return "".join(tokens) - def fake_complete_text(**kwargs): captured["prompt"] = kwargs["user_prompt"] return "summary" From c26dcacdbf88756c21d73e529d87657f3ebc5506 Mon Sep 17 00:00:00 2001 From: ApexAiOfficial <admin@apexaiofficial.com> Date: Wed, 5 Aug 2026 14:49:12 -0400 Subject: [PATCH 6/8] fix: reject pull requests in specific issue summaries --- summarizers.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/summarizers.py b/summarizers.py index 8608a08..8db0bef 100644 --- a/summarizers.py +++ b/summarizers.py @@ -175,6 +175,12 @@ def summarize_specific_issue(repo_full_name: str, issue_number: int) -> str: 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(), @@ -182,8 +188,8 @@ def summarize_specific_issue(repo_full_name: str, issue_number: int) -> str: ) if not comments_response.ok: return _error(comments_response, "issue comments") - issue, comments = _json(issue_response), _json(comments_response) - if not isinstance(issue, dict) or not isinstance(comments, list): + 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." From 3c19e7f29f82cbe7bda68cfdbb392201a8bb8481 Mon Sep 17 00:00:00 2001 From: ApexAiOfficial <admin@apexaiofficial.com> Date: Wed, 5 Aug 2026 14:49:22 -0400 Subject: [PATCH 7/8] test: verify configured authorization header --- tests/test_commands.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_commands.py b/tests/test_commands.py index 022a1cf..c0e6cc6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -6,6 +6,11 @@ def test_headers_do_not_emit_bearer_none(monkeypatch): 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 From 7f912b9f119be6cae8a76a19f68d8d5254191f2d Mon Sep 17 00:00:00 2001 From: ApexAiOfficial <admin@apexaiofficial.com> Date: Wed, 5 Aug 2026 14:49:41 -0400 Subject: [PATCH 8/8] test: cover specific pull request rejection --- tests/test_summarizers.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_summarizers.py b/tests/test_summarizers.py index 76b51ce..899e149 100644 --- a/tests/test_summarizers.py +++ b/tests/test_summarizers.py @@ -44,6 +44,28 @@ def fake_get(url, **kwargs): 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"):