diff --git a/internal/cli/mcp/server.go b/internal/cli/mcp/server.go index c1ed0e4..97b9815 100644 --- a/internal/cli/mcp/server.go +++ b/internal/cli/mcp/server.go @@ -27,9 +27,12 @@ func NewMCPServer(actionsService *github.ActionsService, logger *slog.Logger) *M // RegisterTools registers all available tools with the MCP server. func (m *MCPServer) RegisterTools(server *mcp.Server) { - // Register get_action_parameters tool with Sentry tracing mcp.AddTool(server, &mcp.Tool{ Name: "get_action_parameters", Description: "Fetch and parse a GitHub Action's action.yml file. Returns the complete action.yml structure including inputs, outputs, runs configuration, and metadata.", }, WithSentryTracing("get_action_parameters", m.handleGetActionParameters)) + mcp.AddTool(server, &mcp.Tool{ + Name: "get_readme", + Description: "Fetch the README.md file from a GitHub repository. Takes a repository reference (e.g., 'owner/repo@main' or 'owner/repo'). If no ref is provided, defaults to 'main' branch.", + }, WithSentryTracing("get_readme", m.handleGetReadme)) } diff --git a/internal/cli/mcp/tools.go b/internal/cli/mcp/tools.go index 5979466..121e1ba 100644 --- a/internal/cli/mcp/tools.go +++ b/internal/cli/mcp/tools.go @@ -57,3 +57,31 @@ func (m *MCPServer) handleGetActionParameters(ctx context.Context, req *mcp.Call }, }, params, nil } + +// GetReadmeArgs defines the parameters for the get_readme tool. +type GetReadmeArgs struct { + RepoRef string `json:"repoRef" jsonschema:"GitHub repository reference (e.g., 'owner/repo@main' or 'owner/repo'). If no ref is provided, defaults to 'main'."` +} + +// handleGetReadme handles the get_readme tool call. +func (m *MCPServer) handleGetReadme(ctx context.Context, req *mcp.CallToolRequest, args GetReadmeArgs) (*mcp.CallToolResult, any, error) { + // Validate input + if args.RepoRef == "" { + return nil, nil, fmt.Errorf("repoRef is required") + } + + // Fetch README content + content, err := m.actionsService.GetReadme(args.RepoRef) + if err != nil { + return nil, nil, fmt.Errorf("failed to get README: %w", err) + } + + // Return response with README content + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: content, + }, + }, + }, map[string]string{"content": content}, nil +} diff --git a/internal/github/actions.go b/internal/github/actions.go index a22e7da..5072522 100644 --- a/internal/github/actions.go +++ b/internal/github/actions.go @@ -3,9 +3,7 @@ package github import ( "encoding/json" "fmt" - "io" "net/http" - "strings" "gopkg.in/yaml.v3" ) @@ -22,84 +20,38 @@ func NewActionsService() *ActionsService { } } -// ActionRef represents a parsed GitHub Action reference. -type ActionRef struct { - Owner string - Repo string - Version string -} - // ParseActionRef parses an action reference string like "owner/repo@version". +// The version part is required for actions. // Examples: // - "actions/checkout@v5" -> {Owner: "actions", Repo: "checkout", Version: "v5"} // - "actions/setup-node@v4" -> {Owner: "actions", Repo: "setup-node", Version: "v4"} -func ParseActionRef(ref string) (*ActionRef, error) { - // Trim whitespace (including newlines, spaces, tabs) - ref = strings.TrimSpace(ref) - - if ref == "" { - return nil, fmt.Errorf("action reference cannot be empty") - } - - // Split by @ to separate repo from version - parts := strings.Split(ref, "@") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid action reference format: expected 'owner/repo@version', got '%s'", ref) - } - - repoPath := parts[0] - version := parts[1] - - // Split repo path by / to get owner and repo - repoParts := strings.Split(repoPath, "/") - if len(repoParts) != 2 { - return nil, fmt.Errorf("invalid repository path: expected 'owner/repo', got '%s'", repoPath) - } - - owner := repoParts[0] - repo := repoParts[1] - - if owner == "" || repo == "" || version == "" { - return nil, fmt.Errorf("owner, repo, and version must all be non-empty") - } - - return &ActionRef{ - Owner: owner, - Repo: repo, - Version: version, - }, nil +func ParseActionRef(ref string) (*Ref, error) { + return ParseRef(ref, true, "") } -// FetchActionYAML fetches the action.yml file from GitHub's raw content CDN. -// It constructs the URL in the format: +// FetchActionYAML fetches the action.yml or action.yaml file from GitHub's raw content CDN. +// It tries both common action file names in order of preference. +// It constructs the URL using tags format: // https://raw.githubusercontent.com/{owner}/{repo}/refs/tags/{version}/action.yml func (s *ActionsService) FetchActionYAML(owner, repo, version string) ([]byte, error) { - // Construct URL to raw action.yml on GitHub - url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/refs/tags/%s/action.yml", - owner, repo, version) - - // Make HTTP GET request - resp, err := s.httpClient.Get(url) - if err != nil { - return nil, fmt.Errorf("failed to fetch action.yml: %w", err) - } - defer resp.Body.Close() - - // Check for HTTP errors - if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("action.yml not found at %s (status: 404) - verify the action reference and version", url) + // Try common action filenames in order of preference + actionFilenames := []string{"action.yml", "action.yaml"} + urlPath := fmt.Sprintf("refs/tags/%s", version) + + var lastErr error + for _, filename := range actionFilenames { + data, err := s.FetchRawFile(owner, repo, urlPath, filename) + if err == nil { + return data, nil } - return nil, fmt.Errorf("failed to fetch action.yml from %s (status: %d)", url, resp.StatusCode) + lastErr = err } - // Read response body - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read action.yml response: %w", err) + // If we get here, none of the action files were found + if lastErr != nil { + return nil, fmt.Errorf("action.yml or action.yaml not found for %s/%s@%s: %w", owner, repo, version, lastErr) } - - return data, nil + return nil, fmt.Errorf("action.yml or action.yaml not found for %s/%s@%s", owner, repo, version) } // ParseActionYAML parses YAML data into a map that can be JSON-encoded. @@ -154,3 +106,57 @@ func (s *ActionsService) GetActionParametersJSON(actionRef string) (string, erro return string(jsonData), nil } + +// ParseRepoRef parses a repository reference string like "owner/repo@ref". +// The ref can be a tag, branch name, or commit SHA. +// If no ref is provided (e.g., "owner/repo"), it defaults to "main". +// Examples: +// - "actions/checkout@v5" -> {Owner: "actions", Repo: "checkout", Version: "v5"} +// - "owner/repo@main" -> {Owner: "owner", Repo: "repo", Version: "main"} +// - "owner/repo" -> {Owner: "owner", Repo: "repo", Version: "main"} +func ParseRepoRef(ref string) (*Ref, error) { + return ParseRef(ref, false, "main") +} + +// FetchReadme fetches the README.md file from GitHub's raw content CDN. +// It tries multiple common README filenames in order of preference. +// The ref can be a branch name, tag, or commit SHA. +func (s *ActionsService) FetchReadme(owner, repo, ref string) (string, error) { + // Try common README filenames in order of preference + readmeNames := []string{"README.md", "readme.md", "Readme.md", "README", "readme"} + urlPath := fmt.Sprintf("refs/heads/%s", ref) + + var lastErr error + for _, filename := range readmeNames { + data, err := s.FetchRawFile(owner, repo, urlPath, filename) + if err == nil { + return string(data), nil + } + lastErr = err + } + + // If we get here, none of the README files were found + if lastErr != nil { + return "", fmt.Errorf("README not found in repository %s/%s@%s: %w", owner, repo, ref, lastErr) + } + return "", fmt.Errorf("README not found in repository %s/%s@%s", owner, repo, ref) +} + +// GetReadme fetches a README.md file from a GitHub repository. +// It takes a repository reference (e.g., "owner/repo@main" or "owner/repo") and returns +// the README content as a string. If no ref is provided, it defaults to "main". +func (s *ActionsService) GetReadme(repoRef string) (string, error) { + // Parse the repository reference + ref, err := ParseRepoRef(repoRef) + if err != nil { + return "", fmt.Errorf("invalid repository reference: %w", err) + } + + // Fetch the README file + content, err := s.FetchReadme(ref.Owner, ref.Repo, ref.Version) + if err != nil { + return "", err + } + + return content, nil +} diff --git a/internal/github/actions_test.go b/internal/github/actions_test.go index b40cc64..223b9fd 100644 --- a/internal/github/actions_test.go +++ b/internal/github/actions_test.go @@ -108,3 +108,132 @@ func TestParseActionRef(t *testing.T) { }) } } + +func TestParseRepoRef(t *testing.T) { + tests := []struct { + name string + input string + wantOwner string + wantRepo string + wantVersion string + wantErr bool + }{ + { + name: "valid repo reference with tag", + input: "actions/checkout@v5", + wantOwner: "actions", + wantRepo: "checkout", + wantVersion: "v5", + wantErr: false, + }, + { + name: "valid repo reference with branch", + input: "owner/repo@main", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "main", + wantErr: false, + }, + { + name: "valid repo reference with commit SHA", + input: "owner/repo@abc123def456", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "abc123def456", + wantErr: false, + }, + { + name: "repo without ref defaults to main", + input: "owner/repo", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "main", + wantErr: false, + }, + { + name: "valid repo with trailing newline", + input: "owner/repo@main\n", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "main", + wantErr: false, + }, + { + name: "valid repo with whitespace", + input: " owner/repo@develop ", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "develop", + wantErr: false, + }, + { + name: "repo without ref and whitespace", + input: " owner/repo\n", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "main", + wantErr: false, + }, + { + name: "complex repo name with hyphen", + input: "techprimate/github-actions-utils-cli@main", + wantOwner: "techprimate", + wantRepo: "github-actions-utils-cli", + wantVersion: "main", + wantErr: false, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "only whitespace", + input: " \n\t ", + wantErr: true, + }, + { + name: "missing repo", + input: "owner@main", + wantErr: true, + }, + { + name: "invalid format - too many slashes", + input: "owner/group/repo@main", + wantErr: true, + }, + { + name: "invalid format - multiple @ symbols", + input: "owner/repo@main@extra", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseRepoRef(tt.input) + + if tt.wantErr { + if err == nil { + t.Errorf("ParseRepoRef() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("ParseRepoRef() unexpected error: %v", err) + return + } + + if got.Owner != tt.wantOwner { + t.Errorf("ParseRepoRef() Owner = %v, want %v", got.Owner, tt.wantOwner) + } + if got.Repo != tt.wantRepo { + t.Errorf("ParseRepoRef() Repo = %v, want %v", got.Repo, tt.wantRepo) + } + if got.Version != tt.wantVersion { + t.Errorf("ParseRepoRef() Version = %v, want %v", got.Version, tt.wantVersion) + } + }) + } +} diff --git a/internal/github/ref.go b/internal/github/ref.go new file mode 100644 index 0000000..9f2bc74 --- /dev/null +++ b/internal/github/ref.go @@ -0,0 +1,108 @@ +package github + +import ( + "fmt" + "io" + "strings" +) + +// Ref represents a parsed GitHub reference (repository or action). +type Ref struct { + Owner string + Repo string + Version string // Can be a tag, branch, commit SHA, or version +} + +// ParseRef parses a GitHub reference string like "owner/repo@version". +// If requireVersion is true, the @version part is mandatory. +// If requireVersion is false and no @version is provided, defaultVersion is used. +// +// Examples: +// - "actions/checkout@v5" -> {Owner: "actions", Repo: "checkout", Version: "v5"} +// - "owner/repo@main" -> {Owner: "owner", Repo: "repo", Version: "main"} +// - "owner/repo" with defaultVersion="main" -> {Owner: "owner", Repo: "repo", Version: "main"} +func ParseRef(ref string, requireVersion bool, defaultVersion string) (*Ref, error) { + // Trim whitespace (including newlines, spaces, tabs) + ref = strings.TrimSpace(ref) + + if ref == "" { + return nil, fmt.Errorf("reference cannot be empty") + } + + var repoPath, version string + + // Split by @ to separate repo from version + if strings.Contains(ref, "@") { + parts := strings.Split(ref, "@") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid reference format: expected 'owner/repo@version' or 'owner/repo', got '%s'", ref) + } + repoPath = parts[0] + version = parts[1] + } else { + // No @ found + if requireVersion { + return nil, fmt.Errorf("invalid reference format: expected 'owner/repo@version', got '%s'", ref) + } + repoPath = ref + version = defaultVersion + } + + // Split repo path by / to get owner and repo + repoParts := strings.Split(repoPath, "/") + if len(repoParts) != 2 { + return nil, fmt.Errorf("invalid repository path: expected 'owner/repo', got '%s'", repoPath) + } + + owner := repoParts[0] + repo := repoParts[1] + + if owner == "" || repo == "" || version == "" { + return nil, fmt.Errorf("owner, repo, and version must all be non-empty") + } + + return &Ref{ + Owner: owner, + Repo: repo, + Version: version, + }, nil +} + +// FetchRawFile fetches a file from GitHub's raw content CDN. +// The urlPath should specify the path type and version: +// - For tags: "refs/tags/{version}" +// - For branches: "refs/heads/{branch}" +// - For commits: "{sha}" +func (s *ActionsService) FetchRawFile(owner, repo, urlPath, filename string) ([]byte, error) { + // Construct URL to raw file on GitHub + url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", + owner, repo, urlPath, filename) + + // Make HTTP GET request + resp, err := s.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s: %w", filename, err) + } + defer resp.Body.Close() + + // Check for HTTP errors + if resp.StatusCode != 200 { + if resp.StatusCode == 404 { + return nil, fmt.Errorf("%s not found at %s (status: 404)", filename, url) + } + return nil, fmt.Errorf("failed to fetch %s from %s (status: %d)", filename, url, resp.StatusCode) + } + + // Read response body + data, err := readAllBody(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read %s response: %w", filename, err) + } + + return data, nil +} + +// readAllBody is a helper to read all data from an io.Reader. +func readAllBody(r io.Reader) ([]byte, error) { + return io.ReadAll(r) +} diff --git a/internal/github/ref_test.go b/internal/github/ref_test.go new file mode 100644 index 0000000..50ac3ea --- /dev/null +++ b/internal/github/ref_test.go @@ -0,0 +1,169 @@ +package github + +import ( + "testing" +) + +func TestParseRef(t *testing.T) { + tests := []struct { + name string + input string + requireVersion bool + defaultVersion string + wantOwner string + wantRepo string + wantVersion string + wantErr bool + }{ + { + name: "valid reference with version", + input: "actions/checkout@v5", + requireVersion: false, + defaultVersion: "main", + wantOwner: "actions", + wantRepo: "checkout", + wantVersion: "v5", + wantErr: false, + }, + { + name: "valid reference without version, defaults to provided", + input: "owner/repo", + requireVersion: false, + defaultVersion: "main", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "main", + wantErr: false, + }, + { + name: "valid reference without version, defaults to develop", + input: "owner/repo", + requireVersion: false, + defaultVersion: "develop", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "develop", + wantErr: false, + }, + { + name: "valid reference with branch name", + input: "owner/repo@main", + requireVersion: false, + defaultVersion: "main", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "main", + wantErr: false, + }, + { + name: "valid reference with commit SHA", + input: "owner/repo@abc123def456", + requireVersion: false, + defaultVersion: "main", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "abc123def456", + wantErr: false, + }, + { + name: "reference with trailing newline", + input: "owner/repo@v1\n", + requireVersion: false, + defaultVersion: "main", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "v1", + wantErr: false, + }, + { + name: "reference with whitespace", + input: " owner/repo@v2 ", + requireVersion: false, + defaultVersion: "main", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "v2", + wantErr: false, + }, + { + name: "complex repo name with hyphens", + input: "techprimate/github-actions-utils-cli@v1.0.0", + requireVersion: false, + defaultVersion: "main", + wantOwner: "techprimate", + wantRepo: "github-actions-utils-cli", + wantVersion: "v1.0.0", + wantErr: false, + }, + { + name: "empty string", + input: "", + requireVersion: false, + defaultVersion: "main", + wantErr: true, + }, + { + name: "only whitespace", + input: " \n\t ", + requireVersion: false, + defaultVersion: "main", + wantErr: true, + }, + { + name: "missing version when required", + input: "owner/repo", + requireVersion: true, + defaultVersion: "", + wantErr: true, + }, + { + name: "missing repo", + input: "owner@v1", + requireVersion: false, + defaultVersion: "main", + wantErr: true, + }, + { + name: "invalid format - too many slashes", + input: "owner/group/repo@v1", + requireVersion: false, + defaultVersion: "main", + wantErr: true, + }, + { + name: "invalid format - multiple @ symbols", + input: "owner/repo@v1@extra", + requireVersion: false, + defaultVersion: "main", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseRef(tt.input, tt.requireVersion, tt.defaultVersion) + + if tt.wantErr { + if err == nil { + t.Errorf("ParseRef() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("ParseRef() unexpected error: %v", err) + return + } + + if got.Owner != tt.wantOwner { + t.Errorf("ParseRef() Owner = %v, want %v", got.Owner, tt.wantOwner) + } + if got.Repo != tt.wantRepo { + t.Errorf("ParseRef() Repo = %v, want %v", got.Repo, tt.wantRepo) + } + if got.Version != tt.wantVersion { + t.Errorf("ParseRef() Version = %v, want %v", got.Version, tt.wantVersion) + } + }) + } +}