├── .gitignore ├── .idea ├── .gitignore ├── encodings.xml ├── misc.xml └── vcs.xml ├── LICENSE ├── README.md ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── devoxx │ │ └── agentic │ │ └── github │ │ ├── GitHubMcpApplication.java │ │ └── tools │ │ ├── AbstractToolService.java │ │ ├── BranchService.java │ │ ├── CommitService.java │ │ ├── ContentService.java │ │ ├── GitHubClientFactory.java │ │ ├── GitHubEnv.java │ │ ├── IssueService.java │ │ ├── PullRequestService.java │ │ └── RepositoryService.java └── resources │ ├── application.properties │ └── mcp-servers-config.json └── test └── java └── com └── devoxx └── agentic └── github ├── ClientStdio.java └── GitHubServiceTest.java /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | 6 | ### IntelliJ IDEA ### 7 | .idea/modules.xml 8 | .idea/jarRepositories.xml 9 | .idea/compiler.xml 10 | .idea/libraries/ 11 | *.iws 12 | *.iml 13 | *.ipr 14 | 15 | ### Eclipse ### 16 | .apt_generated 17 | .classpath 18 | .factorypath 19 | .project 20 | .settings 21 | .springBeans 22 | .sts4-cache 23 | 24 | ### NetBeans ### 25 | /nbproject/private/ 26 | /nbbuild/ 27 | /dist/ 28 | /nbdist/ 29 | /.nb-gradle/ 30 | build/ 31 | !**/src/main/**/build/ 32 | !**/src/test/**/build/ 33 | 34 | ### VS Code ### 35 | .vscode/ 36 | 37 | ### Mac OS ### 38 | .DS_Store 39 | /.idea/ 40 | /.env 41 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Stephan Janssen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub MCP Server 2 | 3 | This project implements a Model Context Protocol (MCP) server for GitHub API access. It provides a set of tools that allow LLM agents to interact with GitHub repositories, issues, pull requests, and other GitHub resources. 4 | 5 | ## Features 6 | 7 | The server provides the following GitHub operations: 8 | 9 | - **Repository Management** 10 | - List repositories for the authenticated user 11 | - Get repository details 12 | - Search for repositories 13 | 14 | - **Issue Management** 15 | - List issues with filtering options 16 | - Get detailed information about issues 17 | - Create new issues 18 | - Add comments to issues 19 | - Search for issues 20 | 21 | - **Pull Request Management** 22 | - List pull requests with filtering options 23 | - Get detailed information about pull requests 24 | - Create comments on pull requests 25 | - Merge pull requests 26 | 27 | - **Branch Management** 28 | - List branches in a repository 29 | - Create new branches 30 | 31 | - **Commit Management** 32 | - Get detailed information about commits 33 | - List commits with filtering options 34 | - Search for commits by message 35 | 36 | - **Content Management** 37 | - Get file contents from repositories 38 | - List directory contents 39 | - Create or update files 40 | - Search for code within repositories 41 | 42 | These operations are exposed as tools for Large Language Models using the Model Context Protocol (MCP), allowing AI systems to safely interact with GitHub through its API. 43 | 44 | ### Claude Desktop 45 | GitHubExample 46 | 47 | ### DevoxxGenie IDEA plugin 48 | Screenshot 2025-04-11 at 12 32 58 49 | 50 | 51 | ## Getting Started 52 | 53 | ### Prerequisites 54 | 55 | - Java 17 or higher 56 | - Maven 3.6+ 57 | - Spring Boot 3.3.6 58 | - Spring AI MCP Server components 59 | - A GitHub account and personal access token 60 | 61 | ### Building the Project 62 | 63 | Build the project using Maven: 64 | 65 | ```bash 66 | mvn clean package 67 | ``` 68 | 69 | ### Running the Server 70 | 71 | Run the server using the following command: 72 | 73 | ```bash 74 | java -jar target/GitHubMCP-1.0-SNAPSHOT.jar 75 | ``` 76 | 77 | The server can use STDIO for communication with MCP clients or can be run as a web server. 78 | 79 | ## Environment Variables 80 | 81 | The GitHub MCP server supports the following environment variables for authentication: 82 | 83 | - `GITHUB_TOKEN` or `GITHUB_PERSONAL_ACCESS_TOKEN`: Your GitHub personal access token 84 | - `GITHUB_HOST`: The base URL of your GitHub instance (e.g., `github.com` or `github.mycompany.com` for GitHub Enterprise) 85 | - `GITHUB_REPOSITORY`: Default repository to use if not specified in API calls (e.g., `owner/repo`) 86 | 87 | You can set these environment variables when launching the MCP server, and the GitHub services will use them as default values. This allows you to avoid having to provide authentication details with every API call. 88 | 89 | ## Usage with MCP Clients 90 | 91 | ### Using with Claude Desktop 92 | 93 | Edit your claude_desktop_config.json file with the following: 94 | 95 | ```json 96 | { 97 | "mcpServers": { 98 | "github": { 99 | "command": "java", 100 | "args": [ 101 | "-Dspring.ai.mcp.server.stdio=true", 102 | "-Dspring.main.web-application-type=none", 103 | "-Dlogging.pattern.console=", 104 | "-jar", 105 | "/path/to/GitHubMCP/target/GitHubMCP-1.0-SNAPSHOT.jar" 106 | ], 107 | "env": { 108 | "GITHUB_TOKEN": "your-github-token-here", 109 | "GITHUB_HOST": "github.com", 110 | "GITHUB_REPOSITORY": "your-username/your-repository" 111 | } 112 | } 113 | } 114 | } 115 | ``` 116 | 117 | ### Using with DevoxxGenie or similar MCP clients 118 | 119 | 1. In your MCP client, access the MCP Server configuration screen 120 | 2. Configure the server with the following settings: 121 | - **Name**: `GitHub` (or any descriptive name) 122 | - **Transport Type**: `STDIO` 123 | - **Command**: Full path to your Java executable 124 | - **Arguments**: 125 | ``` 126 | -Dspring.ai.mcp.server.stdio=true 127 | -Dspring.main.web-application-type=none 128 | -Dlogging.pattern.console= 129 | -jar 130 | /path/to/GitHubMCP/target/GitHubMCP-1.0-SNAPSHOT.jar 131 | ``` 132 | - **Environment Variables**: 133 | ``` 134 | GITHUB_TOKEN=your-github-token-here 135 | GITHUB_HOST=github.com 136 | GITHUB_REPOSITORY=your-username/your-repository 137 | ``` 138 | 139 | ## Security Considerations 140 | 141 | When using this server, be aware that: 142 | - You need to provide a GitHub personal access token for authentication 143 | - The LLM agent will have access to create, read, and modify GitHub resources 144 | - Consider running the server with appropriate permissions and in a controlled environment 145 | - Ensure your token has only the minimum required permissions for your use case 146 | 147 | ## Example Usage 148 | 149 | ### With Environment Variables 150 | 151 | If you've configured the MCP server with environment variables, you can simply ask: 152 | 153 | ``` 154 | Can you get a list of open issues in my GitHub repository? 155 | ``` 156 | 157 | Claude will use the GitHub services with the pre-configured authentication details to fetch the open issues from your GitHub repository and summarize them. 158 | 159 | ### With Explicit Repository 160 | 161 | You can override the default repository by specifying it in your request: 162 | 163 | ``` 164 | Can you list the pull requests for the anthropics/claude-playground repository? 165 | ``` 166 | 167 | The GitHub service will use your authentication token but query the specified repository instead of the default one. 168 | 169 | ## Available Services 170 | 171 | The GitHub MCP server is organized into several service classes, each providing different functionality: 172 | 173 | 1. **RepositoryService** 174 | - `listRepositories`: List repositories for the authenticated user 175 | - `getRepository`: Get detailed information about a specific repository 176 | - `searchRepositories`: Search for repositories matching a query 177 | 178 | 2. **IssueService** 179 | - `listIssues`: List issues for a repository with filtering options 180 | - `getIssue`: Get detailed information about a specific issue 181 | - `createIssue`: Create a new issue in a repository 182 | - `addIssueComment`: Add a comment to an issue 183 | - `searchIssues`: Search for issues matching a query 184 | 185 | 3. **PullRequestService** 186 | - `listPullRequests`: List pull requests for a repository with filtering options 187 | - `getPullRequest`: Get detailed information about a specific pull request 188 | - `createPullRequestComment`: Add a comment to a pull request 189 | - `mergePullRequest`: Merge a pull request with specified merge method 190 | 191 | 4. **BranchService** 192 | - `listBranches`: List branches in a repository with filtering options 193 | - `createBranch`: Create a new branch from a specified reference 194 | 195 | 5. **CommitService** 196 | - `getCommitDetails`: Get detailed information about a specific commit 197 | - `listCommits`: List commits in a repository with filtering options 198 | - `findCommitByMessage`: Search for commits containing specific text in their messages 199 | 200 | 6. **ContentService** 201 | - `getFileContents`: Get the contents of a file in a repository 202 | - `listDirectoryContents`: List contents of a directory in a repository 203 | - `createOrUpdateFile`: Create or update a file in a repository 204 | - `searchCode`: Search for code within repositories 205 | 206 | Each service provides methods that can be called by LLM agents through the MCP protocol, allowing them to interact with GitHub in a structured and controlled manner. 207 | 208 | ## Enterprise GitHub Support 209 | 210 | This MCP server supports both GitHub.com and GitHub Enterprise instances. To use with GitHub Enterprise, set the `GITHUB_HOST` environment variable to your enterprise GitHub URL. 211 | 212 | ## Limitations 213 | 214 | - The server requires a valid GitHub personal access token with appropriate permissions 215 | - Rate limiting is subject to GitHub API limits 216 | - Some operations may require specific permissions on the token 217 | - Large repositories or files may encounter performance limitations 218 | 219 | ## Contributing 220 | 221 | Contributions are welcome! Please feel free to submit a Pull Request. 222 | 223 | ## License 224 | 225 | This project is licensed under the MIT License - see the LICENSE file for details. 226 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | org.springframework.boot 8 | spring-boot-starter-parent 9 | 3.3.6 10 | 11 | 12 | 13 | com.devoxx.agentic 14 | GitHubMCP 15 | 1.0-SNAPSHOT 16 | 17 | 18 | 17 19 | 17 20 | UTF-8 21 | 22 | 23 | 24 | 25 | 26 | org.springframework.ai 27 | spring-ai-bom 28 | 1.0.0-SNAPSHOT 29 | pom 30 | import 31 | 32 | 33 | 34 | 35 | 36 | 37 | org.springframework.ai 38 | spring-ai-starter-mcp-server-webmvc 39 | 40 | 41 | 42 | org.springframework 43 | spring-web 44 | 45 | 46 | 47 | com.fasterxml.jackson.core 48 | jackson-databind 49 | 2.15.2 50 | 51 | 52 | 53 | io.github.cdimascio 54 | dotenv-java 55 | 3.0.0 56 | 57 | 58 | 59 | 60 | org.kohsuke 61 | github-api 62 | 1.317 63 | 64 | 65 | 66 | 67 | org.junit.jupiter 68 | junit-jupiter 69 | test 70 | 71 | 72 | 73 | org.mockito 74 | mockito-junit-jupiter 75 | test 76 | 77 | 78 | 79 | org.mockito 80 | mockito-core 81 | test 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | org.springframework.boot 90 | spring-boot-maven-plugin 91 | 92 | 93 | org.apache.maven.plugins 94 | maven-surefire-plugin 95 | 96 | 97 | 98 | 99 | 100 | 101 | Central Portal Snapshots 102 | central-portal-snapshots 103 | https://central.sonatype.com/repository/maven-snapshots/ 104 | 105 | false 106 | 107 | 108 | true 109 | 110 | 111 | 112 | spring-milestones 113 | Spring Milestones 114 | https://repo.spring.io/milestone 115 | 116 | false 117 | 118 | 119 | 120 | spring-snapshots 121 | Spring Snapshots 122 | https://repo.spring.io/snapshot 123 | 124 | false 125 | 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/agentic/github/GitHubMcpApplication.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github; 2 | 3 | import com.devoxx.agentic.github.tools.BranchService; 4 | import com.devoxx.agentic.github.tools.CommitService; 5 | import com.devoxx.agentic.github.tools.ContentService; 6 | import com.devoxx.agentic.github.tools.IssueService; 7 | import com.devoxx.agentic.github.tools.PullRequestService; 8 | import com.devoxx.agentic.github.tools.RepositoryService; 9 | import org.springframework.ai.tool.ToolCallbackProvider; 10 | import org.springframework.ai.tool.method.MethodToolCallbackProvider; 11 | import org.springframework.boot.SpringApplication; 12 | import org.springframework.boot.autoconfigure.SpringBootApplication; 13 | import org.springframework.context.annotation.Bean; 14 | 15 | @SpringBootApplication 16 | public class GitHubMcpApplication { 17 | 18 | public static void main(String[] args) { 19 | SpringApplication.run(GitHubMcpApplication.class, args); 20 | } 21 | 22 | @Bean 23 | public ToolCallbackProvider mcpServices(IssueService issueService, 24 | PullRequestService pullRequestService, 25 | RepositoryService repositoryService, 26 | BranchService branchService, 27 | CommitService commitService, 28 | ContentService contentService) { 29 | return MethodToolCallbackProvider.builder() 30 | .toolObjects(issueService, pullRequestService, repositoryService, branchService, commitService, contentService) 31 | .build(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/agentic/github/tools/AbstractToolService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github.tools; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | 5 | import java.util.HashMap; 6 | import java.util.Map; 7 | 8 | public class AbstractToolService { 9 | 10 | protected static final String SUCCESS = "success"; 11 | protected static final String ERROR = "error"; 12 | 13 | protected final ObjectMapper mapper = new ObjectMapper(); 14 | 15 | protected String errorMessage(String errorMessage) { 16 | Map result = new HashMap<>(); 17 | result.put(SUCCESS, false); 18 | result.put(ERROR, errorMessage); 19 | try { 20 | return mapper.writeValueAsString(result); 21 | } catch (Exception ex) { 22 | return "{\"success\": false, \"error\": \"Failed to serialize error result\"}"; 23 | } 24 | } 25 | 26 | protected String successMessage(Map result) { 27 | result.put(SUCCESS, true); 28 | try { 29 | return mapper.writeValueAsString(result); 30 | } catch (Exception ex) { 31 | return "{\"success\": false, \"error\": \"Failed to serialize success result\"}"; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/agentic/github/tools/BranchService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github.tools; 2 | 3 | import org.kohsuke.github.*; 4 | import org.springframework.ai.tool.annotation.Tool; 5 | import org.springframework.ai.tool.annotation.ToolParam; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.io.IOException; 9 | import java.util.*; 10 | 11 | /** 12 | * Service for GitHub branch-related operations 13 | */ 14 | @Service 15 | public class BranchService extends AbstractToolService { 16 | 17 | @Tool(description = """ 18 | List branches in a repository. 19 | Returns a list of branches with their details. 20 | """) 21 | public String listBranches( 22 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 23 | @ToolParam(description = "Filter branches by prefix", required = false) String filter, 24 | @ToolParam(description = "Maximum number of branches to return", required = false) Integer limit 25 | ) { 26 | Map result = new HashMap<>(); 27 | 28 | try { 29 | Optional env = GitHubClientFactory.getEnvironment(); 30 | if (env.isEmpty()) { 31 | return errorMessage("GitHub is not configured correctly"); 32 | } 33 | 34 | GitHubEnv githubEnv = env.get(); 35 | GitHub github = GitHubClientFactory.createClient(githubEnv); 36 | 37 | // Use provided repository or default from environment 38 | String repoName = (repository != null && !repository.isEmpty()) ? 39 | repository : githubEnv.getFullRepository(); 40 | 41 | if (repoName == null || repoName.isEmpty()) { 42 | return errorMessage("Repository name is required"); 43 | } 44 | 45 | GHRepository repo = github.getRepository(repoName); 46 | 47 | List> branchList = new ArrayList<>(); 48 | int count = 0; 49 | 50 | for (GHBranch branch : repo.getBranches().values()) { 51 | // Apply filter if provided 52 | if (filter != null && !filter.isEmpty() && !branch.getName().startsWith(filter)) { 53 | continue; 54 | } 55 | 56 | if (limit != null && count >= limit) { 57 | break; 58 | } 59 | 60 | Map branchData = new HashMap<>(); 61 | branchData.put("name", branch.getName()); 62 | branchData.put("sha", branch.getSHA1()); 63 | 64 | // Get the latest commit for this branch 65 | GHCommit commit = repo.getCommit(branch.getSHA1()); 66 | if (commit != null) { 67 | Map commitData = new HashMap<>(); 68 | commitData.put("message", commit.getCommitShortInfo().getMessage()); 69 | commitData.put("author", commit.getCommitShortInfo().getAuthor().getName()); 70 | commitData.put("date", commit.getCommitShortInfo().getCommitDate().toString()); 71 | branchData.put("latest_commit", commitData); 72 | } 73 | 74 | // Check if this is the default branch 75 | branchData.put("is_default", branch.getName().equals(repo.getDefaultBranch())); 76 | 77 | // Get protection status (requires separate API call) 78 | try { 79 | GHBranchProtection protection = repo.getBranch(branch.getName()).getProtection(); 80 | branchData.put("protected", true); 81 | // Add protection details if needed 82 | } catch (GHFileNotFoundException ex) { 83 | // Branch is not protected 84 | branchData.put("protected", false); 85 | } catch (Exception ex) { 86 | // Ignore other exceptions for protection status 87 | branchData.put("protected", false); 88 | } 89 | 90 | branchList.add(branchData); 91 | count++; 92 | } 93 | 94 | result.put("branches", branchList); 95 | result.put("total_count", branchList.size()); 96 | 97 | return successMessage(result); 98 | 99 | } catch (GHFileNotFoundException e) { 100 | return errorMessage("Repository not found: " + e.getMessage()); 101 | } catch (IOException e) { 102 | return errorMessage("IO error: " + e.getMessage()); 103 | } catch (Exception e) { 104 | return errorMessage("Unexpected error: " + e.getMessage()); 105 | } 106 | } 107 | 108 | @Tool(description = """ 109 | Create a new branch in a repository. 110 | Creates a branch from a specified SHA or reference (defaults to the default branch if not specified). 111 | """) 112 | public String createBranch( 113 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 114 | @ToolParam(description = "New branch name") String branchName, 115 | @ToolParam(description = "SHA or reference to create branch from (defaults to default branch)", required = false) String fromRef 116 | ) { 117 | Map result = new HashMap<>(); 118 | 119 | try { 120 | if (branchName == null || branchName.isEmpty()) { 121 | return errorMessage("Branch name is required"); 122 | } 123 | 124 | Optional env = GitHubClientFactory.getEnvironment(); 125 | if (env.isEmpty()) { 126 | return errorMessage("GitHub is not configured correctly"); 127 | } 128 | 129 | GitHubEnv githubEnv = env.get(); 130 | GitHub github = GitHubClientFactory.createClient(githubEnv); 131 | 132 | // Use provided repository or default from environment 133 | String repoName = (repository != null && !repository.isEmpty()) ? 134 | repository : githubEnv.getFullRepository(); 135 | 136 | if (repoName == null || repoName.isEmpty()) { 137 | return errorMessage("Repository name is required"); 138 | } 139 | 140 | GHRepository repo = github.getRepository(repoName); 141 | 142 | // Check if branch already exists 143 | try { 144 | GHBranch existingBranch = repo.getBranch(branchName); 145 | if (existingBranch != null) { 146 | return errorMessage("Branch '" + branchName + "' already exists"); 147 | } 148 | } catch (GHFileNotFoundException ex) { 149 | // Branch doesn't exist, which is what we want 150 | } 151 | 152 | // Determine the SHA to create from 153 | String sha; 154 | if (fromRef != null && !fromRef.isEmpty()) { 155 | try { 156 | // Try to get the SHA from the reference 157 | GHRef ref = repo.getRef("heads/" + fromRef); 158 | sha = ref.getObject().getSha(); 159 | } catch (GHFileNotFoundException ex) { 160 | try { 161 | // Try to resolve as a direct SHA 162 | GHCommit commit = repo.getCommit(fromRef); 163 | sha = commit.getSHA1(); 164 | } catch (Exception e) { 165 | return errorMessage("Invalid reference: " + fromRef); 166 | } 167 | } 168 | } else { 169 | // Use default branch 170 | String defaultBranch = repo.getDefaultBranch(); 171 | GHRef ref = repo.getRef("heads/" + defaultBranch); 172 | sha = ref.getObject().getSha(); 173 | } 174 | 175 | // Create the new branch 176 | GHRef newBranch = repo.createRef("refs/heads/" + branchName, sha); 177 | 178 | Map branchData = new HashMap<>(); 179 | branchData.put("name", branchName); 180 | branchData.put("sha", sha); 181 | branchData.put("url", newBranch.getUrl().toString()); 182 | 183 | result.put("branch", branchData); 184 | 185 | return successMessage(result); 186 | 187 | } catch (GHFileNotFoundException e) { 188 | return errorMessage("Repository not found: " + e.getMessage()); 189 | } catch (IOException e) { 190 | return errorMessage("IO error: " + e.getMessage()); 191 | } catch (Exception e) { 192 | return errorMessage("Unexpected error: " + e.getMessage()); 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/agentic/github/tools/CommitService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github.tools; 2 | 3 | import org.kohsuke.github.*; 4 | import org.springframework.ai.tool.annotation.Tool; 5 | import org.springframework.ai.tool.annotation.ToolParam; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.io.IOException; 9 | import java.util.*; 10 | 11 | /** 12 | * Service for GitHub commit-related operations 13 | */ 14 | @Service 15 | public class CommitService extends AbstractToolService { 16 | 17 | @Tool(description = """ 18 | Get detailed information about a specific commit. 19 | Returns commit data including author, committer, message, and file changes. 20 | """) 21 | public String getCommitDetails( 22 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 23 | @ToolParam(description = "Commit SHA") String sha 24 | ) { 25 | Map result = new HashMap<>(); 26 | 27 | try { 28 | if (sha == null || sha.isEmpty()) { 29 | return errorMessage("Commit SHA is required"); 30 | } 31 | 32 | Optional env = GitHubClientFactory.getEnvironment(); 33 | if (env.isEmpty()) { 34 | return errorMessage("GitHub is not configured correctly"); 35 | } 36 | 37 | GitHubEnv githubEnv = env.get(); 38 | GitHub github = GitHubClientFactory.createClient(githubEnv); 39 | 40 | // Use provided repository or default from environment 41 | String repoName = (repository != null && !repository.isEmpty()) ? 42 | repository : githubEnv.getFullRepository(); 43 | 44 | if (repoName == null || repoName.isEmpty()) { 45 | return errorMessage("Repository name is required"); 46 | } 47 | 48 | GHRepository repo = github.getRepository(repoName); 49 | GHCommit commit = repo.getCommit(sha); 50 | 51 | Map commitData = new HashMap<>(); 52 | commitData.put("sha", commit.getSHA1()); 53 | commitData.put("message", commit.getCommitShortInfo().getMessage()); 54 | commitData.put("html_url", commit.getHtmlUrl().toString()); 55 | 56 | // Author details 57 | GHCommit.ShortInfo info = commit.getCommitShortInfo(); 58 | Map authorData = new HashMap<>(); 59 | authorData.put("name", info.getAuthor().getName()); 60 | authorData.put("email", info.getAuthor().getEmail()); 61 | authorData.put("date", info.getAuthoredDate().toString()); 62 | commitData.put("author", authorData); 63 | 64 | // Committer details (might be different from author) 65 | Map committerData = new HashMap<>(); 66 | committerData.put("name", info.getCommitter().getName()); 67 | committerData.put("email", info.getCommitter().getEmail()); 68 | committerData.put("date", info.getCommitDate().toString()); 69 | commitData.put("committer", committerData); 70 | 71 | // Parents 72 | List> parentsList = new ArrayList<>(); 73 | for (GHCommit parent : commit.getParents()) { 74 | Map parentData = new HashMap<>(); 75 | parentData.put("sha", parent.getSHA1()); 76 | parentData.put("url", parent.getHtmlUrl().toString()); 77 | parentsList.add(parentData); 78 | } 79 | commitData.put("parents", parentsList); 80 | 81 | // File changes 82 | List> filesList = new ArrayList<>(); 83 | for (GHCommit.File file : commit.listFiles()) { 84 | Map fileData = new HashMap<>(); 85 | fileData.put("filename", file.getFileName()); 86 | fileData.put("status", file.getStatus()); 87 | fileData.put("additions", file.getLinesAdded()); 88 | fileData.put("deletions", file.getLinesDeleted()); 89 | fileData.put("changes", file.getLinesChanged()); 90 | fileData.put("patch", file.getPatch()); 91 | filesList.add(fileData); 92 | } 93 | commitData.put("files", filesList); 94 | 95 | // Stats 96 | Map statsData = new HashMap<>(); 97 | statsData.put("additions", commit.getLinesAdded()); 98 | statsData.put("deletions", commit.getLinesDeleted()); 99 | statsData.put("total", commit.getLinesChanged()); 100 | commitData.put("stats", statsData); 101 | 102 | result.put("commit", commitData); 103 | 104 | return successMessage(result); 105 | 106 | } catch (GHFileNotFoundException e) { 107 | return errorMessage("Commit or repository not found: " + e.getMessage()); 108 | } catch (IOException e) { 109 | return errorMessage("IO error: " + e.getMessage()); 110 | } catch (Exception e) { 111 | return errorMessage("Unexpected error: " + e.getMessage()); 112 | } 113 | } 114 | 115 | @Tool(description = """ 116 | List commits in a repository. 117 | Returns a list of commits with filtering options for branch and author. 118 | """) 119 | public String listCommits( 120 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 121 | @ToolParam(description = "Branch or tag name to filter commits", required = false) String branch, 122 | @ToolParam(description = "Author name or email to filter commits", required = false) String author, 123 | @ToolParam(description = "Path to filter commits that touch the specified path", required = false) String path, 124 | @ToolParam(description = "Maximum number of commits to return", required = false) Integer limit 125 | ) { 126 | Map result = new HashMap<>(); 127 | 128 | try { 129 | Optional env = GitHubClientFactory.getEnvironment(); 130 | if (env.isEmpty()) { 131 | return errorMessage("GitHub is not configured correctly"); 132 | } 133 | 134 | GitHubEnv githubEnv = env.get(); 135 | GitHub github = GitHubClientFactory.createClient(githubEnv); 136 | 137 | // Use provided repository or default from environment 138 | String repoName = (repository != null && !repository.isEmpty()) ? 139 | repository : githubEnv.getFullRepository(); 140 | 141 | if (repoName == null || repoName.isEmpty()) { 142 | return errorMessage("Repository name is required"); 143 | } 144 | 145 | GHRepository repo = github.getRepository(repoName); 146 | 147 | // Setup commit query parameters 148 | GHCommitQueryBuilder queryBuilder = repo.queryCommits(); 149 | 150 | if (branch != null && !branch.isEmpty()) { 151 | queryBuilder.from(branch); 152 | } 153 | 154 | if (author != null && !author.isEmpty()) { 155 | queryBuilder.author(author); 156 | } 157 | 158 | if (path != null && !path.isEmpty()) { 159 | queryBuilder.path(path); 160 | } 161 | 162 | int actualLimit = (limit != null && limit > 0) ? limit : 30; // Default to 30 commits 163 | 164 | List> commitList = new ArrayList<>(); 165 | int count = 0; 166 | 167 | for (GHCommit commit : queryBuilder.list().withPageSize(actualLimit)) { 168 | if (count >= actualLimit) { 169 | break; 170 | } 171 | 172 | Map commitData = new HashMap<>(); 173 | commitData.put("sha", commit.getSHA1()); 174 | 175 | GHCommit.ShortInfo info = commit.getCommitShortInfo(); 176 | commitData.put("message", info.getMessage()); 177 | commitData.put("author", info.getAuthor().getName()); 178 | commitData.put("author_email", info.getAuthor().getEmail()); 179 | commitData.put("date", info.getAuthoredDate().toString()); 180 | 181 | // Include stats 182 | Map statsData = new HashMap<>(); 183 | statsData.put("additions", commit.getLinesAdded()); 184 | statsData.put("deletions", commit.getLinesDeleted()); 185 | statsData.put("total", commit.getLinesChanged()); 186 | commitData.put("stats", statsData); 187 | 188 | commitData.put("html_url", commit.getHtmlUrl().toString()); 189 | 190 | commitList.add(commitData); 191 | count++; 192 | } 193 | 194 | result.put("commits", commitList); 195 | result.put("count", commitList.size()); 196 | 197 | if (branch != null && !branch.isEmpty()) { 198 | result.put("branch", branch); 199 | } 200 | 201 | if (author != null && !author.isEmpty()) { 202 | result.put("author", author); 203 | } 204 | 205 | if (path != null && !path.isEmpty()) { 206 | result.put("path", path); 207 | } 208 | 209 | return successMessage(result); 210 | 211 | } catch (GHFileNotFoundException e) { 212 | return errorMessage("Repository not found: " + e.getMessage()); 213 | } catch (IOException e) { 214 | return errorMessage("IO error: " + e.getMessage()); 215 | } catch (Exception e) { 216 | return errorMessage("Unexpected error: " + e.getMessage()); 217 | } 218 | } 219 | 220 | @Tool(description = """ 221 | Search for a commit based on the provided text or keywords in the project history. 222 | Useful for finding specific change sets or code modifications by commit messages or diff content. 223 | Takes a query parameter and returns the matching commit information. 224 | Returns matched commit hashes as a JSON array. 225 | """) 226 | public String findCommitByMessage( 227 | @ToolParam(description = "Text to search for in commit messages") String text, 228 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 229 | @ToolParam(description = "Branch or tag name to search within", required = false) String branch, 230 | @ToolParam(description = "Maximum number of results to return", required = false) Integer limit 231 | ) { 232 | Map result = new HashMap<>(); 233 | 234 | try { 235 | if (text == null || text.isEmpty()) { 236 | return errorMessage("Search text is required"); 237 | } 238 | 239 | Optional env = GitHubClientFactory.getEnvironment(); 240 | if (env.isEmpty()) { 241 | return errorMessage("GitHub is not configured correctly"); 242 | } 243 | 244 | GitHubEnv githubEnv = env.get(); 245 | GitHub github = GitHubClientFactory.createClient(githubEnv); 246 | 247 | // Use provided repository or default from environment 248 | String repoName = (repository != null && !repository.isEmpty()) ? 249 | repository : githubEnv.getFullRepository(); 250 | 251 | if (repoName == null || repoName.isEmpty()) { 252 | return errorMessage("Repository name is required"); 253 | } 254 | 255 | GHRepository repo = github.getRepository(repoName); 256 | 257 | // Setup query parameters 258 | GHCommitQueryBuilder queryBuilder = repo.queryCommits(); 259 | 260 | if (branch != null && !branch.isEmpty()) { 261 | queryBuilder.from(branch); 262 | } 263 | 264 | // Normalize the search text to lowercase for case-insensitive matching 265 | String searchText = text.toLowerCase(); 266 | int actualLimit = (limit != null && limit > 0) ? limit : 20; // Default to 20 results 267 | 268 | List> matchedCommits = new ArrayList<>(); 269 | int count = 0; 270 | 271 | for (GHCommit commit : queryBuilder.list()) { 272 | if (count >= actualLimit) { 273 | break; 274 | } 275 | 276 | GHCommit.ShortInfo info = commit.getCommitShortInfo(); 277 | String message = info.getMessage(); 278 | 279 | // Check if the commit message contains the search text 280 | if (message != null && message.toLowerCase().contains(searchText)) { 281 | Map commitData = new HashMap<>(); 282 | commitData.put("sha", commit.getSHA1()); 283 | commitData.put("message", message); 284 | commitData.put("author", info.getAuthor().getName()); 285 | commitData.put("date", info.getAuthoredDate().toString()); 286 | commitData.put("html_url", commit.getHtmlUrl().toString()); 287 | 288 | // Include stats 289 | Map statsData = new HashMap<>(); 290 | statsData.put("additions", commit.getLinesAdded()); 291 | statsData.put("deletions", commit.getLinesDeleted()); 292 | statsData.put("total", commit.getLinesChanged()); 293 | commitData.put("stats", statsData); 294 | 295 | matchedCommits.add(commitData); 296 | count++; 297 | } 298 | } 299 | 300 | result.put("commits", matchedCommits); 301 | result.put("count", matchedCommits.size()); 302 | result.put("search_text", text); 303 | 304 | if (branch != null && !branch.isEmpty()) { 305 | result.put("branch", branch); 306 | } 307 | 308 | return successMessage(result); 309 | 310 | } catch (GHFileNotFoundException e) { 311 | return errorMessage("Repository not found: " + e.getMessage()); 312 | } catch (IOException e) { 313 | return errorMessage("IO error: " + e.getMessage()); 314 | } catch (Exception e) { 315 | return errorMessage("Unexpected error: " + e.getMessage()); 316 | } 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/agentic/github/tools/ContentService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github.tools; 2 | 3 | import org.kohsuke.github.*; 4 | import org.springframework.ai.tool.annotation.Tool; 5 | import org.springframework.ai.tool.annotation.ToolParam; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.io.IOException; 9 | import java.util.*; 10 | import java.nio.charset.StandardCharsets; 11 | import java.util.Base64; 12 | 13 | /** 14 | * Service for GitHub repository content management operations 15 | */ 16 | @Service 17 | public class ContentService extends AbstractToolService { 18 | 19 | @Tool(description = """ 20 | Get the contents of a file in a repository. 21 | Returns the file content and metadata such as size and sha. 22 | """) 23 | public String getFileContents( 24 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 25 | @ToolParam(description = "Path to the file in the repository") String path, 26 | @ToolParam(description = "Branch or commit SHA (defaults to the default branch)", required = false) String ref 27 | ) { 28 | Map result = new HashMap<>(); 29 | 30 | try { 31 | if (path == null || path.isEmpty()) { 32 | return errorMessage("File path is required"); 33 | } 34 | 35 | Optional env = GitHubClientFactory.getEnvironment(); 36 | if (env.isEmpty()) { 37 | return errorMessage("GitHub is not configured correctly"); 38 | } 39 | 40 | GitHubEnv githubEnv = env.get(); 41 | GitHub github = GitHubClientFactory.createClient(githubEnv); 42 | 43 | // Use provided repository or default from environment 44 | String repoName = (repository != null && !repository.isEmpty()) ? 45 | repository : githubEnv.getFullRepository(); 46 | 47 | if (repoName == null || repoName.isEmpty()) { 48 | return errorMessage("Repository name is required"); 49 | } 50 | 51 | GHRepository repo = github.getRepository(repoName); 52 | 53 | // Get contents, using ref if provided 54 | GHContent content; 55 | if (ref != null && !ref.isEmpty()) { 56 | content = repo.getFileContent(path, ref); 57 | } else { 58 | content = repo.getFileContent(path); 59 | } 60 | 61 | // Check if it's a file 62 | if (content.isDirectory()) { 63 | return errorMessage("Path points to a directory, not a file"); 64 | } 65 | 66 | var contentData = getContentDetails(content); 67 | contentData.put("type", content.getType()); 68 | contentData.put("url", content.getHtmlUrl()); 69 | contentData.put("download_url", content.getDownloadUrl()); 70 | 71 | // Get and decode content 72 | String base64Content = content.getContent(); 73 | if (base64Content != null) { 74 | // The content is base64 encoded 75 | String decodedContent = new String(Base64.getDecoder().decode(base64Content), StandardCharsets.UTF_8); 76 | contentData.put("content", decodedContent); 77 | } else { 78 | contentData.put("content", ""); 79 | } 80 | 81 | result.put("file", contentData); 82 | 83 | return successMessage(result); 84 | 85 | } catch (GHFileNotFoundException e) { 86 | return errorMessage("File or repository not found: " + e.getMessage()); 87 | } catch (IOException e) { 88 | return errorMessage("IO error: " + e.getMessage()); 89 | } catch (Exception e) { 90 | return errorMessage("Unexpected error: " + e.getMessage()); 91 | } 92 | } 93 | 94 | @Tool(description = """ 95 | List contents of a directory in a repository. 96 | Returns a list of files and directories at the specified path. 97 | """) 98 | public String listDirectoryContents( 99 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 100 | @ToolParam(description = "Path to the directory in the repository (use '/' for root)", required = false) String path, 101 | @ToolParam(description = "Branch or commit SHA (defaults to the default branch)", required = false) String ref 102 | ) { 103 | Map result = new HashMap<>(); 104 | 105 | try { 106 | Optional env = GitHubClientFactory.getEnvironment(); 107 | if (env.isEmpty()) { 108 | return errorMessage("GitHub is not configured correctly"); 109 | } 110 | 111 | GitHubEnv githubEnv = env.get(); 112 | GitHub github = GitHubClientFactory.createClient(githubEnv); 113 | 114 | // Use provided repository or default from environment 115 | String repoName = (repository != null && !repository.isEmpty()) ? 116 | repository : githubEnv.getFullRepository(); 117 | 118 | if (repoName == null || repoName.isEmpty()) { 119 | return errorMessage("Repository name is required"); 120 | } 121 | 122 | GHRepository repo = github.getRepository(repoName); 123 | 124 | // Set default path if not provided 125 | String dirPath = (path != null && !path.isEmpty()) ? path : ""; 126 | 127 | // Get contents, using ref if provided 128 | List contents; 129 | if (ref != null && !ref.isEmpty()) { 130 | contents = repo.getDirectoryContent(dirPath, ref); 131 | } else { 132 | contents = repo.getDirectoryContent(dirPath); 133 | } 134 | 135 | List> contentsList = new ArrayList<>(); 136 | 137 | for (GHContent content : contents) { 138 | Map contentData = getContentDetails(content); 139 | contentData.put("type", content.isDirectory() ? "directory" : "file"); 140 | contentData.put("url", content.getHtmlUrl()); 141 | if (!content.isDirectory()) { 142 | contentData.put("download_url", content.getDownloadUrl()); 143 | } 144 | 145 | contentsList.add(contentData); 146 | } 147 | 148 | result.put("contents", contentsList); 149 | result.put("path", dirPath); 150 | 151 | return successMessage(result); 152 | 153 | } catch (GHFileNotFoundException e) { 154 | return errorMessage("Directory or repository not found: " + e.getMessage()); 155 | } catch (IOException e) { 156 | return errorMessage("IO error: " + e.getMessage()); 157 | } catch (Exception e) { 158 | return errorMessage("Unexpected error: " + e.getMessage()); 159 | } 160 | } 161 | 162 | private Map getContentDetails(GHContent content) { 163 | Map contentData = new HashMap<>(); 164 | contentData.put("name", content.getName()); 165 | contentData.put("path", content.getPath()); 166 | contentData.put("sha", content.getSha()); 167 | contentData.put("size", content.getSize()); 168 | return contentData; 169 | } 170 | 171 | @Tool(description = """ 172 | Create or update a file in a repository. 173 | If the file doesn't exist, it will be created. If it exists, it will be updated. 174 | """) 175 | public String createOrUpdateFile( 176 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 177 | @ToolParam(description = "Path to the file in the repository") String path, 178 | @ToolParam(description = "File content") String content, 179 | @ToolParam(description = "Commit message") String message, 180 | @ToolParam(description = "Branch name (defaults to the default branch)", required = false) String branch, 181 | @ToolParam(description = "Current file SHA (required for updates, not for new files)", required = false) String sha 182 | ) { 183 | Map result = new HashMap<>(); 184 | 185 | try { 186 | if (path == null || path.isEmpty()) { 187 | return errorMessage("File path is required"); 188 | } 189 | 190 | if (content == null) { 191 | return errorMessage("File content is required"); 192 | } 193 | 194 | if (message == null || message.isEmpty()) { 195 | return errorMessage("Commit message is required"); 196 | } 197 | 198 | Optional env = GitHubClientFactory.getEnvironment(); 199 | if (env.isEmpty()) { 200 | return errorMessage("GitHub is not configured correctly"); 201 | } 202 | 203 | GitHubEnv githubEnv = env.get(); 204 | GitHub github = GitHubClientFactory.createClient(githubEnv); 205 | 206 | // Use provided repository or default from environment 207 | String repoName = (repository != null && !repository.isEmpty()) ? 208 | repository : githubEnv.getFullRepository(); 209 | 210 | if (repoName == null || repoName.isEmpty()) { 211 | return errorMessage("Repository name is required"); 212 | } 213 | 214 | GHRepository repo = github.getRepository(repoName); 215 | 216 | // Determine branch to use 217 | String branchToUse = (branch != null && !branch.isEmpty()) ? 218 | branch : repo.getDefaultBranch(); 219 | 220 | // Create GHContentBuilder 221 | GHContentBuilder contentBuilder = repo.createContent() 222 | .content(content) 223 | .message(message) 224 | .path(path) 225 | .branch(branchToUse); 226 | 227 | // Add SHA if updating an existing file 228 | if (sha != null && !sha.isEmpty()) { 229 | contentBuilder.sha(sha); 230 | } 231 | 232 | // Commit the changes 233 | GHContentUpdateResponse response = contentBuilder.commit(); 234 | 235 | // Prepare response data 236 | Map contentData = new HashMap<>(); 237 | contentData.put("path", path); 238 | 239 | // Get the commit info 240 | GitCommit commit = response.getCommit(); 241 | Map commitData = new HashMap<>(); 242 | commitData.put("sha", commit.getSHA1()); 243 | commitData.put("url", commit.getHtmlUrl()); 244 | commitData.put("message", commit.getMessage()); 245 | contentData.put("commit", commitData); 246 | 247 | // Get the content info 248 | GHContent fileContent = response.getContent(); 249 | contentData.put("sha", fileContent.getSha()); 250 | contentData.put("name", fileContent.getName()); 251 | contentData.put("url", fileContent.getHtmlUrl()); 252 | 253 | result.put("operation", sha != null ? "update" : "create"); 254 | result.put("file", contentData); 255 | 256 | return successMessage(result); 257 | 258 | } catch (GHFileNotFoundException e) { 259 | return errorMessage("Repository not found: " + e.getMessage()); 260 | } catch (IOException e) { 261 | return errorMessage("IO error: " + e.getMessage()); 262 | } catch (Exception e) { 263 | return errorMessage("Unexpected error: " + e.getMessage()); 264 | } 265 | } 266 | 267 | @Tool(description = """ 268 | Search for code within repositories. 269 | Searches GitHub for code matching the query. 270 | """) 271 | public String searchCode( 272 | @ToolParam(description = "Search query") String query, 273 | @ToolParam(description = "Repository name in format 'owner/repo' to limit search", required = false) String repository, 274 | @ToolParam(description = "Filter by file extension (e.g., 'java', 'py')", required = false) String extension, 275 | @ToolParam(description = "Maximum number of results to return", required = false) Integer limit 276 | ) { 277 | Map result = new HashMap<>(); 278 | 279 | try { 280 | if (query == null || query.isEmpty()) { 281 | return errorMessage("Search query is required"); 282 | } 283 | 284 | Optional env = GitHubClientFactory.getEnvironment(); 285 | if (env.isEmpty()) { 286 | return errorMessage("GitHub is not configured correctly"); 287 | } 288 | 289 | GitHubEnv githubEnv = env.get(); 290 | GitHub github = GitHubClientFactory.createClient(githubEnv); 291 | 292 | // Build search query 293 | StringBuilder queryBuilder = new StringBuilder(query); 294 | 295 | // Add repository filter if provided 296 | if (repository != null && !repository.isEmpty()) { 297 | queryBuilder.append(" repo:").append(repository); 298 | } 299 | 300 | // Add extension filter if provided 301 | if (extension != null && !extension.isEmpty()) { 302 | queryBuilder.append(" extension:").append(extension); 303 | } 304 | 305 | int actualLimit = (limit != null && limit > 0) ? limit : 20; // Default to 20 results 306 | 307 | GHContentSearchBuilder searchBuilder = github.searchContent() 308 | .q(queryBuilder.toString()); 309 | 310 | List> resultsList = new ArrayList<>(); 311 | int count = 0; 312 | 313 | for (GHContent content : searchBuilder.list().withPageSize(actualLimit)) { 314 | if (count >= actualLimit) { 315 | break; 316 | } 317 | 318 | Map contentData = new HashMap<>(); 319 | contentData.put("name", content.getName()); 320 | contentData.put("path", content.getPath()); 321 | contentData.put("sha", content.getSha()); 322 | contentData.put("repository", content.getOwner().getFullName()); 323 | contentData.put("html_url", content.getHtmlUrl()); 324 | 325 | // Try to get a snippet of content for context 326 | try { 327 | // The content is base64 encoded 328 | String base64Content = content.getContent(); 329 | if (base64Content != null) { 330 | String decodedContent = new String(Base64.getDecoder().decode(base64Content), StandardCharsets.UTF_8); 331 | 332 | // Get a snippet (first 200 chars or less) 333 | int snippetLength = Math.min(decodedContent.length(), 200); 334 | String snippet = decodedContent.substring(0, snippetLength); 335 | if (snippetLength < decodedContent.length()) { 336 | snippet += "..."; 337 | } 338 | 339 | contentData.put("text_matches", snippet); 340 | } 341 | } catch (Exception e) { 342 | // Ignore content retrieval errors for search results 343 | contentData.put("text_matches", "[Content unavailable]"); 344 | } 345 | 346 | resultsList.add(contentData); 347 | count++; 348 | } 349 | 350 | result.put("items", resultsList); 351 | result.put("count", resultsList.size()); 352 | result.put("query", queryBuilder.toString()); 353 | 354 | return successMessage(result); 355 | 356 | } catch (IOException e) { 357 | return errorMessage("IO error: " + e.getMessage()); 358 | } catch (Exception e) { 359 | return errorMessage("Unexpected error: " + e.getMessage()); 360 | } 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/agentic/github/tools/GitHubClientFactory.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github.tools; 2 | 3 | import org.kohsuke.github.GitHub; 4 | import java.io.IOException; 5 | import java.util.Optional; 6 | 7 | /** 8 | * Factory for creating GitHub client instances with proper configuration 9 | */ 10 | public class GitHubClientFactory { 11 | 12 | /** 13 | * Creates a GitHub client based on the provided environment configuration 14 | * 15 | * @param env The GitHub environment configuration 16 | * @return A configured GitHub client 17 | * @throws IOException if there's an error connecting to GitHub 18 | */ 19 | public static GitHub createClient(GitHubEnv env) throws IOException { 20 | if (env.isEnterprise()) { 21 | return GitHub.connectToEnterprise(env.githubHost(), env.githubToken()); 22 | } else { 23 | return GitHub.connectUsingOAuth(env.githubToken()); 24 | } 25 | } 26 | 27 | /** 28 | * Retrieves environment variables needed for GitHub API access 29 | * 30 | * @return Optional containing GitHub environment, or empty if configuration is missing 31 | */ 32 | public static Optional getEnvironment() { 33 | String token = getEnvValue("GITHUB_TOKEN"); 34 | if (token == null || token.isEmpty()) { 35 | // Try personal access token alternative env var 36 | token = getEnvValue("GITHUB_PERSONAL_ACCESS_TOKEN"); 37 | if (token == null || token.isEmpty()) { 38 | System.err.println("WARNING: GitHub token not found in environment variables"); 39 | return Optional.empty(); 40 | } 41 | } 42 | 43 | String host = getEnvValue("GITHUB_HOST"); 44 | if (host == null || host.isEmpty()) { 45 | // Try GH_HOST alternative 46 | host = getEnvValue("GH_HOST"); 47 | // Default to github.com if not set 48 | if (host == null || host.isEmpty()) { 49 | host = "github.com"; 50 | } 51 | } 52 | 53 | String repository = getEnvValue("GITHUB_REPOSITORY"); 54 | 55 | // Log information without exposing token 56 | String tokenPreview = (token.length() > 8) ? 57 | token.substring(0, 4) + "..." + token.substring(token.length() - 4) : "****"; 58 | System.out.println("GitHub configuration: Host=" + host + 59 | ", Token=" + tokenPreview + 60 | ", Default repository=" + repository); 61 | 62 | return Optional.of(new GitHubEnv(token, host, repository)); 63 | } 64 | 65 | /** 66 | * Helper method to retrieve environment variables from various sources 67 | */ 68 | private static String getEnvValue(String name) { 69 | // First check system properties (useful for tests) 70 | String value = System.getProperty(name); 71 | if (value != null && !value.isEmpty()) { 72 | return value; 73 | } 74 | 75 | // Then check environment variables 76 | return System.getenv(name); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/agentic/github/tools/GitHubEnv.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github.tools; 2 | 3 | public record GitHubEnv(String githubToken, String githubHost, String repository) { 4 | /** 5 | * Returns whether the environment is set up for GitHub Enterprise 6 | */ 7 | public boolean isEnterprise() { 8 | return githubHost != null && !githubHost.isEmpty() && 9 | !githubHost.equals("github.com") && !githubHost.equals("https://github.com"); 10 | } 11 | 12 | /** 13 | * Gets the full repository name in owner/repo format 14 | */ 15 | public String getFullRepository() { 16 | return repository; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/agentic/github/tools/IssueService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github.tools; 2 | 3 | import org.kohsuke.github.*; 4 | import org.springframework.ai.tool.annotation.Tool; 5 | import org.springframework.ai.tool.annotation.ToolParam; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.io.IOException; 9 | import java.util.*; 10 | 11 | /** 12 | * Service for GitHub issue-related operations 13 | */ 14 | @Service 15 | public class IssueService extends AbstractToolService { 16 | 17 | @Tool(description = """ 18 | List issues for a repository. 19 | Returns issues with filtering options for state, labels, and more. 20 | """) 21 | public String listIssues( 22 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 23 | @ToolParam(description = "State of issues to return (open, closed, all)", required = false) String state, 24 | @ToolParam(description = "Maximum number of issues to return", required = false) Integer limit 25 | ) { 26 | Map result = new HashMap<>(); 27 | 28 | try { 29 | Optional env = GitHubClientFactory.getEnvironment(); 30 | if (env.isEmpty()) { 31 | return errorMessage("GitHub is not configured correctly"); 32 | } 33 | 34 | GitHubEnv githubEnv = env.get(); 35 | GitHub github = GitHubClientFactory.createClient(githubEnv); 36 | 37 | // Use provided repository or default from environment 38 | String repoName = (repository != null && !repository.isEmpty()) ? 39 | repository : githubEnv.getFullRepository(); 40 | 41 | if (repoName == null || repoName.isEmpty()) { 42 | return errorMessage("Repository name is required"); 43 | } 44 | 45 | GHRepository repo = github.getRepository(repoName); 46 | 47 | GHIssueState issueState = GHIssueState.OPEN; 48 | if (state != null) { 49 | issueState = switch (state.toLowerCase()) { 50 | case "closed" -> GHIssueState.CLOSED; 51 | case "all" -> GHIssueState.ALL; 52 | default -> issueState; 53 | }; 54 | } 55 | 56 | List issues; 57 | 58 | // if (labels != null && !labels.isEmpty()) { 59 | // String[] labelArray = labels.split(","); 60 | // issues = repo.getIssues(issueState, labelArray); 61 | // } else { 62 | issues = repo.getIssues(issueState); 63 | // } 64 | 65 | List> issueList = new ArrayList<>(); 66 | int count = 0; 67 | 68 | for (GHIssue issue : issues) { 69 | if (limit != null && count >= limit) { 70 | break; 71 | } 72 | 73 | Map issueData = new HashMap<>(); 74 | issueData.put("number", issue.getNumber()); 75 | issueData.put("title", issue.getTitle()); 76 | issueData.put("state", issue.getState().name().toLowerCase()); 77 | issueData.put("html_url", issue.getHtmlUrl().toString()); 78 | 79 | List issueLabels = new ArrayList<>(); 80 | for (GHLabel label : issue.getLabels()) { 81 | issueLabels.add(label.getName()); 82 | } 83 | issueData.put("labels", issueLabels); 84 | 85 | issueData.put("created_at", issue.getCreatedAt().toString()); 86 | issueData.put("updated_at", issue.getUpdatedAt().toString()); 87 | issueData.put("closed_at", issue.getClosedAt() != null ? issue.getClosedAt().toString() : null); 88 | 89 | if (issue.getAssignee() != null) { 90 | issueData.put("assignee", issue.getAssignee().getLogin()); 91 | } 92 | 93 | issueList.add(issueData); 94 | count++; 95 | } 96 | 97 | result.put("issues", issueList); 98 | result.put("total_count", issues.size()); 99 | 100 | return successMessage(result); 101 | 102 | } catch (GHFileNotFoundException e) { 103 | return errorMessage("Repository not found: " + e.getMessage()); 104 | } catch (IOException e) { 105 | return errorMessage("IO error: " + e.getMessage()); 106 | } catch (Exception e) { 107 | return errorMessage("Unexpected error: " + e.getMessage()); 108 | } 109 | } 110 | 111 | @Tool(description = """ 112 | Get a specific issue in a repository. 113 | Returns detailed information about the issue. 114 | """) 115 | public String getIssue( 116 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 117 | @ToolParam(description = "Issue number") Integer issueNumber 118 | ) { 119 | Map result = new HashMap<>(); 120 | 121 | try { 122 | if (issueNumber == null) { 123 | return errorMessage("Issue number is required"); 124 | } 125 | 126 | Optional env = GitHubClientFactory.getEnvironment(); 127 | if (env.isEmpty()) { 128 | return errorMessage("GitHub is not configured correctly"); 129 | } 130 | 131 | GitHubEnv githubEnv = env.get(); 132 | GitHub github = GitHubClientFactory.createClient(githubEnv); 133 | 134 | // Use provided repository or default from environment 135 | String repoName = (repository != null && !repository.isEmpty()) ? 136 | repository : githubEnv.getFullRepository(); 137 | 138 | if (repoName == null || repoName.isEmpty()) { 139 | return errorMessage("Repository name is required"); 140 | } 141 | 142 | GHRepository repo = github.getRepository(repoName); 143 | GHIssue issue = repo.getIssue(issueNumber); 144 | 145 | Map issueData = new HashMap<>(); 146 | issueData.put("number", issue.getNumber()); 147 | issueData.put("title", issue.getTitle()); 148 | issueData.put("body", issue.getBody()); 149 | issueData.put("state", issue.getState().name().toLowerCase()); 150 | issueData.put("html_url", issue.getHtmlUrl().toString()); 151 | 152 | List labels = new ArrayList<>(); 153 | for (GHLabel label : issue.getLabels()) { 154 | labels.add(label.getName()); 155 | } 156 | issueData.put("labels", labels); 157 | 158 | issueData.put("created_at", issue.getCreatedAt().toString()); 159 | issueData.put("updated_at", issue.getUpdatedAt().toString()); 160 | issueData.put("closed_at", issue.getClosedAt() != null ? issue.getClosedAt().toString() : null); 161 | 162 | if (issue.getAssignee() != null) { 163 | issueData.put("assignee", issue.getAssignee().getLogin()); 164 | } 165 | 166 | // Get comments 167 | List> commentsList = new ArrayList<>(); 168 | for (GHIssueComment comment : issue.getComments()) { 169 | Map commentData = new HashMap<>(); 170 | commentData.put("id", comment.getId()); 171 | commentData.put("user", comment.getUser().getLogin()); 172 | commentData.put("body", comment.getBody()); 173 | commentData.put("created_at", comment.getCreatedAt().toString()); 174 | commentData.put("updated_at", comment.getUpdatedAt().toString()); 175 | 176 | commentsList.add(commentData); 177 | } 178 | 179 | issueData.put("comments", commentsList); 180 | issueData.put("comments_count", commentsList.size()); 181 | 182 | result.put("issue", issueData); 183 | 184 | return successMessage(result); 185 | 186 | } catch (GHFileNotFoundException e) { 187 | return errorMessage("Issue or repository not found: " + e.getMessage()); 188 | } catch (IOException e) { 189 | return errorMessage("IO error: " + e.getMessage()); 190 | } catch (Exception e) { 191 | return errorMessage("Unexpected error: " + e.getMessage()); 192 | } 193 | } 194 | 195 | @Tool(description = """ 196 | Create a new issue in a repository. 197 | Creates an issue with the specified title, body, and optional labels. 198 | """) 199 | public String createIssue( 200 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 201 | @ToolParam(description = "Issue title") String title, 202 | @ToolParam(description = "Issue body/description", required = false) String body, 203 | @ToolParam(description = "Comma-separated list of labels", required = false) String labels 204 | ) { 205 | Map result = new HashMap<>(); 206 | 207 | try { 208 | if (title == null || title.isEmpty()) { 209 | return errorMessage("Issue title is required"); 210 | } 211 | 212 | Optional env = GitHubClientFactory.getEnvironment(); 213 | if (env.isEmpty()) { 214 | return errorMessage("GitHub is not configured correctly"); 215 | } 216 | 217 | GitHubEnv githubEnv = env.get(); 218 | GitHub github = GitHubClientFactory.createClient(githubEnv); 219 | 220 | // Use provided repository or default from environment 221 | String repoName = (repository != null && !repository.isEmpty()) ? 222 | repository : githubEnv.getFullRepository(); 223 | 224 | if (repoName == null || repoName.isEmpty()) { 225 | return errorMessage("Repository name is required"); 226 | } 227 | 228 | GHRepository repo = github.getRepository(repoName); 229 | 230 | GHIssueBuilder issueBuilder = repo.createIssue(title); 231 | 232 | if (body != null && !body.isEmpty()) { 233 | issueBuilder.body(body); 234 | } 235 | 236 | if (labels != null && !labels.isEmpty()) { 237 | String[] labelArray = labels.split(","); 238 | for (String label : labelArray) { 239 | issueBuilder.label(label.trim()); 240 | } 241 | } 242 | 243 | GHIssue issue = issueBuilder.create(); 244 | 245 | Map issueData = new HashMap<>(); 246 | issueData.put("number", issue.getNumber()); 247 | issueData.put("title", issue.getTitle()); 248 | issueData.put("body", issue.getBody()); 249 | issueData.put("html_url", issue.getHtmlUrl().toString()); 250 | 251 | result.put("issue", issueData); 252 | 253 | return successMessage(result); 254 | 255 | } catch (GHFileNotFoundException e) { 256 | return errorMessage("Repository not found: " + e.getMessage()); 257 | } catch (IOException e) { 258 | return errorMessage("IO error: " + e.getMessage()); 259 | } catch (Exception e) { 260 | return errorMessage("Unexpected error: " + e.getMessage()); 261 | } 262 | } 263 | 264 | @Tool(description = """ 265 | Add a comment to an issue. 266 | Posts a new comment on the specified issue. 267 | """) 268 | public String addIssueComment( 269 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 270 | @ToolParam(description = "Issue number") Integer issueNumber, 271 | @ToolParam(description = "Comment text") String body 272 | ) { 273 | Map result = new HashMap<>(); 274 | 275 | try { 276 | if (issueNumber == null) { 277 | return errorMessage("Issue number is required"); 278 | } 279 | 280 | if (body == null || body.isEmpty()) { 281 | return errorMessage("Comment body is required"); 282 | } 283 | 284 | Optional env = GitHubClientFactory.getEnvironment(); 285 | if (env.isEmpty()) { 286 | return errorMessage("GitHub is not configured correctly"); 287 | } 288 | 289 | GitHubEnv githubEnv = env.get(); 290 | GitHub github = GitHubClientFactory.createClient(githubEnv); 291 | 292 | // Use provided repository or default from environment 293 | String repoName = (repository != null && !repository.isEmpty()) ? 294 | repository : githubEnv.getFullRepository(); 295 | 296 | if (repoName == null || repoName.isEmpty()) { 297 | return errorMessage("Repository name is required"); 298 | } 299 | 300 | GHRepository repo = github.getRepository(repoName); 301 | GHIssue issue = repo.getIssue(issueNumber); 302 | 303 | GHIssueComment comment = issue.comment(body); 304 | 305 | Map commentData = new HashMap<>(); 306 | commentData.put("id", comment.getId()); 307 | commentData.put("body", comment.getBody()); 308 | commentData.put("html_url", comment.getHtmlUrl().toString()); 309 | commentData.put("created_at", comment.getCreatedAt().toString()); 310 | 311 | result.put("comment", commentData); 312 | 313 | return successMessage(result); 314 | 315 | } catch (GHFileNotFoundException e) { 316 | return errorMessage("Issue or repository not found: " + e.getMessage()); 317 | } catch (IOException e) { 318 | return errorMessage("IO error: " + e.getMessage()); 319 | } catch (Exception e) { 320 | return errorMessage("Unexpected error: " + e.getMessage()); 321 | } 322 | } 323 | 324 | @Tool(description = """ 325 | Search for issues. 326 | Searches for issues matching the query across GitHub or in a specific repository. 327 | """) 328 | public String searchIssues( 329 | @ToolParam(description = "Search query") String query, 330 | @ToolParam(description = "Repository name in format 'owner/repo' to limit search", required = false) String repository, 331 | @ToolParam(description = "State of issues to search for (open, closed)", required = false) String state, 332 | @ToolParam(description = "Maximum number of results to return", required = false) Integer limit 333 | ) { 334 | Map result = new HashMap<>(); 335 | 336 | try { 337 | if (query == null || query.isEmpty()) { 338 | return errorMessage("Search query is required"); 339 | } 340 | 341 | Optional env = GitHubClientFactory.getEnvironment(); 342 | if (env.isEmpty()) { 343 | return errorMessage("GitHub is not configured correctly"); 344 | } 345 | 346 | GitHubEnv githubEnv = env.get(); 347 | GitHub github = GitHubClientFactory.createClient(githubEnv); 348 | 349 | // Build search query 350 | StringBuilder queryBuilder = new StringBuilder(query); 351 | 352 | // Add repository filter if provided 353 | if (repository != null && !repository.isEmpty()) { 354 | queryBuilder.append(" repo:").append(repository); 355 | } 356 | 357 | // Add state filter if provided 358 | if (state != null && !state.isEmpty()) { 359 | queryBuilder.append(" is:").append(state); 360 | } 361 | 362 | int actualLimit = (limit != null && limit > 0) ? limit : 10; 363 | 364 | GHIssueSearchBuilder searchBuilder = github.searchIssues() 365 | .q(queryBuilder.toString()) 366 | .order(GHDirection.DESC) 367 | .sort(GHIssueSearchBuilder.Sort.CREATED); 368 | 369 | List> issueList = new ArrayList<>(); 370 | 371 | searchBuilder.list().withPageSize(actualLimit).iterator().forEachRemaining(issue -> { 372 | if (issueList.size() < actualLimit) { 373 | Map issueData = new HashMap<>(); 374 | issueData.put("number", issue.getNumber()); 375 | issueData.put("title", issue.getTitle()); 376 | issueData.put("state", issue.getState().name().toLowerCase()); 377 | issueData.put("repository", issue.getRepository().getFullName()); 378 | issueData.put("html_url", issue.getHtmlUrl().toString()); 379 | try { 380 | issueData.put("created_at", issue.getCreatedAt().toString()); 381 | } catch (IOException e) { 382 | throw new RuntimeException(e); 383 | } 384 | 385 | issueList.add(issueData); 386 | } 387 | }); 388 | 389 | result.put("issues", issueList); 390 | result.put("query", queryBuilder.toString()); 391 | 392 | return successMessage(result); 393 | 394 | } catch (IOException e) { 395 | return errorMessage("IO error: " + e.getMessage()); 396 | } catch (Exception e) { 397 | return errorMessage("Unexpected error: " + e.getMessage()); 398 | } 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/agentic/github/tools/PullRequestService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github.tools; 2 | 3 | import org.kohsuke.github.*; 4 | import org.springframework.ai.tool.annotation.Tool; 5 | import org.springframework.ai.tool.annotation.ToolParam; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.io.IOException; 9 | import java.util.*; 10 | 11 | /** 12 | * Service for GitHub pull request-related operations 13 | */ 14 | @Service 15 | public class PullRequestService extends AbstractToolService { 16 | 17 | @Tool(description = """ 18 | List pull requests for a repository. 19 | Returns pull requests with filtering options for state. 20 | """) 21 | public String listPullRequests( 22 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 23 | @ToolParam(description = "State of pull requests (open, closed, all)", required = false) String state, 24 | @ToolParam(description = "Maximum number of results to return", required = false) Integer limit 25 | ) { 26 | Map result = new HashMap<>(); 27 | 28 | try { 29 | Optional env = GitHubClientFactory.getEnvironment(); 30 | if (env.isEmpty()) { 31 | return errorMessage("GitHub is not configured correctly"); 32 | } 33 | 34 | GitHubEnv githubEnv = env.get(); 35 | GitHub github = GitHubClientFactory.createClient(githubEnv); 36 | 37 | // Use provided repository or default from environment 38 | String repoName = (repository != null && !repository.isEmpty()) ? 39 | repository : githubEnv.getFullRepository(); 40 | 41 | if (repoName == null || repoName.isEmpty()) { 42 | return errorMessage("Repository name is required"); 43 | } 44 | 45 | GHRepository repo = github.getRepository(repoName); 46 | 47 | GHIssueState prState = GHIssueState.OPEN; 48 | if (state != null) { 49 | prState = switch (state.toLowerCase()) { 50 | case "closed" -> GHIssueState.CLOSED; 51 | case "all" -> GHIssueState.ALL; 52 | default -> prState; 53 | }; 54 | } 55 | 56 | List pullRequests = repo.getPullRequests(prState); 57 | List> prList = new ArrayList<>(); 58 | int count = 0; 59 | 60 | for (GHPullRequest pr : pullRequests) { 61 | if (limit != null && count >= limit) { 62 | break; 63 | } 64 | 65 | Map prData = new HashMap<>(); 66 | prData.put("number", pr.getNumber()); 67 | prData.put("title", pr.getTitle()); 68 | prData.put("state", pr.getState().name().toLowerCase()); 69 | prData.put("html_url", pr.getHtmlUrl().toString()); 70 | prData.put("created_at", pr.getCreatedAt().toString()); 71 | prData.put("updated_at", pr.getUpdatedAt().toString()); 72 | prData.put("closed_at", pr.getClosedAt() != null ? pr.getClosedAt().toString() : null); 73 | prData.put("merged_at", pr.getMergedAt() != null ? pr.getMergedAt().toString() : null); 74 | prData.put("is_merged", pr.isMerged()); 75 | 76 | prData.put("user", pr.getUser().getLogin()); 77 | 78 | prData.put("base_branch", pr.getBase().getRef()); 79 | prData.put("head_branch", pr.getHead().getRef()); 80 | 81 | prList.add(prData); 82 | count++; 83 | } 84 | 85 | result.put("pull_requests", prList); 86 | result.put("total_count", pullRequests.size()); 87 | 88 | return successMessage(result); 89 | 90 | } catch (GHFileNotFoundException e) { 91 | return errorMessage("Repository not found: " + e.getMessage()); 92 | } catch (IOException e) { 93 | return errorMessage("IO error: " + e.getMessage()); 94 | } catch (Exception e) { 95 | return errorMessage("Unexpected error: " + e.getMessage()); 96 | } 97 | } 98 | 99 | @Tool(description = """ 100 | Get a specific pull request. 101 | Returns detailed information about the pull request. 102 | """) 103 | public String getPullRequest( 104 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 105 | @ToolParam(description = "Pull request number") Integer prNumber 106 | ) { 107 | Map result = new HashMap<>(); 108 | 109 | try { 110 | if (prNumber == null) { 111 | return errorMessage("Pull request number is required"); 112 | } 113 | 114 | Optional env = GitHubClientFactory.getEnvironment(); 115 | if (env.isEmpty()) { 116 | return errorMessage("GitHub is not configured correctly"); 117 | } 118 | 119 | GitHubEnv githubEnv = env.get(); 120 | GitHub github = GitHubClientFactory.createClient(githubEnv); 121 | 122 | // Use provided repository or default from environment 123 | String repoName = (repository != null && !repository.isEmpty()) ? 124 | repository : githubEnv.getFullRepository(); 125 | 126 | if (repoName == null || repoName.isEmpty()) { 127 | return errorMessage("Repository name is required"); 128 | } 129 | 130 | GHRepository repo = github.getRepository(repoName); 131 | GHPullRequest pr = repo.getPullRequest(prNumber); 132 | 133 | Map prData = new HashMap<>(); 134 | prData.put("number", pr.getNumber()); 135 | prData.put("title", pr.getTitle()); 136 | prData.put("body", pr.getBody()); 137 | prData.put("state", pr.getState().name().toLowerCase()); 138 | prData.put("html_url", pr.getHtmlUrl().toString()); 139 | prData.put("created_at", pr.getCreatedAt().toString()); 140 | prData.put("updated_at", pr.getUpdatedAt().toString()); 141 | prData.put("closed_at", pr.getClosedAt() != null ? pr.getClosedAt().toString() : null); 142 | prData.put("merged_at", pr.getMergedAt() != null ? pr.getMergedAt().toString() : null); 143 | prData.put("is_merged", pr.isMerged()); 144 | 145 | prData.put("user", pr.getUser().getLogin()); 146 | 147 | prData.put("base_branch", pr.getBase().getRef()); 148 | prData.put("head_branch", pr.getHead().getRef()); 149 | 150 | // Get comments 151 | List> commentsList = new ArrayList<>(); 152 | for (GHIssueComment comment : pr.getComments()) { 153 | Map commentData = new HashMap<>(); 154 | commentData.put("id", comment.getId()); 155 | commentData.put("user", comment.getUser().getLogin()); 156 | commentData.put("body", comment.getBody()); 157 | commentData.put("created_at", comment.getCreatedAt().toString()); 158 | commentData.put("updated_at", comment.getUpdatedAt().toString()); 159 | 160 | commentsList.add(commentData); 161 | } 162 | 163 | prData.put("comments", commentsList); 164 | 165 | // Get files 166 | List> filesList = new ArrayList<>(); 167 | for (GHPullRequestFileDetail file : pr.listFiles()) { 168 | Map fileData = new HashMap<>(); 169 | fileData.put("filename", file.getFilename()); 170 | fileData.put("status", file.getStatus()); 171 | fileData.put("additions", file.getAdditions()); 172 | fileData.put("deletions", file.getDeletions()); 173 | fileData.put("changes", file.getChanges()); 174 | 175 | filesList.add(fileData); 176 | } 177 | 178 | prData.put("files", filesList); 179 | 180 | result.put("pull_request", prData); 181 | 182 | return successMessage(result); 183 | 184 | } catch (GHFileNotFoundException e) { 185 | return errorMessage("Pull request or repository not found: " + e.getMessage()); 186 | } catch (IOException e) { 187 | return errorMessage("IO error: " + e.getMessage()); 188 | } catch (Exception e) { 189 | return errorMessage("Unexpected error: " + e.getMessage()); 190 | } 191 | } 192 | 193 | @Tool(description = """ 194 | Create a comment on a pull request. 195 | Posts a new comment on the specified pull request. 196 | """) 197 | public String createPullRequestComment( 198 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 199 | @ToolParam(description = "Pull request number") Integer prNumber, 200 | @ToolParam(description = "Comment text") String body 201 | ) { 202 | Map result = new HashMap<>(); 203 | 204 | try { 205 | if (prNumber == null) { 206 | return errorMessage("Pull request number is required"); 207 | } 208 | 209 | if (body == null || body.isEmpty()) { 210 | return errorMessage("Comment body is required"); 211 | } 212 | 213 | Optional env = GitHubClientFactory.getEnvironment(); 214 | if (env.isEmpty()) { 215 | return errorMessage("GitHub is not configured correctly"); 216 | } 217 | 218 | GitHubEnv githubEnv = env.get(); 219 | GitHub github = GitHubClientFactory.createClient(githubEnv); 220 | 221 | // Use provided repository or default from environment 222 | String repoName = (repository != null && !repository.isEmpty()) ? 223 | repository : githubEnv.getFullRepository(); 224 | 225 | if (repoName == null || repoName.isEmpty()) { 226 | return errorMessage("Repository name is required"); 227 | } 228 | 229 | GHRepository repo = github.getRepository(repoName); 230 | GHPullRequest pr = repo.getPullRequest(prNumber); 231 | 232 | GHIssueComment comment = pr.comment(body); 233 | 234 | Map commentData = new HashMap<>(); 235 | commentData.put("id", comment.getId()); 236 | commentData.put("body", comment.getBody()); 237 | commentData.put("html_url", comment.getHtmlUrl().toString()); 238 | commentData.put("created_at", comment.getCreatedAt().toString()); 239 | 240 | result.put("comment", commentData); 241 | 242 | return successMessage(result); 243 | 244 | } catch (GHFileNotFoundException e) { 245 | return errorMessage("Pull request or repository not found: " + e.getMessage()); 246 | } catch (IOException e) { 247 | return errorMessage("IO error: " + e.getMessage()); 248 | } catch (Exception e) { 249 | return errorMessage("Unexpected error: " + e.getMessage()); 250 | } 251 | } 252 | 253 | @Tool(description = """ 254 | Merge a pull request. 255 | Merges the pull request with the specified merge method. 256 | """) 257 | public String mergePullRequest( 258 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository, 259 | @ToolParam(description = "Pull request number") Integer prNumber, 260 | @ToolParam(description = "Commit message for the merge", required = false) String commitMessage, 261 | @ToolParam(description = "Merge method (merge, squash, rebase)", required = false) String mergeMethod 262 | ) { 263 | Map result = new HashMap<>(); 264 | 265 | try { 266 | if (prNumber == null) { 267 | return errorMessage("Pull request number is required"); 268 | } 269 | 270 | Optional env = GitHubClientFactory.getEnvironment(); 271 | if (env.isEmpty()) { 272 | return errorMessage("GitHub is not configured correctly"); 273 | } 274 | 275 | GitHubEnv githubEnv = env.get(); 276 | GitHub github = GitHubClientFactory.createClient(githubEnv); 277 | 278 | // Use provided repository or default from environment 279 | String repoName = (repository != null && !repository.isEmpty()) ? 280 | repository : githubEnv.getFullRepository(); 281 | 282 | if (repoName == null || repoName.isEmpty()) { 283 | return errorMessage("Repository name is required"); 284 | } 285 | 286 | GHRepository repo = github.getRepository(repoName); 287 | GHPullRequest pr = repo.getPullRequest(prNumber); 288 | 289 | // Check if PR is already merged 290 | if (pr.isMerged()) { 291 | return errorMessage("Pull request is already merged"); 292 | } 293 | 294 | // Set default merge method if not provided 295 | String method = (mergeMethod != null && !mergeMethod.isEmpty()) ? 296 | mergeMethod.toLowerCase() : "merge"; 297 | 298 | boolean success = switch (method) { 299 | case "squash" -> { 300 | pr.merge(commitMessage, null, GHPullRequest.MergeMethod.SQUASH); 301 | yield true; 302 | } 303 | case "rebase" -> { 304 | pr.merge(commitMessage, null, GHPullRequest.MergeMethod.REBASE); 305 | yield true; 306 | } 307 | case "merge" -> { 308 | pr.merge(commitMessage, null, GHPullRequest.MergeMethod.MERGE); 309 | yield true; 310 | } 311 | default -> false; 312 | }; 313 | 314 | if (success) { 315 | result.put("merged", true); 316 | result.put("method", method); 317 | result.put("pull_request_number", prNumber); 318 | result.put("repository", repoName); 319 | 320 | return successMessage(result); 321 | } else { 322 | return errorMessage("Failed to merge pull request"); 323 | } 324 | 325 | } catch (GHFileNotFoundException e) { 326 | return errorMessage("Pull request or repository not found: " + e.getMessage()); 327 | } catch (IOException e) { 328 | return errorMessage("IO error: " + e.getMessage()); 329 | } catch (Exception e) { 330 | return errorMessage("Unexpected error: " + e.getMessage()); 331 | } 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/agentic/github/tools/RepositoryService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github.tools; 2 | 3 | import org.kohsuke.github.*; 4 | import org.springframework.ai.tool.annotation.Tool; 5 | import org.springframework.ai.tool.annotation.ToolParam; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.io.IOException; 9 | import java.util.*; 10 | 11 | /** 12 | * Service for GitHub repository-related operations 13 | */ 14 | @Service 15 | public class RepositoryService extends AbstractToolService { 16 | 17 | @Tool(description = """ 18 | List repositories for the authenticated user. 19 | Returns a list of repositories the user has access to. 20 | """) 21 | public String listRepositories( 22 | @ToolParam(description = "Maximum number of repositories to return", required = false) Integer limit 23 | ) { 24 | Map result = new HashMap<>(); 25 | 26 | try { 27 | Optional env = GitHubClientFactory.getEnvironment(); 28 | if (env.isEmpty()) { 29 | return errorMessage("GitHub is not configured correctly"); 30 | } 31 | 32 | GitHubEnv githubEnv = env.get(); 33 | GitHub github = GitHubClientFactory.createClient(githubEnv); 34 | 35 | GHMyself myself = github.getMyself(); 36 | Map repos = myself.getAllRepositories(); 37 | 38 | List> repoList = new ArrayList<>(); 39 | int count = 0; 40 | 41 | for (GHRepository repo : repos.values()) { 42 | if (limit != null && count >= limit) { 43 | break; 44 | } 45 | 46 | Map repoData = new HashMap<>(); 47 | repoData.put("name", repo.getName()); 48 | repoData.put("full_name", repo.getFullName()); 49 | repoData.put("description", repo.getDescription()); 50 | repoData.put("url", repo.getHtmlUrl().toString()); 51 | repoData.put("stars", repo.getStargazersCount()); 52 | repoData.put("forks", repo.listForks().toList().size()); 53 | repoData.put("private", repo.isPrivate()); 54 | 55 | repoList.add(repoData); 56 | count++; 57 | } 58 | 59 | result.put("repositories", repoList); 60 | result.put("total_count", repos.size()); 61 | 62 | return successMessage(result); 63 | 64 | } catch (IOException e) { 65 | return errorMessage("IO error: " + e.getMessage()); 66 | } catch (Exception e) { 67 | return errorMessage("Unexpected error: " + e.getMessage()); 68 | } 69 | } 70 | 71 | @Tool(description = """ 72 | Get information about a specific repository. 73 | Returns details about the repository such as description, stars, forks, etc. 74 | """) 75 | public String getRepository( 76 | @ToolParam(description = "Repository name in format 'owner/repo'", required = false) String repository 77 | ) { 78 | Map result = new HashMap<>(); 79 | 80 | try { 81 | Optional env = GitHubClientFactory.getEnvironment(); 82 | if (env.isEmpty()) { 83 | return errorMessage("GitHub is not configured correctly"); 84 | } 85 | 86 | GitHubEnv githubEnv = env.get(); 87 | GitHub github = GitHubClientFactory.createClient(githubEnv); 88 | 89 | // Use provided repository or default from environment 90 | String repoName = (repository != null && !repository.isEmpty()) ? 91 | repository : githubEnv.getFullRepository(); 92 | 93 | if (repoName == null || repoName.isEmpty()) { 94 | return errorMessage("Repository name is required"); 95 | } 96 | 97 | GHRepository repo = github.getRepository(repoName); 98 | 99 | Map repoData = new HashMap<>(); 100 | repoData.put("name", repo.getName()); 101 | repoData.put("full_name", repo.getFullName()); 102 | repoData.put("description", repo.getDescription()); 103 | repoData.put("url", repo.getHtmlUrl().toString()); 104 | repoData.put("stars", repo.getStargazersCount()); 105 | repoData.put("forks", repo.listForks().toList().size()); 106 | repoData.put("open_issues", repo.getOpenIssueCount()); 107 | repoData.put("watchers", repo.getWatchersCount()); 108 | repoData.put("license", repo.getLicense() != null ? repo.getLicense().getName() : null); 109 | repoData.put("default_branch", repo.getDefaultBranch()); 110 | repoData.put("created_at", repo.getCreatedAt().toString()); 111 | repoData.put("updated_at", repo.getUpdatedAt().toString()); 112 | repoData.put("private", repo.isPrivate()); 113 | 114 | result.put("repository", repoData); 115 | 116 | return successMessage(result); 117 | 118 | } catch (GHFileNotFoundException e) { 119 | return errorMessage("Repository not found: " + e.getMessage()); 120 | } catch (IOException e) { 121 | return errorMessage("IO error: " + e.getMessage()); 122 | } catch (Exception e) { 123 | return errorMessage("Unexpected error: " + e.getMessage()); 124 | } 125 | } 126 | 127 | @Tool(description = """ 128 | Search for repositories. 129 | Searches GitHub for repositories matching the query. 130 | """) 131 | public String searchRepositories( 132 | @ToolParam(description = "Search query") String query, 133 | @ToolParam(description = "Maximum number of results to return", required = false) Integer limit 134 | ) { 135 | Map result = new HashMap<>(); 136 | 137 | try { 138 | if (query == null || query.isEmpty()) { 139 | return errorMessage("Search query is required"); 140 | } 141 | 142 | Optional env = GitHubClientFactory.getEnvironment(); 143 | if (env.isEmpty()) { 144 | return errorMessage("GitHub is not configured correctly"); 145 | } 146 | 147 | GitHubEnv githubEnv = env.get(); 148 | GitHub github = GitHubClientFactory.createClient(githubEnv); 149 | 150 | int actualLimit = (limit != null && limit > 0) ? limit : 10; 151 | 152 | GHRepositorySearchBuilder searchBuilder = github.searchRepositories() 153 | .q(query) 154 | .order(GHDirection.DESC) 155 | .sort(GHRepositorySearchBuilder.Sort.STARS); 156 | 157 | List> repoList = new ArrayList<>(); 158 | 159 | searchBuilder.list().withPageSize(actualLimit).iterator().forEachRemaining(repo -> { 160 | if (repoList.size() < actualLimit) { 161 | Map repoData = new HashMap<>(); 162 | repoData.put("name", repo.getName()); 163 | repoData.put("full_name", repo.getFullName()); 164 | repoData.put("description", repo.getDescription()); 165 | repoData.put("url", repo.getHtmlUrl().toString()); 166 | repoData.put("stars", repo.getStargazersCount()); 167 | try { 168 | repoData.put("forks", repo.listForks().toList().size()); 169 | } catch (IOException e) { 170 | throw new RuntimeException(e); 171 | } 172 | repoData.put("language", repo.getLanguage()); 173 | 174 | repoList.add(repoData); 175 | } 176 | }); 177 | 178 | result.put("repositories", repoList); 179 | result.put("query", query); 180 | 181 | return successMessage(result); 182 | 183 | } catch (IOException e) { 184 | return errorMessage("IO error: " + e.getMessage()); 185 | } catch (Exception e) { 186 | return errorMessage("Unexpected error: " + e.getMessage()); 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | # Application settings for GitHub MCP server 2 | # NOTE: You must disable the banner and the console logging 3 | # to allow the STDIO transport to work !!! 4 | spring.main.banner-mode=off 5 | logging.pattern.console= 6 | 7 | # MCP server configuration 8 | spring.ai.mcp.server.name=github-server 9 | spring.ai.mcp.server.version=0.0.1 10 | 11 | # spring.ai.mcp.server.stdio=true 12 | 13 | # Web server configuration (disable for STDIO mode) 14 | # spring.main.web-application-type=none 15 | 16 | # Logging configuration 17 | logging.file.name=./target/github-server.log 18 | -------------------------------------------------------------------------------- /src/main/resources/mcp-servers-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "devoxx-github": { 4 | "command": "java", 5 | "args": [ 6 | "-Dspring.ai.mcp.server.stdio=true", 7 | "-Dspring.main.web-application-type=none", 8 | "-Dlogging.pattern.console=", 9 | "-jar", 10 | "/Users/stephan/IdeaProjects/GitHubMCP/target/GitHubMCP-1.0-SNAPSHOT.jar" 11 | ], 12 | "env": { 13 | "GITHUB_TOKEN": "your personal access token", 14 | "GITHUB_HOST": "github.com", 15 | "GITHUB_REPOSITORY": "your-username/your-repository" 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/test/java/com/devoxx/agentic/github/ClientStdio.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github; 2 | 3 | import io.github.cdimascio.dotenv.Dotenv; 4 | import io.modelcontextprotocol.client.McpClient; 5 | import io.modelcontextprotocol.client.transport.ServerParameters; 6 | import io.modelcontextprotocol.client.transport.StdioClientTransport; 7 | import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; 8 | 9 | public class ClientStdio { 10 | 11 | public static void main(String[] args) { 12 | var stdioParams = ServerParameters.builder("java") 13 | .args("-Dspring.ai.mcp.server.stdio=true", 14 | "-Dspring.main.web-application-type=none", 15 | "-Dlogging.pattern.console=", 16 | "-jar", 17 | "/Users/stephan/IdeaProjects/GitHubMCP/target/GitHubMCP-1.0-SNAPSHOT.jar") 18 | .addEnvVar("GITHUB_TOKEN", Dotenv.load().get("GITHUB_TOKEN")) 19 | .addEnvVar("GITHUB_HOST", "github.com") 20 | .addEnvVar("GITHUB_REPOSITORY", "stephan-dowding/mcp-examples") 21 | .build(); 22 | 23 | var transport = new StdioClientTransport(stdioParams); 24 | var client = McpClient.sync(transport).build(); 25 | 26 | client.initialize(); 27 | 28 | // List and demonstrate tools 29 | ListToolsResult toolsList = client.listTools(); 30 | System.out.println("Available GitHub MCP Tools = " + toolsList); 31 | 32 | client.closeGracefully(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/java/com/devoxx/agentic/github/GitHubServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.agentic.github; 2 | 3 | import com.devoxx.agentic.github.tools.GitHubClientFactory; 4 | import com.devoxx.agentic.github.tools.GitHubEnv; 5 | import io.github.cdimascio.dotenv.Dotenv; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; 8 | import org.kohsuke.github.GitHub; 9 | import org.kohsuke.github.GHMyself; 10 | 11 | import java.io.IOException; 12 | import java.util.Optional; 13 | 14 | import static org.junit.jupiter.api.Assertions.*; 15 | 16 | @EnabledIfEnvironmentVariable(named = "GITHUB_TOKEN", matches = ".+") 17 | class GitHubServiceTest { 18 | 19 | @Test 20 | void testGitHubConnection() throws IOException { 21 | String githubToken = Dotenv.load().get("GITHUB_TOKEN"); 22 | String githubHost = Dotenv.load().get("GITHUB_HOST"); 23 | 24 | System.setProperty("GITHUB_TOKEN", githubToken); 25 | System.setProperty("GITHUB_HOST", githubHost); 26 | 27 | // Test environment setup 28 | Optional env = GitHubClientFactory.getEnvironment(); 29 | assertTrue(env.isPresent(), "GitHub environment configuration should be present"); 30 | 31 | GitHubEnv githubEnv = env.get(); 32 | assertNotNull(githubEnv.githubToken(), "GitHub token should not be null"); 33 | assertNotNull(githubEnv.githubHost(), "GitHub host should not be null"); 34 | 35 | // Test connection to GitHub API 36 | GitHub github = GitHubClientFactory.createClient(githubEnv); 37 | assertNotNull(github, "GitHub client should not be null"); 38 | 39 | // Test that we can get authenticated user 40 | GHMyself myself = github.getMyself(); 41 | assertNotNull(myself, "GitHub user should not be null"); 42 | assertNotNull(myself.getLogin(), "GitHub username should not be null"); 43 | 44 | System.out.println("Successfully connected to GitHub as: " + myself.getLogin()); 45 | } 46 | } 47 | --------------------------------------------------------------------------------