Skip to content

Commit 1d99d41

Browse files
committed
feat: add get_file_blame tool for retrieving git blame information
1 parent 3a4bc26 commit 1d99d41

3 files changed

Lines changed: 236 additions & 0 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,6 +1205,12 @@ The following sets of tools are available:
12051205
- `repo`: Repository name (string, required)
12061206
- `sha`: Commit SHA, branch name, or tag name (string, required)
12071207

1208+
- **get_file_blame** - Get file blame information
1209+
- `owner`: Repository owner (username or organization) (string, required)
1210+
- `path`: Path to the file in the repository (string, required)
1211+
- `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (string, optional)
1212+
- `repo`: Repository name (string, required)
1213+
12081214
- **get_file_contents** - Get file or directory contents
12091215
- **Required OAuth Scopes**: `repo`
12101216
- `owner`: Repository owner (username or organization) (string, required)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"annotations": {
3+
"readOnlyHint": true,
4+
"title": "Get file blame information"
5+
},
6+
"description": "Get git blame information for a file, showing who last modified each line",
7+
"inputSchema": {
8+
"type": "object",
9+
"required": [
10+
"owner",
11+
"repo",
12+
"path"
13+
],
14+
"properties": {
15+
"owner": {
16+
"type": "string",
17+
"description": "Repository owner (username or organization)"
18+
},
19+
"path": {
20+
"type": "string",
21+
"description": "Path to the file in the repository"
22+
},
23+
"ref": {
24+
"type": "string",
25+
"description": "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch"
26+
},
27+
"repo": {
28+
"type": "string",
29+
"description": "Repository name"
30+
}
31+
}
32+
},
33+
"name": "get_file_blame"
34+
}

pkg/github/repositories.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/google/go-github/v82/github"
2020
"github.com/google/jsonschema-go/jsonschema"
2121
"github.com/modelcontextprotocol/go-sdk/mcp"
22+
"github.com/shurcooL/githubv4"
2223
)
2324

2425
func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool {
@@ -2240,3 +2241,198 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool
22402241
},
22412242
)
22422243
}
2244+
2245+
func GetFileBlame(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) {
2246+
tool := mcp.Tool{
2247+
Name: "get_file_blame",
2248+
Description: t("TOOL_GET_FILE_BLAME_DESCRIPTION", "Get git blame information for a file, showing who last modified each line"),
2249+
Annotations: &mcp.ToolAnnotations{
2250+
Title: t("TOOL_GET_FILE_BLAME_USER_TITLE", "Get file blame information"),
2251+
ReadOnlyHint: true,
2252+
},
2253+
InputSchema: &jsonschema.Schema{
2254+
Type: "object",
2255+
Properties: map[string]*jsonschema.Schema{
2256+
"owner": {
2257+
Type: "string",
2258+
Description: "Repository owner (username or organization)",
2259+
},
2260+
"repo": {
2261+
Type: "string",
2262+
Description: "Repository name",
2263+
},
2264+
"path": {
2265+
Type: "string",
2266+
Description: "Path to the file in the repository",
2267+
},
2268+
"ref": {
2269+
Type: "string",
2270+
Description: "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch",
2271+
},
2272+
},
2273+
Required: []string{"owner", "repo", "path"},
2274+
},
2275+
}
2276+
2277+
handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
2278+
owner, err := RequiredParam[string](args, "owner")
2279+
if err != nil {
2280+
return utils.NewToolResultError(err.Error()), nil, nil
2281+
}
2282+
repo, err := RequiredParam[string](args, "repo")
2283+
if err != nil {
2284+
return utils.NewToolResultError(err.Error()), nil, nil
2285+
}
2286+
path, err := RequiredParam[string](args, "path")
2287+
if err != nil {
2288+
return utils.NewToolResultError(err.Error()), nil, nil
2289+
}
2290+
ref, err := OptionalParam[string](args, "ref")
2291+
if err != nil {
2292+
return utils.NewToolResultError(err.Error()), nil, nil
2293+
}
2294+
2295+
client, err := getGQLClient(ctx)
2296+
if err != nil {
2297+
return nil, nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err)
2298+
}
2299+
2300+
// First, get the default branch if ref is not specified
2301+
if ref == "" {
2302+
var repoQuery struct {
2303+
Repository struct {
2304+
DefaultBranchRef struct {
2305+
Name githubv4.String
2306+
}
2307+
} `graphql:"repository(owner: $owner, name: $repo)"`
2308+
}
2309+
2310+
vars := map[string]interface{}{
2311+
"owner": githubv4.String(owner),
2312+
"repo": githubv4.String(repo),
2313+
}
2314+
2315+
if err := client.Query(ctx, &repoQuery, vars); err != nil {
2316+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
2317+
"failed to get default branch",
2318+
err,
2319+
), nil, nil
2320+
}
2321+
2322+
// Validate that the repository has a default branch
2323+
if repoQuery.Repository.DefaultBranchRef.Name == "" {
2324+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
2325+
"repository has no default branch",
2326+
fmt.Errorf("repository %s/%s has no default branch or is empty", owner, repo),
2327+
), nil, nil
2328+
}
2329+
2330+
ref = string(repoQuery.Repository.DefaultBranchRef.Name)
2331+
}
2332+
// Now query the blame information
2333+
var blameQuery struct {
2334+
Repository struct {
2335+
Object struct {
2336+
Commit struct {
2337+
Blame struct {
2338+
Ranges []struct {
2339+
StartingLine githubv4.Int
2340+
EndingLine githubv4.Int
2341+
Age githubv4.Int
2342+
Commit struct {
2343+
OID githubv4.String
2344+
Message githubv4.String
2345+
CommittedDate githubv4.DateTime
2346+
Author struct {
2347+
Name githubv4.String
2348+
Email githubv4.String
2349+
User *struct {
2350+
Login githubv4.String
2351+
URL githubv4.String
2352+
}
2353+
}
2354+
}
2355+
}
2356+
} `graphql:"blame(path: $path)"`
2357+
} `graphql:"... on Commit"`
2358+
} `graphql:"object(expression: $ref)"`
2359+
} `graphql:"repository(owner: $owner, name: $repo)"`
2360+
}
2361+
2362+
vars := map[string]interface{}{
2363+
"owner": githubv4.String(owner),
2364+
"repo": githubv4.String(repo),
2365+
"ref": githubv4.String(ref),
2366+
"path": githubv4.String(path),
2367+
}
2368+
2369+
if err := client.Query(ctx, &blameQuery, vars); err != nil {
2370+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
2371+
fmt.Sprintf("failed to get blame for file: %s", path),
2372+
err,
2373+
), nil, nil
2374+
}
2375+
2376+
// Convert the blame ranges to a more readable format
2377+
type BlameRange struct {
2378+
StartingLine int `json:"starting_line"`
2379+
EndingLine int `json:"ending_line"`
2380+
Age int `json:"age"`
2381+
Commit struct {
2382+
SHA string `json:"sha"`
2383+
Message string `json:"message"`
2384+
CommittedDate string `json:"committed_date"`
2385+
Author struct {
2386+
Name string `json:"name"`
2387+
Email string `json:"email"`
2388+
Login *string `json:"login,omitempty"`
2389+
URL *string `json:"url,omitempty"`
2390+
} `json:"author"`
2391+
} `json:"commit"`
2392+
}
2393+
2394+
type BlameResult struct {
2395+
Repository string `json:"repository"`
2396+
Path string `json:"path"`
2397+
Ref string `json:"ref"`
2398+
Ranges []BlameRange `json:"ranges"`
2399+
}
2400+
2401+
result := BlameResult{
2402+
Repository: fmt.Sprintf("%s/%s", owner, repo),
2403+
Path: path,
2404+
Ref: ref,
2405+
Ranges: make([]BlameRange, 0, len(blameQuery.Repository.Object.Commit.Blame.Ranges)),
2406+
}
2407+
2408+
for _, r := range blameQuery.Repository.Object.Commit.Blame.Ranges {
2409+
br := BlameRange{
2410+
StartingLine: int(r.StartingLine),
2411+
EndingLine: int(r.EndingLine),
2412+
Age: int(r.Age),
2413+
}
2414+
br.Commit.SHA = string(r.Commit.OID)
2415+
br.Commit.Message = string(r.Commit.Message)
2416+
br.Commit.CommittedDate = r.Commit.CommittedDate.Format("2006-01-02T15:04:05Z")
2417+
br.Commit.Author.Name = string(r.Commit.Author.Name)
2418+
br.Commit.Author.Email = string(r.Commit.Author.Email)
2419+
if r.Commit.Author.User != nil {
2420+
login := string(r.Commit.Author.User.Login)
2421+
url := string(r.Commit.Author.User.URL)
2422+
br.Commit.Author.Login = &login
2423+
br.Commit.Author.URL = &url
2424+
}
2425+
2426+
result.Ranges = append(result.Ranges, br)
2427+
}
2428+
2429+
r, err := json.Marshal(result)
2430+
if err != nil {
2431+
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
2432+
}
2433+
2434+
return utils.NewToolResultText(string(r)), nil, nil
2435+
})
2436+
2437+
return tool, handler
2438+
}

0 commit comments

Comments
 (0)