Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion internal/cli/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
28 changes: 28 additions & 0 deletions internal/cli/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
142 changes: 74 additions & 68 deletions internal/github/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ package github
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

"gopkg.in/yaml.v3"
)
Expand All @@ -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)
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function can return nil, nil when lastErr is nil, which is unreachable in practice since the loop always assigns to lastErr on error. However, if the actionFilenames slice were empty, this would be possible.

Consider simplifying the error handling by removing line 54 and only keeping the check on line 51-52, or adding a defensive check for an empty actionFilenames slice.

Suggested change
return nil, fmt.Errorf("action.yml or action.yaml not found for %s/%s@%s", owner, repo, version)

Copilot uses AI. Check for mistakes.
}

// ParseActionYAML parses YAML data into a map that can be JSON-encoded.
Expand Down Expand Up @@ -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)
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FetchReadme function always constructs the URL path as refs/heads/{ref}, which assumes the ref is a branch name. However, according to the function's documentation and the ParseRepoRef examples, the ref can also be a tag (e.g., "v5") or a commit SHA (e.g., "abc123def456").

When the ref is a tag or commit SHA, the URL should use refs/tags/{tag} or just {sha} respectively. Using refs/heads/ for these cases will result in 404 errors.

Consider updating the logic to handle different ref types appropriately, similar to how FetchActionYAML uses refs/tags/{version} for tags, or allow the caller to specify the URL path type.

Copilot uses AI. Check for mistakes.

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)
Comment on lines +138 to +142
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the issue in FetchActionYAML, this code can return nil, "" when lastErr is nil, which is unreachable in practice since the loop always assigns to lastErr on error. However, if the readmeNames slice were empty, this would be possible.

Consider simplifying the error handling by removing line 142 and only keeping the check on line 139-140, or adding a defensive check for an empty readmeNames slice.

Copilot uses AI. Check for mistakes.
}

// 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
}
129 changes: 129 additions & 0 deletions internal/github/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
Loading
Loading