Skip to content

Commit 40f392c

Browse files
committed
feat: add get_readme tool and refactor reference parsing
- Add new get_readme MCP tool to fetch README files from GitHub repositories - Support both README.md and readme.md filename variations - Add support for action.yaml extension (in addition to action.yml) - Refactor reference parsing into shared ref.go module - Unify ActionRef and RepoRef into single Ref struct with flexible parsing - Add comprehensive tests for reference parsing - Reuse FetchRawFile helper for both actions and README fetching Breaking changes: None - all existing functionality maintained Closes #N/A
1 parent 1da4f70 commit 40f392c

File tree

6 files changed

+512
-69
lines changed

6 files changed

+512
-69
lines changed

internal/cli/mcp/server.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,12 @@ func NewMCPServer(actionsService *github.ActionsService, logger *slog.Logger) *M
2727

2828
// RegisterTools registers all available tools with the MCP server.
2929
func (m *MCPServer) RegisterTools(server *mcp.Server) {
30-
// Register get_action_parameters tool with Sentry tracing
3130
mcp.AddTool(server, &mcp.Tool{
3231
Name: "get_action_parameters",
3332
Description: "Fetch and parse a GitHub Action's action.yml file. Returns the complete action.yml structure including inputs, outputs, runs configuration, and metadata.",
3433
}, WithSentryTracing("get_action_parameters", m.handleGetActionParameters))
34+
mcp.AddTool(server, &mcp.Tool{
35+
Name: "get_readme",
36+
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.",
37+
}, WithSentryTracing("get_readme", m.handleGetReadme))
3538
}

internal/cli/mcp/tools.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,31 @@ func (m *MCPServer) handleGetActionParameters(ctx context.Context, req *mcp.Call
5757
},
5858
}, params, nil
5959
}
60+
61+
// GetReadmeArgs defines the parameters for the get_readme tool.
62+
type GetReadmeArgs struct {
63+
RepoRef string `json:"repoRef" jsonschema:"GitHub repository reference (e.g., 'owner/repo@main' or 'owner/repo'). If no ref is provided, defaults to 'main'."`
64+
}
65+
66+
// handleGetReadme handles the get_readme tool call.
67+
func (m *MCPServer) handleGetReadme(ctx context.Context, req *mcp.CallToolRequest, args GetReadmeArgs) (*mcp.CallToolResult, any, error) {
68+
// Validate input
69+
if args.RepoRef == "" {
70+
return nil, nil, fmt.Errorf("repoRef is required")
71+
}
72+
73+
// Fetch README content
74+
content, err := m.actionsService.GetReadme(args.RepoRef)
75+
if err != nil {
76+
return nil, nil, fmt.Errorf("failed to get README: %w", err)
77+
}
78+
79+
// Return response with README content
80+
return &mcp.CallToolResult{
81+
Content: []mcp.Content{
82+
&mcp.TextContent{
83+
Text: content,
84+
},
85+
},
86+
}, map[string]string{"content": content}, nil
87+
}

internal/github/actions.go

Lines changed: 74 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ package github
33
import (
44
"encoding/json"
55
"fmt"
6-
"io"
76
"net/http"
8-
"strings"
97

108
"gopkg.in/yaml.v3"
119
)
@@ -22,84 +20,38 @@ func NewActionsService() *ActionsService {
2220
}
2321
}
2422

