diff --git a/.gitignore b/.gitignore index c33c0598cf..021b7a066a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ instruments/** # for browser-use agent_history.gif +.agent/** +.claude/** diff --git a/agent.py b/agent.py index df2c8925be..b8d8c367b1 100644 --- a/agent.py +++ b/agent.py @@ -304,6 +304,7 @@ class UserMessage: message: str attachments: list[str] = field(default_factory=list[str]) system_message: list[str] = field(default_factory=list[str]) + repo_mentions: list[dict] = field(default_factory=list) class LoopData: @@ -675,6 +676,12 @@ def hist_add_user_message(self, message: UserMessage, intervention: bool = False if isinstance(content, dict): content = {k: v for k, v in content.items() if v} + # Store repo_mentions for extensions to access + if hasattr(message, 'repo_mentions') and message.repo_mentions: + self.data['repo_mentions'] = message.repo_mentions + else: + self.data['repo_mentions'] = [] + # add to history msg = self.hist_add_message(False, content=content) # type: ignore self.last_user_message = msg diff --git a/prompts/agent.extras.github_status.md b/prompts/agent.extras.github_status.md new file mode 100644 index 0000000000..4a8b05b325 --- /dev/null +++ b/prompts/agent.extras.github_status.md @@ -0,0 +1,9 @@ +# GitHub Integration +The user is connected to GitHub as **{{username}}**{{if name}} ({{name}}){{/if}}. + +You have access to their GitHub account and can: +- Browse their repositories +- Access file contents from their repos +- Help with GitHub-related tasks (repos, issues, PRs, etc.) + +When the user references a GitHub repository or asks about their code on GitHub, you can use the GitHub integration to help them. diff --git a/prompts/agent.extras.repo_mention.md b/prompts/agent.extras.repo_mention.md new file mode 100644 index 0000000000..9134c9b7cc --- /dev/null +++ b/prompts/agent.extras.repo_mention.md @@ -0,0 +1,10 @@ +# GitHub Repository: {{full_name}} +{{if description}}{{description}}{{/if}} + +## Repository Info +- **Language:** {{language}} +- **Stars:** {{stars}} +- **Default Branch:** {{default_branch}} + +## File Structure +{{file_tree}} diff --git a/prompts/agent.system.tool.github.md b/prompts/agent.system.tool.github.md new file mode 100644 index 0000000000..84026b851b --- /dev/null +++ b/prompts/agent.system.tool.github.md @@ -0,0 +1,52 @@ +{{if github_connected}} +### github: +Interact with user's GitHub repositories. User is connected as **{{github_username}}**. +Available actions: +- `list_repos`: List user's repositories (optional: page, per_page) +- `get_repo`: Get repo details (required: owner, repo) +- `get_contents`: List directory contents (required: owner, repo; optional: path) +- `get_file`: Read file contents (required: owner, repo, path) +- `search_repos`: Search GitHub repos (required: query) + +**Example - List repos:** +~~~json +{ + "thoughts": ["User wants to see their repositories"], + "headline": "Listing GitHub repositories", + "tool_name": "github", + "tool_args": { + "action": "list_repos" + } +} +~~~ + +**Example - Get file contents:** +~~~json +{ + "thoughts": ["Need to read the README from user's repo"], + "headline": "Reading README.md", + "tool_name": "github", + "tool_args": { + "action": "get_file", + "owner": "username", + "repo": "repo-name", + "path": "README.md" + } +} +~~~ + +**Example - Browse directory:** +~~~json +{ + "thoughts": ["User wants to see what's in the src folder"], + "headline": "Browsing src directory", + "tool_name": "github", + "tool_args": { + "action": "get_contents", + "owner": "username", + "repo": "repo-name", + "path": "src" + } +} +~~~ +{{/if}} diff --git a/prompts/agent.system.tool.github.py b/prompts/agent.system.tool.github.py new file mode 100644 index 0000000000..932037ecb7 --- /dev/null +++ b/prompts/agent.system.tool.github.py @@ -0,0 +1,28 @@ +from typing import Any +from python.helpers.files import VariablesPlugin + + +class GithubToolVariables(VariablesPlugin): + """Provide variables for the GitHub tool prompt.""" + + def get_variables( + self, file: str, backup_dirs: list[str] | None = None, **kwargs + ) -> dict[str, Any]: + # Import here to avoid circular imports + from python.api.github_callback import get_github_auth + + auth_data = get_github_auth() + + if not auth_data: + return {"github_connected": False, "github_username": ""} + + access_token = auth_data.get("access_token") + user = auth_data.get("user") + + if not access_token or not user: + return {"github_connected": False, "github_username": ""} + + return { + "github_connected": True, + "github_username": user.get("login", "unknown"), + } diff --git a/python/api/__init__.py b/python/api/__init__.py new file mode 100644 index 0000000000..4e2a1805d9 --- /dev/null +++ b/python/api/__init__.py @@ -0,0 +1,2 @@ +# GitHub API handlers are auto-discovered from this folder +# See run_ui.py:register_api_handler() and load_classes_from_folder() diff --git a/python/api/github_callback.py b/python/api/github_callback.py new file mode 100644 index 0000000000..177f451910 --- /dev/null +++ b/python/api/github_callback.py @@ -0,0 +1,123 @@ +from python.helpers.api import ApiHandler +from python.helpers.print_style import PrintStyle +from python.helpers import files +from flask import Request, Response, session, redirect +import os +import json +import httpx +import urllib.parse + +GITHUB_AUTH_FILE = files.get_abs_path("tmp/github_auth.json") + + +def save_github_auth(data: dict): + """Save GitHub auth data to file""" + content = json.dumps(data, indent=2) + files.write_file(GITHUB_AUTH_FILE, content) + + +def get_github_auth() -> dict | None: + """Load GitHub auth data from file""" + if os.path.exists(GITHUB_AUTH_FILE): + content = files.read_file(GITHUB_AUTH_FILE) + return json.loads(content) + return None + + +def clear_github_auth(): + """Remove GitHub auth file""" + if os.path.exists(GITHUB_AUTH_FILE): + os.remove(GITHUB_AUTH_FILE) + + +class GithubCallback(ApiHandler): + """Handle GitHub OAuth callback""" + + @staticmethod + def requires_csrf() -> bool: + return False # OAuth callback comes from GitHub, not our frontend + + @staticmethod + def get_methods() -> list[str]: + return ["GET"] + + async def process(self, input: dict, request: Request) -> Response: + code = request.args.get("code") + state = request.args.get("state") + error = request.args.get("error") + + # Handle user denial + if error: + return redirect(f"/?github_error={error}") + + if not code: + return redirect("/?github_error=no_code") + + # Validate state for CSRF protection + stored_state = session.pop("github_oauth_state", None) + if not state or state != stored_state: + return redirect("/?github_error=invalid_state") + + client_id = os.getenv("GITHUB_CLIENT_ID") + client_secret = os.getenv("GITHUB_CLIENT_SECRET") + + if not client_id or not client_secret: + return redirect("/?github_error=not_configured") + + try: + async with httpx.AsyncClient() as client: + # Exchange code for access token + token_response = await client.post( + "https://github.com/login/oauth/access_token", + data={ + "client_id": client_id, + "client_secret": client_secret, + "code": code, + }, + headers={"Accept": "application/json"}, + ) + + if token_response.status_code != 200: + return redirect("/?github_error=token_exchange_failed") + + token_data = token_response.json() + + if "error" in token_data: + error_desc = token_data.get("error_description", token_data["error"]) + return redirect(f"/?github_error={error_desc}") + + access_token = token_data.get("access_token") + if not access_token: + return redirect("/?github_error=no_access_token") + + # Fetch user info + user_response = await client.get( + "https://api.github.com/user", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + }, + ) + + if user_response.status_code != 200: + return redirect("/?github_error=user_fetch_failed") + + user_data = user_response.json() + + # Store in separate auth file (not main settings) + save_github_auth({ + "access_token": access_token, + "user": { + "login": user_data.get("login"), + "name": user_data.get("name"), + "avatar_url": user_data.get("avatar_url"), + "html_url": user_data.get("html_url"), + }, + }) + + return redirect("/?github_connected=true") + + except Exception as e: + PrintStyle.error(f"GitHub OAuth callback error: {e}") + error_msg = urllib.parse.quote(str(e)[:100]) + return redirect(f"/?github_error={error_msg}") diff --git a/python/api/github_contents.py b/python/api/github_contents.py new file mode 100644 index 0000000000..006bc8d1b7 --- /dev/null +++ b/python/api/github_contents.py @@ -0,0 +1,78 @@ +from python.helpers.api import ApiHandler +from python.api.github_callback import get_github_auth +from flask import Request +import httpx + + +class GithubContents(ApiHandler): + """Get repository contents (file browser)""" + + async def process(self, input: dict, request: Request) -> dict: + auth_data = get_github_auth() + access_token = auth_data.get("access_token") if auth_data else None + + if not access_token: + return {"error": "Not connected to GitHub", "connected": False} + + # Get params from input or query string + owner = input.get("owner") or request.args.get("owner") + repo = input.get("repo") or request.args.get("repo") + path = input.get("path") or request.args.get("path", "") + ref = input.get("ref") or request.args.get("ref", "") + + if not owner or not repo: + return {"error": "owner and repo parameters required"} + + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + } + + params = {} + if ref: + params["ref"] = ref + + async with httpx.AsyncClient() as client: + url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}" + response = await client.get(url, params=params, headers=headers) + + if response.status_code != 200: + return {"error": "Failed to fetch contents", "status": response.status_code} + + data = response.json() + + # Handle single file vs directory + if isinstance(data, dict): + # Single file + return { + "type": "file", + "content": { + "name": data["name"], + "path": data["path"], + "sha": data["sha"], + "size": data["size"], + "type": data["type"], + "content": data.get("content"), # Base64 encoded + "encoding": data.get("encoding"), + "html_url": data["html_url"], + "download_url": data.get("download_url"), + }, + } + else: + # Directory listing + return { + "type": "directory", + "path": path, + "contents": [ + { + "name": item["name"], + "path": item["path"], + "sha": item["sha"], + "size": item.get("size", 0), + "type": item["type"], + "html_url": item["html_url"], + "download_url": item.get("download_url"), + } + for item in sorted(data, key=lambda x: (x["type"] != "dir", x["name"].lower())) + ], + } diff --git a/python/api/github_disconnect.py b/python/api/github_disconnect.py new file mode 100644 index 0000000000..4ca7d43bd8 --- /dev/null +++ b/python/api/github_disconnect.py @@ -0,0 +1,11 @@ +from python.helpers.api import ApiHandler +from python.api.github_callback import clear_github_auth +from flask import Request + + +class GithubDisconnect(ApiHandler): + """Disconnect GitHub integration""" + + async def process(self, input: dict, request: Request) -> dict: + clear_github_auth() + return {"success": True, "message": "GitHub disconnected"} diff --git a/python/api/github_file_content.py b/python/api/github_file_content.py new file mode 100644 index 0000000000..c087eec936 --- /dev/null +++ b/python/api/github_file_content.py @@ -0,0 +1,56 @@ +from python.helpers.api import ApiHandler +from python.api.github_callback import get_github_auth +from flask import Request +import httpx +import base64 + + +class GithubFileContent(ApiHandler): + """Get file content from a repository""" + + async def process(self, input: dict, request: Request) -> dict: + auth_data = get_github_auth() + access_token = auth_data.get("access_token") if auth_data else None + + if not access_token: + return {"error": "Not connected to GitHub", "connected": False} + + owner = input.get("owner", "") + repo = input.get("repo", "") + path = input.get("path", "") + ref = input.get("ref", "") + + if not owner or not repo or not path: + return {"error": "Owner, repo, and path are required"} + + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + } + + params = {} + if ref: + params["ref"] = ref + + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://api.github.com/repos/{owner}/{repo}/contents/{path}", + headers=headers, + params=params, + ) + + if response.status_code != 200: + return {"error": "Failed to fetch file", "status": response.status_code} + + file_data = response.json() + + if isinstance(file_data, dict) and file_data.get("type") == "file": + content = file_data.get("content", "") + if content: + try: + decoded = base64.b64decode(content).decode("utf-8") + file_data["decoded_content"] = decoded + except Exception: + file_data["decoded_content"] = None + + return file_data diff --git a/python/api/github_oauth.py b/python/api/github_oauth.py new file mode 100644 index 0000000000..6912c5cb16 --- /dev/null +++ b/python/api/github_oauth.py @@ -0,0 +1,29 @@ +from python.helpers.api import ApiHandler +from flask import Request, session +import os +import secrets + + +class GithubOauth(ApiHandler): + """Initiate GitHub OAuth flow""" + + async def process(self, input: dict, request: Request) -> dict: + client_id = os.getenv("GITHUB_CLIENT_ID", "") + redirect_uri = os.getenv("GITHUB_REDIRECT_URI", "http://localhost:55080/github_callback") + + if not client_id: + return {"success": False, "error": "GitHub client ID not configured. Set GITHUB_CLIENT_ID in .env"} + + # Generate and store state for CSRF protection + state = secrets.token_urlsafe(32) + session["github_oauth_state"] = state + + auth_url = ( + f"https://github.com/login/oauth/authorize" + f"?client_id={client_id}" + f"&redirect_uri={redirect_uri}" + f"&scope=repo,user" + f"&state={state}" + ) + + return {"success": True, "auth_url": auth_url} diff --git a/python/api/github_repo_detail.py b/python/api/github_repo_detail.py new file mode 100644 index 0000000000..dea85a30af --- /dev/null +++ b/python/api/github_repo_detail.py @@ -0,0 +1,82 @@ +from python.helpers.api import ApiHandler +from python.api.github_callback import get_github_auth +from flask import Request +import httpx + + +class GithubRepoDetail(ApiHandler): + """Get detailed repository info""" + + async def process(self, input: dict, request: Request) -> dict: + auth_data = get_github_auth() + access_token = auth_data.get("access_token") if auth_data else None + + if not access_token: + return {"error": "Not connected to GitHub", "connected": False} + + # Get params from input or query string + owner = input.get("owner") or request.args.get("owner") + repo = input.get("repo") or request.args.get("repo") + + if not owner or not repo: + return {"error": "owner and repo parameters required"} + + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + } + + async with httpx.AsyncClient() as client: + # Get repo info + repo_response = await client.get( + f"https://api.github.com/repos/{owner}/{repo}", + headers=headers, + ) + + if repo_response.status_code != 200: + return {"error": "Repository not found", "status": 404} + + repo_data = repo_response.json() + + # Get branches + branches_response = await client.get( + f"https://api.github.com/repos/{owner}/{repo}/branches", + params={"per_page": 100}, + headers=headers, + ) + branches = branches_response.json() if branches_response.status_code == 200 else [] + + # Get languages + languages_response = await client.get( + f"https://api.github.com/repos/{owner}/{repo}/languages", + headers=headers, + ) + languages = languages_response.json() if languages_response.status_code == 200 else {} + + return { + "repository": { + "id": repo_data["id"], + "name": repo_data["name"], + "full_name": repo_data["full_name"], + "description": repo_data.get("description"), + "private": repo_data["private"], + "html_url": repo_data["html_url"], + "clone_url": repo_data["clone_url"], + "ssh_url": repo_data["ssh_url"], + "language": repo_data.get("language"), + "stargazers_count": repo_data["stargazers_count"], + "watchers_count": repo_data["watchers_count"], + "forks_count": repo_data["forks_count"], + "open_issues_count": repo_data["open_issues_count"], + "default_branch": repo_data["default_branch"], + "created_at": repo_data["created_at"], + "updated_at": repo_data["updated_at"], + "pushed_at": repo_data["pushed_at"], + "owner": { + "login": repo_data["owner"]["login"], + "avatar_url": repo_data["owner"]["avatar_url"], + }, + }, + "branches": [b["name"] for b in branches] if isinstance(branches, list) else [], + "languages": languages, + } diff --git a/python/api/github_repos.py b/python/api/github_repos.py new file mode 100644 index 0000000000..cf1233ad10 --- /dev/null +++ b/python/api/github_repos.py @@ -0,0 +1,75 @@ +from python.helpers.api import ApiHandler +from python.api.github_callback import get_github_auth +from flask import Request +import httpx + + +class GithubRepos(ApiHandler): + """List GitHub repositories""" + + async def process(self, input: dict, request: Request) -> dict: + auth_data = get_github_auth() + access_token = auth_data.get("access_token") if auth_data else None + + if not access_token: + return {"error": "Not connected to GitHub", "connected": False} + + # Get params from input (POST body) or query string + page = input.get("page") or request.args.get("page", 1, type=int) + per_page = input.get("per_page") or request.args.get("per_page", 30, type=int) + sort = input.get("sort") or request.args.get("sort", "updated") + direction = input.get("direction") or request.args.get("direction", "desc") + type_filter = input.get("type") or request.args.get("type", "all") + + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.github.com/user/repos", + params={ + "page": page, + "per_page": per_page, + "sort": sort, + "direction": direction, + "type": type_filter, + }, + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + }, + ) + + if response.status_code != 200: + return {"error": "Failed to fetch repositories", "status": response.status_code} + + repos = response.json() + + # Parse Link header for pagination + link_header = response.headers.get("Link", "") + has_next = 'rel="next"' in link_header + has_prev = 'rel="prev"' in link_header + + return { + "repositories": [ + { + "id": repo["id"], + "name": repo["name"], + "full_name": repo["full_name"], + "description": repo.get("description"), + "private": repo["private"], + "html_url": repo["html_url"], + "language": repo.get("language"), + "stargazers_count": repo["stargazers_count"], + "forks_count": repo["forks_count"], + "updated_at": repo["updated_at"], + "default_branch": repo["default_branch"], + "owner": { + "login": repo["owner"]["login"], + "avatar_url": repo["owner"]["avatar_url"], + }, + } + for repo in repos + ], + "page": page, + "per_page": per_page, + "has_next": has_next, + "has_prev": has_prev, + } diff --git a/python/api/github_search.py b/python/api/github_search.py new file mode 100644 index 0000000000..58b9973052 --- /dev/null +++ b/python/api/github_search.py @@ -0,0 +1,48 @@ +from python.helpers.api import ApiHandler +from python.api.github_callback import get_github_auth +from flask import Request +import httpx + + +class GithubSearch(ApiHandler): + """Search GitHub repositories""" + + async def process(self, input: dict, request: Request) -> dict: + auth_data = get_github_auth() + access_token = auth_data.get("access_token") if auth_data else None + + query = input.get("query", "") + page = input.get("page", 1) + per_page = input.get("per_page", 30) + + if not query: + return {"error": "Search query is required"} + + headers = {"Accept": "application/vnd.github+json"} + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.github.com/search/repositories", + params={ + "q": query, + "page": page, + "per_page": per_page, + "sort": "stars", + "order": "desc", + }, + headers=headers, + ) + + if response.status_code != 200: + return {"error": "Search failed", "status": response.status_code} + + search_data = response.json() + + return { + "total_count": search_data.get("total_count", 0), + "items": search_data.get("items", []), + "page": page, + "per_page": per_page, + } diff --git a/python/api/github_user.py b/python/api/github_user.py new file mode 100644 index 0000000000..8454a833fc --- /dev/null +++ b/python/api/github_user.py @@ -0,0 +1,21 @@ +from python.helpers.api import ApiHandler +from python.api.github_callback import get_github_auth +from flask import Request + + +class GithubUser(ApiHandler): + """Get current GitHub user info and connection status""" + + async def process(self, input: dict, request: Request) -> dict: + auth_data = get_github_auth() + + if not auth_data: + return {"connected": False, "user": None} + + access_token = auth_data.get("access_token") + user = auth_data.get("user") + + return { + "connected": bool(access_token and user), + "user": user, + } diff --git a/python/api/message.py b/python/api/message.py index cd63e85fd0..3afeacf4bc 100644 --- a/python/api/message.py +++ b/python/api/message.py @@ -2,6 +2,7 @@ from python.helpers.api import ApiHandler, Request, Response from python.helpers import files, extension +import json import os from werkzeug.utils import secure_filename from python.helpers.defer import DeferredTask @@ -29,6 +30,13 @@ async def communicate(self, input: dict, request: Request): attachments = request.files.getlist("attachments") attachment_paths = [] + # Extract repo_mentions from form data + repo_mentions_raw = request.form.get("repo_mentions", "[]") + try: + repo_mentions = json.loads(repo_mentions_raw) + except: + repo_mentions = [] + upload_folder_int = "/a0/tmp/uploads" upload_folder_ext = files.get_abs_path("tmp/uploads") # for development environment @@ -48,6 +56,7 @@ async def communicate(self, input: dict, request: Request): ctxid = input_data.get("context", "") message_id = input_data.get("message_id", None) attachment_paths = [] + repo_mentions = input_data.get("repo_mentions", []) # Now process the message message = text @@ -81,13 +90,16 @@ async def communicate(self, input: dict, request: Request): for filename in attachment_filenames: PrintStyle(font_color="white", padding=False).print(f"- {filename}") - # Log the message with message_id and attachments + # Log the message with message_id, attachments, and repo mentions context.log.log( type="user", heading="", content=message, - kvps={"attachments": attachment_filenames}, + kvps={ + "attachments": attachment_filenames, + "repo_mentions": repo_mentions if repo_mentions else [], + }, id=message_id, ) - return context.communicate(UserMessage(message, attachment_paths)), context + return context.communicate(UserMessage(message, attachment_paths, repo_mentions=repo_mentions)), context diff --git a/python/extensions/message_loop_prompts_after/_80_include_github_status.py b/python/extensions/message_loop_prompts_after/_80_include_github_status.py new file mode 100644 index 0000000000..e341b151e7 --- /dev/null +++ b/python/extensions/message_loop_prompts_after/_80_include_github_status.py @@ -0,0 +1,31 @@ +from python.helpers.extension import Extension +from agent import LoopData + + +class IncludeGithubStatus(Extension): + """Inject GitHub connection status into agent context.""" + + async def execute(self, loop_data: LoopData = LoopData(), **kwargs): + # Import here to avoid circular imports + from python.api.github_callback import get_github_auth + + auth_data = get_github_auth() + if not auth_data: + return # Not connected, don't add to context + + access_token = auth_data.get("access_token") + user = auth_data.get("user") + + if not access_token or not user: + return # Incomplete auth data + + # Build the GitHub status prompt + github_prompt = self.agent.read_prompt( + "agent.extras.github_status.md", + username=user.get("login", "unknown"), + name=user.get("name") or user.get("login", ""), + avatar_url=user.get("avatar_url", ""), + ) + + # Add to agent's context + loop_data.extras_temporary["github_status"] = github_prompt diff --git a/python/extensions/message_loop_prompts_after/_81_include_repo_mentions.py b/python/extensions/message_loop_prompts_after/_81_include_repo_mentions.py new file mode 100644 index 0000000000..201a97e9c0 --- /dev/null +++ b/python/extensions/message_loop_prompts_after/_81_include_repo_mentions.py @@ -0,0 +1,126 @@ +from python.helpers.extension import Extension +from agent import LoopData +import httpx + + +class IncludeRepoMentions(Extension): + """Inject @mentioned GitHub repository context into agent prompts.""" + + async def execute(self, loop_data: LoopData = LoopData(), **kwargs): + # Import here to avoid circular imports + from python.api.github_callback import get_github_auth + + # Get mentions from agent.data (set by hist_add_user_message) + mentions = self.agent.data.get("repo_mentions", []) + if not mentions: + return + + auth_data = get_github_auth() + if not auth_data: + return # Not connected to GitHub + + access_token = auth_data.get("access_token") + if not access_token: + return + + # Process each mention and inject context + for mention in mentions: + owner = mention.get("owner") + repo = mention.get("repo") + if not owner or not repo: + continue + + context = await self.build_repo_context(access_token, owner, repo) + if context: + key = f"repo_mention_{owner}_{repo}" + loop_data.extras_temporary[key] = context + + async def build_repo_context( + self, token: str, owner: str, repo: str + ) -> str | None: + """Fetch repo metadata and file tree from GitHub API.""" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + + try: + async with httpx.AsyncClient() as client: + # Fetch repo metadata + repo_response = await client.get( + f"https://api.github.com/repos/{owner}/{repo}", + headers=headers, + ) + if repo_response.status_code != 200: + return None + + repo_data = repo_response.json() + default_branch = repo_data.get("default_branch", "main") + + # Fetch file tree + tree_response = await client.get( + f"https://api.github.com/repos/{owner}/{repo}/git/trees/{default_branch}?recursive=1", + headers=headers, + ) + + file_tree = "" + if tree_response.status_code == 200: + tree_data = tree_response.json() + items = tree_data.get("tree", []) + file_tree = self.format_tree(items) + + # Build context using prompt template + context = self.agent.read_prompt( + "agent.extras.repo_mention.md", + owner=owner, + repo=repo, + full_name=repo_data.get("full_name", f"{owner}/{repo}"), + description=repo_data.get("description") or "", + language=repo_data.get("language") or "Not specified", + stars=repo_data.get("stargazers_count", 0), + default_branch=default_branch, + file_tree=file_tree, + ) + + return context + + except Exception: + return None + + def format_tree(self, items: list, max_items: int = 100) -> str: + """Format tree items into readable directory structure.""" + if not items: + return "No files found" + + # Filter to max depth of 3 levels + filtered = [] + for item in items: + path = item.get("path", "") + depth = path.count("/") + if depth <= 2: # 0, 1, 2 = 3 levels + filtered.append(item) + + # Sort: directories first, then files, alphabetically + def sort_key(item): + is_tree = item.get("type") == "tree" + path = item.get("path", "") + return (not is_tree, path.lower()) + + filtered.sort(key=sort_key) + + # Format output with truncation + lines = [] + truncated = len(filtered) > max_items + display_items = filtered[:max_items] + + for item in display_items: + path = item.get("path", "") + item_type = item.get("type", "blob") + prefix = "/" if item_type == "tree" else "" + lines.append(f"{prefix}{path}") + + if truncated: + remaining = len(filtered) - max_items + lines.append(f"... and {remaining} more items") + + return "\n".join(lines) diff --git a/python/helpers/dotenv.py b/python/helpers/dotenv.py index 07ef0942be..f39dd85e66 100644 --- a/python/helpers/dotenv.py +++ b/python/helpers/dotenv.py @@ -9,6 +9,8 @@ KEY_AUTH_PASSWORD = "AUTH_PASSWORD" KEY_RFC_PASSWORD = "RFC_PASSWORD" KEY_ROOT_PASSWORD = "ROOT_PASSWORD" +KEY_GITHUB_CLIENT_ID = "GITHUB_CLIENT_ID" +KEY_GITHUB_CLIENT_SECRET = "GITHUB_CLIENT_SECRET" def load_dotenv(): _load_dotenv(get_dotenv_file_path(), override=True) diff --git a/python/helpers/settings.py b/python/helpers/settings.py index aebba6bbf1..36acea743c 100644 --- a/python/helpers/settings.py +++ b/python/helpers/settings.py @@ -144,6 +144,10 @@ class Settings(TypedDict): variables: str secrets: str + # GitHub OAuth configuration + github_client_id: str + github_client_secret: str + # LiteLLM global kwargs applied to all model calls litellm_global_kwargs: dict[str, Any] @@ -284,6 +288,12 @@ def convert_out(settings: Settings) -> SettingsOutput: PASSWORD_PLACEHOLDER if dotenv.get_dotenv_value(dotenv.KEY_ROOT_PASSWORD) else "" ) + # load GitHub OAuth config from dotenv + out["settings"]["github_client_id"] = dotenv.get_dotenv_value(dotenv.KEY_GITHUB_CLIENT_ID) or "" + out["settings"]["github_client_secret"] = ( + PASSWORD_PLACEHOLDER if dotenv.get_dotenv_value(dotenv.KEY_GITHUB_CLIENT_SECRET) else "" + ) + #secrets secrets_manager = get_default_secrets_manager() try: @@ -426,6 +436,8 @@ def _remove_sensitive_settings(settings: Settings): settings["root_password"] = "" settings["mcp_server_token"] = "" settings["secrets"] = "" + settings["github_client_id"] = "" + settings["github_client_secret"] = "" def _write_sensitive_settings(settings: Settings): @@ -443,6 +455,11 @@ def _write_sensitive_settings(settings: Settings): dotenv.save_dotenv_value(dotenv.KEY_ROOT_PASSWORD, settings["root_password"]) set_root_password(settings["root_password"]) + # GitHub OAuth settings + dotenv.save_dotenv_value(dotenv.KEY_GITHUB_CLIENT_ID, settings["github_client_id"]) + if settings["github_client_secret"] != PASSWORD_PLACEHOLDER: + dotenv.save_dotenv_value(dotenv.KEY_GITHUB_CLIENT_SECRET, settings["github_client_secret"]) + # Handle secrets separately - merge with existing preserving comments/order and support deletions secrets_manager = get_default_secrets_manager() submitted_content = settings["secrets"] @@ -528,6 +545,8 @@ def get_default_settings() -> Settings: a2a_server_enabled=get_default_value("a2a_server_enabled", False), variables="", secrets="", + github_client_id="", + github_client_secret="", litellm_global_kwargs=get_default_value("litellm_global_kwargs", {}), update_check_enabled=get_default_value("update_check_enabled", True), ) diff --git a/python/tools/github.py b/python/tools/github.py new file mode 100644 index 0000000000..c35b7a2b9b --- /dev/null +++ b/python/tools/github.py @@ -0,0 +1,239 @@ +import httpx +from python.helpers.tool import Tool, Response +from python.helpers.print_style import PrintStyle +from python.api.github_callback import get_github_auth + + +class Github(Tool): + """Tool for interacting with GitHub repositories.""" + + async def execute(self, action="", **kwargs) -> Response: + auth_data = get_github_auth() + if not auth_data: + return Response( + message="GitHub is not connected. Ask the user to connect to GitHub in Settings > GitHub.", + break_loop=False, + ) + + access_token = auth_data.get("access_token") + if not access_token: + return Response( + message="GitHub authentication is incomplete. Ask the user to reconnect.", + break_loop=False, + ) + + # Route to appropriate method based on action + if action == "list_repos": + result = await self.list_repos(access_token, **kwargs) + elif action == "get_repo": + result = await self.get_repo(access_token, **kwargs) + elif action == "get_contents": + result = await self.get_contents(access_token, **kwargs) + elif action == "get_file": + result = await self.get_file(access_token, **kwargs) + elif action == "search_repos": + result = await self.search_repos(access_token, **kwargs) + else: + result = f"Unknown action: {action}. Valid actions: list_repos, get_repo, get_contents, get_file, search_repos" + + await self.agent.handle_intervention(result) + return Response(message=result, break_loop=False) + + async def list_repos(self, token: str, page: int = 1, per_page: int = 30, **kwargs) -> str: + """List user's repositories.""" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.github.com/user/repos", + params={"page": page, "per_page": per_page, "sort": "updated"}, + headers=headers, + ) + + if response.status_code != 200: + return f"Failed to fetch repositories: HTTP {response.status_code}" + + repos = response.json() + if not repos: + return "No repositories found." + + lines = ["**Your GitHub Repositories:**\n"] + for repo in repos: + visibility = "Private" if repo["private"] else "Public" + desc = repo.get("description") or "No description" + lines.append( + f"- **{repo['full_name']}** ({visibility})\n" + f" {desc}\n" + f" Stars: {repo['stargazers_count']} | " + f"Language: {repo.get('language') or 'N/A'}" + ) + + return "\n".join(lines) + + async def get_repo(self, token: str, owner: str = "", repo: str = "", **kwargs) -> str: + """Get detailed info about a repository.""" + if not owner or not repo: + return "Error: 'owner' and 'repo' arguments are required." + + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://api.github.com/repos/{owner}/{repo}", + headers=headers, + ) + + if response.status_code == 404: + return f"Repository {owner}/{repo} not found." + if response.status_code != 200: + return f"Failed to fetch repository: HTTP {response.status_code}" + + data = response.json() + visibility = "Private" if data["private"] else "Public" + + return ( + f"**{data['full_name']}** ({visibility})\n\n" + f"**Description:** {data.get('description') or 'No description'}\n\n" + f"**Stats:**\n" + f"- Stars: {data['stargazers_count']}\n" + f"- Forks: {data['forks_count']}\n" + f"- Watchers: {data['watchers_count']}\n" + f"- Open Issues: {data['open_issues_count']}\n\n" + f"**Details:**\n" + f"- Language: {data.get('language') or 'N/A'}\n" + f"- Default Branch: {data['default_branch']}\n" + f"- Created: {data['created_at']}\n" + f"- Updated: {data['updated_at']}\n" + f"- URL: {data['html_url']}" + ) + + async def get_contents( + self, token: str, owner: str = "", repo: str = "", path: str = "", **kwargs + ) -> str: + """List contents of a directory in a repository.""" + if not owner or not repo: + return "Error: 'owner' and 'repo' arguments are required." + + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + + async with httpx.AsyncClient() as client: + url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}" + response = await client.get(url, headers=headers) + + if response.status_code == 404: + return f"Path '{path}' not found in {owner}/{repo}." + if response.status_code != 200: + return f"Failed to fetch contents: HTTP {response.status_code}" + + data = response.json() + + # Single file + if isinstance(data, dict): + return f"'{path}' is a file. Use action='get_file' to read its contents." + + # Directory listing + lines = [f"**Contents of {owner}/{repo}/{path or '(root)'}:**\n"] + + # Sort: directories first, then files + items = sorted(data, key=lambda x: (x["type"] != "dir", x["name"].lower())) + + for item in items: + icon = "[DIR]" if item["type"] == "dir" else "[FILE]" + size = f" ({item.get('size', 0)} bytes)" if item["type"] == "file" else "" + lines.append(f"- {icon} {item['name']}{size}") + + return "\n".join(lines) + + async def get_file( + self, token: str, owner: str = "", repo: str = "", path: str = "", **kwargs + ) -> str: + """Get the contents of a file from a repository.""" + if not owner or not repo or not path: + return "Error: 'owner', 'repo', and 'path' arguments are required." + + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + + async with httpx.AsyncClient() as client: + url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}" + response = await client.get(url, headers=headers) + + if response.status_code == 404: + return f"File '{path}' not found in {owner}/{repo}." + if response.status_code != 200: + return f"Failed to fetch file: HTTP {response.status_code}" + + data = response.json() + + if isinstance(data, list): + return f"'{path}' is a directory. Use action='get_contents' to list its contents." + + if data.get("type") != "file": + return f"'{path}' is not a file (type: {data.get('type')})." + + # Decode base64 content + import base64 + try: + content = base64.b64decode(data.get("content", "")).decode("utf-8") + except Exception as e: + return f"Could not decode file content: {e}" + + # Truncate very large files + max_chars = 50000 + if len(content) > max_chars: + content = content[:max_chars] + f"\n\n... [truncated, file is {len(content)} characters]" + + return ( + f"**File: {owner}/{repo}/{path}**\n" + f"Size: {data.get('size', 0)} bytes\n\n" + f"```\n{content}\n```" + ) + + async def search_repos(self, token: str, query: str = "", **kwargs) -> str: + """Search GitHub repositories.""" + if not query: + return "Error: 'query' argument is required." + + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.github.com/search/repositories", + params={"q": query, "per_page": 10, "sort": "stars"}, + headers=headers, + ) + + if response.status_code != 200: + return f"Search failed: HTTP {response.status_code}" + + data = response.json() + repos = data.get("items", []) + + if not repos: + return f"No repositories found for '{query}'." + + lines = [f"**Search results for '{query}'** ({data.get('total_count', 0)} total):\n"] + for repo in repos: + visibility = "Private" if repo["private"] else "Public" + desc = repo.get("description") or "No description" + lines.append( + f"- **{repo['full_name']}** ({visibility})\n" + f" {desc[:100]}{'...' if len(desc) > 100 else ''}\n" + f" Stars: {repo['stargazers_count']} | Language: {repo.get('language') or 'N/A'}" + ) + + return "\n".join(lines) diff --git a/requirements.txt b/requirements.txt index 755cfcf5ec..1a1fd346e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,5 +46,6 @@ imapclient>=3.0.1 html2text>=2024.2.26 beautifulsoup4>=4.12.3 boto3>=1.35.0 +httpx>=0.27.0 exchangelib>=5.4.3 pywinpty==3.0.2; sys_platform == "win32" \ No newline at end of file diff --git a/webui/components/chat/input/chat-bar-input.html b/webui/components/chat/input/chat-bar-input.html index 96b60735a6..240463c698 100644 --- a/webui/components/chat/input/chat-bar-input.html +++ b/webui/components/chat/input/chat-bar-input.html @@ -4,6 +4,7 @@ import { store as speechStore } from "/components/chat/speech/speech-store.js"; import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js"; import { store as fullScreenStore } from "/components/modals/full-screen-input/full-screen-store.js"; + import { store as repoMentionStore } from "/components/chat/input/repoMentionStore.js";
@@ -20,9 +21,11 @@