25-
// ActionRef represents a parsed GitHub Action reference.
26-
type ActionRef struct {
27-
Owner string
28-
Repo string
29-
Version string
30-
}
31-
3223
// ParseActionRef parses an action reference string like "owner/repo@version".
24+
// The version part is required for actions.
3325
// Examples:
3426
// - "actions/checkout@v5" -> {Owner: "actions", Repo: "checkout", Version: "v5"}
3527
// - "actions/setup-node@v4" -> {Owner: "actions", Repo: "setup-node", Version: "v4"}
36-
func ParseActionRef(ref string) (*ActionRef, error) {
37-
// Trim whitespace (including newlines, spaces, tabs)
38-
ref = strings.TrimSpace(ref)
39-
40-
if ref == "" {
41-
return nil, fmt.Errorf("action reference cannot be empty")
42-
}
43-
44-
// Split by @ to separate repo from version
45-
parts := strings.Split(ref, "@")
46-
if len(parts) != 2 {
47-
return nil, fmt.Errorf("invalid action reference format: expected 'owner/repo@version', got '%s'", ref)
48-
}
49-
50-
repoPath := parts[0]
51-
version := parts[1]
52-
53-
// Split repo path by / to get owner and repo
54-
repoParts := strings.Split(repoPath, "/")
55-
if len(repoParts) != 2 {
56-
return nil, fmt.Errorf("invalid repository path: expected 'owner/repo', got '%s'", repoPath)
57-
}
58-
59-
owner := repoParts[0]
60-
repo := repoParts[1]
61-
62-
if owner == "" || repo == "" || version == "" {
63-
return nil, fmt.Errorf("owner, repo, and version must all be non-empty")
64-
}
65-
66-
return &ActionRef{
67-
Owner: owner,
68-
Repo: repo,
69-
Version: version,
70-
}, nil
28+
func ParseActionRef(ref string) (*Ref, error) {
29+
return ParseRef(ref, true, "")
7130
}
7231

73-
// FetchActionYAML fetches the action.yml file from GitHub's raw content CDN.
74-
// It constructs the URL in the format:
32+
// FetchActionYAML fetches the action.yml or action.yaml file from GitHub's raw content CDN.
33+
// It tries both common action file names in order of preference.
34+
// It constructs the URL using tags format:
7535
// https://raw.githubusercontent.com/{owner}/{repo}/refs/tags/{version}/action.yml
7636
func (s *ActionsService) FetchActionYAML(owner, repo, version string) ([]byte, error) {
77-
// Construct URL to raw action.yml on GitHub
78-
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/refs/tags/%s/action.yml",
79-
owner, repo, version)
80-
81-
// Make HTTP GET request
82-
resp, err := s.httpClient.Get(url)
83-
if err != nil {
84-
return nil, fmt.Errorf("failed to fetch action.yml: %w", err)
85-
}
86-
defer resp.Body.Close()
87-
88-
// Check for HTTP errors
89-
if resp.StatusCode != http.StatusOK {
90-
if resp.StatusCode == http.StatusNotFound {
91-
return nil, fmt.Errorf("action.yml not found at %s (status: 404) - verify the action reference and version", url)
37+
// Try common action filenames in order of preference
38+
actionFilenames := []string{"action.yml", "action.yaml"}
39+
urlPath := fmt.Sprintf("refs/tags/%s", version)
40+
41+
var lastErr error
42+
for _, filename := range actionFilenames {
43+
data, err := s.FetchRawFile(owner, repo, urlPath, filename)
44+
if err == nil {
45+
return data, nil
9246
}
93-
return nil, fmt.Errorf("failed to fetch action.yml from %s (status: %d)", url, resp.StatusCode)
47+
lastErr = err
9448
}
9549

96-
// Read response body
97-
data, err := io.ReadAll(resp.Body)
98-
if err != nil {
99-
return nil, fmt.Errorf("failed to read action.yml response: %w", err)
50+
// If we get here, none of the action files were found
51+
if lastErr != nil {
52+
return nil, fmt.Errorf("action.yml or action.yaml not found for %s/%s@%s: %w", owner, repo, version, lastErr)
10053
}
101-
102-
return data, nil
54+
return nil, fmt.Errorf("action.yml or action.yaml not found for %s/%s@%s", owner, repo, version)
10355
}
10456

10557
// ParseActionYAML parses YAML data into a map that can be JSON-encoded.
@@ -154,3 +106,57 @@ func (s *ActionsService) GetActionParametersJSON(actionRef string) (string, erro
154106

155107
return string(jsonData), nil
156108
}
109+
110+
// ParseRepoRef parses a repository reference string like "owner/repo@ref".
111+
// The ref can be a tag, branch name, or commit SHA.
112+
// If no ref is provided (e.g., "owner/repo"), it defaults to "main".
113+
// Examples:
114+
// - "actions/checkout@v5" -> {Owner: "actions", Repo: "checkout", Version: "v5"}
115+
// - "owner/repo@main" -> {Owner: "owner", Repo: "repo", Version: "main"}
116+
// - "owner/repo" -> {Owner: "owner", Repo: "repo", Version: "main"}
117+
func ParseRepoRef(ref string) (*Ref, error) {
118+
return ParseRef(ref, false, "main")
119+
}
120+
121+
// FetchReadme fetches the README.md file from GitHub's raw content CDN.
122+
// It tries multiple common README filenames in order of preference.
123+
// The ref can be a branch name, tag, or commit SHA.
124+
func (s *ActionsService) FetchReadme(owner, repo, ref string) (string, error) {
125+
// Try common README filenames in order of preference
126+
readmeNames := []string{"README.md", "readme.md", "Readme.md", "README", "readme"}
127+
urlPath := fmt.Sprintf("refs/heads/%s", ref)
128+
129+
var lastErr error
130+
for _, filename := range readmeNames {
131+
data, err := s.FetchRawFile(owner, repo, urlPath, filename)
132+
if err == nil {
133+
return string(data), nil
134+
}
135+
lastErr = err
136+
}
137+
138+
// If we get here, none of the README files were found
139+
if lastErr != nil {
140+
return "", fmt.Errorf("README not found in repository %s/%s@%s: %w", owner, repo, ref, lastErr)
141+
}
142+
return "", fmt.Errorf("README not found in repository %s/%s@%s", owner, repo, ref)
143+
}
144+
145+
// GetReadme fetches a README.md file from a GitHub repository.
146+
// It takes a repository reference (e.g., "owner/repo@main" or "owner/repo") and returns
147+
// the README content as a string. If no ref is provided, it defaults to "main".
148+
func (s *ActionsService) GetReadme(repoRef string) (string, error) {
149+
// Parse the repository reference
150+
ref, err := ParseRepoRef(repoRef)
151+
if err != nil {
152+
return "", fmt.Errorf("invalid repository reference: %w", err)
153+
}
154+
155+
// Fetch the README file
156+
content, err := s.FetchReadme(ref.Owner, ref.Repo, ref.Version)
157+
if err != nil {
158+
return "", err
159+
}
160+
161+
return content, nil
162+
}

internal/github/actions_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,132 @@ func TestParseActionRef(t *testing.T) {
108108
})
109109
}
110110
}
111+
112+
func TestParseRepoRef(t *testing.T) {
113+
tests := []struct {
114+
name string
115+
input string
116+
wantOwner string
117+
wantRepo string
118+
wantVersion string
119+
wantErr bool
120+
}{
121+
{
122+
name: "valid repo reference with tag",
123+
input: "actions/checkout@v5",
124+
wantOwner: "actions",
125+
wantRepo: "checkout",
126+
wantVersion: "v5",
127+
wantErr: false,
128+
},
129+
{
130+
name: "valid repo reference with branch",
131+
input: "owner/repo@main",
132+
wantOwner: "owner",
133+
wantRepo: "repo",
134+
wantVersion: "main",
135+
wantErr: false,
136+
},
137+
{
138+
name: "valid repo reference with commit SHA",
139+
input: "owner/repo@abc123def456",
140+
wantOwner: "owner",
141+
wantRepo: "repo",
142+
wantVersion: "abc123def456",
143+
wantErr: false,
144+
},
145+
{
146+
name: "repo without ref defaults to main",
147+
input: "owner/repo",
148+
wantOwner: "owner",
149+
wantRepo: "repo",
150+
wantVersion: "main",
151+
wantErr: false,
152+
},
153+
{
154+
name: "valid repo with trailing newline",
155+
input: "owner/repo@main\n",
156+
wantOwner: "owner",
157+
wantRepo: "repo",
158+
wantVersion: "main",
159+
wantErr: false,
160+
},
161+
{
162+
name: "valid repo with whitespace",
163+
input: " owner/repo@develop ",
164+
wantOwner: "owner",
165+
wantRepo: "repo",
166+
wantVersion: "develop",
167+
wantErr: false,
168+
},
169+
{
170+
name: "repo without ref and whitespace",
171+
input: " owner/repo\n",
172+
wantOwner: "owner",
173+
wantRepo: "repo",
174+
wantVersion: "main",
175+
wantErr: false,
176+
},
177+
{
178+
name: "complex repo name with hyphen",
179+
input: "techprimate/github-actions-utils-cli@main",
180+
wantOwner: "techprimate",
181+
wantRepo: "github-actions-utils-cli",
182+
wantVersion: "main",
183+
wantErr: false,
184+
},
185+
{
186+
name: "empty string",
187+
input: "",
188+
wantErr: true,
189+
},
190+
{
191+
name: "only whitespace",
192+
input: " \n\t ",
193+
wantErr: true,
194+
},
195+
{
196+
name: "missing repo",
197+
input: "owner@main",
198+
wantErr: true,
199+
},
200+
{
201+
name: "invalid format - too many slashes",
202+
input: "owner/group/repo@main",
203+
wantErr: true,
204+
},
205+
{
206+
name: "invalid format - multiple @ symbols",
207+
input: "owner/repo@main@extra",
208+
wantErr: true,
209+
},
210+
}
211+
212+
for _, tt := range tests {
213+
t.Run(tt.name, func(t *testing.T) {
214+
got, err := ParseRepoRef(tt.input)
215+
216+
if tt.wantErr {
217+
if err == nil {
218+
t.Errorf("ParseRepoRef() expected error but got none")
219+
}
220+
return
221+
}
222+
223+
if err != nil {
224+
t.Errorf("ParseRepoRef() unexpected error: %v", err)
225+
return
226+
}
227+
228+
if got.Owner != tt.wantOwner {
229+
t.Errorf("ParseRepoRef() Owner = %v, want %v", got.Owner, tt.wantOwner)
230+
}
231+
if got.Repo != tt.wantRepo {
232+
t.Errorf("ParseRepoRef() Repo = %v, want %v", got.Repo, tt.wantRepo)
233+
}
234+
if got.Version != tt.wantVersion {
235+
t.Errorf("ParseRepoRef() Version = %v, want %v", got.Version, tt.wantVersion)
236+
}
237+
})
238+
}
239+
}

0 commit comments

Comments
 (0)