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
28 changes: 26 additions & 2 deletions packages/code-storage-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ fmt.Println(result.Files[0].LastCommitSHA)
fmt.Println(result.Commits[result.Files[0].LastCommitSHA].Author)
```

### Blame a file

```go
blame, err := repo.GetBlame(context.Background(), storage.BlameOptions{
Path: "src/main.go",
Ref: "main",
Ranges: []string{"10,30"},
DetectMoves: true,
})
if err != nil {
log.Fatal(err)
}

for _, line := range blame.Lines {
fmt.Printf("%d (%s): %s — %s\n", line.LineNumber, line.CommitSHA[:7], line.AuthorName, line.Summary)
}
```

`Ranges` accepts repeated `git blame -L` specs (`"10,30"`, `"/getUser/,/^}/"`,
`"10,+5"`, `"10,"`, `",30"`, `"10"`, `":funcname"`). Up to 16 per request; omit
to blame the whole file. The top-level `CommitSHA` is the SHA `Ref` resolved
to; each `BlameLine` carries its authoring commit's metadata inline, with
`PreviousCommitSHA` empty when the line has no prior version.

### Manage tags

```go
Expand Down Expand Up @@ -200,8 +224,8 @@ fmt.Println(repo.ID)
Because this Go module lives in a monorepo, git tags must be prefixed with the module's subdirectory path:

```bash
git tag packages/code-storage-go/v0.5.0
git push origin packages/code-storage-go/v0.5.0
git tag packages/code-storage-go/v0.6.0
git push origin packages/code-storage-go/v0.6.0
```

Make sure the version in `version.go` (`PackageVersion`) matches the tag before tagging.
Expand Down
66 changes: 66 additions & 0 deletions packages/code-storage-go/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,72 @@ func (r *Repo) GetCommit(ctx context.Context, options GetCommitOptions) (GetComm
}, nil
}

// GetBlame returns per-line authorship for a file at a ref.
func (r *Repo) GetBlame(ctx context.Context, options BlameOptions) (BlameResult, error) {
if strings.TrimSpace(options.Path) == "" {
return BlameResult{}, errors.New("getBlame path is required")
}

ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL)
jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead}, TTL: ttl})
if err != nil {
return BlameResult{}, err
}

params := url.Values{}
params.Set("path", options.Path)
if ref := strings.TrimSpace(options.Ref); ref != "" {
params.Set("ref", ref)
}
if options.Ephemeral {
params.Set("ephemeral", "true")
}
for _, spec := range options.Ranges {
params.Add("range", spec)
}
if options.DetectMoves {
params.Set("detect_moves", "true")
}

resp, err := r.client.api.get(ctx, "repos/blame", params, jwtToken, nil)
if err != nil {
return BlameResult{}, err
}
defer resp.Body.Close()

var payload blameResponse
if err := decodeJSON(resp, &payload); err != nil {
return BlameResult{}, err
}

result := BlameResult{
Ref: payload.Ref,
Path: payload.Path,
CommitSHA: payload.CommitSHA,
Lines: make([]BlameLine, len(payload.Lines)),
}
for i, line := range payload.Lines {
result.Lines[i] = BlameLine{
LineNumber: line.LineNumber,
CommitSHA: line.CommitSHA,
OriginalLineNumber: line.OriginalLineNumber,
OriginalPath: line.OriginalPath,
PreviousCommitSHA: line.PreviousCommitSHA,
AuthorName: line.AuthorName,
AuthorEmail: line.AuthorEmail,
AuthorTime: parseTime(line.AuthorTime),
RawAuthorTime: line.AuthorTime,
CommitterName: line.CommitterName,
CommitterEmail: line.CommitterEmail,
CommitterTime: parseTime(line.CommitterTime),
RawCommitterTime: line.CommitterTime,
Summary: line.Summary,
}
}

return result, nil
}

// GetNote reads a git note.
func (r *Repo) GetNote(ctx context.Context, options GetNoteOptions) (GetNoteResult, error) {
sha := strings.TrimSpace(options.SHA)
Expand Down
113 changes: 113 additions & 0 deletions packages/code-storage-go/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1456,3 +1456,116 @@ func TestGetCommitRequiresSHA(t *testing.T) {
func intPtr(value int) *int {
return &value
}

func TestBlame(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/blame" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
q := r.URL.Query()
if got := q.Get("path"); got != "src/x.go" {
t.Fatalf("unexpected path query: %q", got)
}
if got := q.Get("ref"); got != "main" {
t.Fatalf("unexpected ref query: %q", got)
}
if got := q["range"]; len(got) != 2 || got[0] != "10,20" || got[1] != "/getUser/,+30" {
t.Fatalf("unexpected range query: %v", got)
}
if got := q.Get("detect_moves"); got != "true" {
t.Fatalf("unexpected detect_moves query: %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"ref": "main",
"path": "src/x.go",
"commit_sha": "aaa111",
"lines": [
{"line_number": 10, "commit_sha": "bbb222", "original_line_number": 5, "original_path": "src/x.go", "previous_commit_sha": "zzz000", "author_name": "Alice", "author_email": "alice@example.com", "author_time": "2024-01-15T14:32:18Z", "committer_name": "Alice", "committer_email": "alice@example.com", "committer_time": "2024-01-15T14:32:18Z", "summary": "init"},
{"line_number": 11, "commit_sha": "ccc333", "original_line_number": 11, "original_path": "src/old.go", "author_name": "Bob", "author_email": "bob@example.com", "author_time": "2024-02-20T09:00:00Z", "committer_name": "Bob", "committer_email": "bob@example.com", "committer_time": "2024-02-20T09:00:00Z", "summary": "fix"}
]
}`))
}))
defer server.Close()

client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL})
if err != nil {
t.Fatalf("client error: %v", err)
}
repo := &Repo{ID: "repo", DefaultBranch: "main", client: client}

result, err := repo.GetBlame(nil, BlameOptions{
Path: "src/x.go",
Ref: "main",
Ranges: []string{"10,20", "/getUser/,+30"},
DetectMoves: true,
})
if err != nil {
t.Fatalf("blame error: %v", err)
}
if result.Ref != "main" || result.Path != "src/x.go" || result.CommitSHA != "aaa111" {
t.Fatalf("unexpected top-level fields: %+v", result)
}
if len(result.Lines) != 2 {
t.Fatalf("unexpected line count: %d", len(result.Lines))
}
first := result.Lines[0]
if first.CommitSHA != "bbb222" || first.LineNumber != 10 {
t.Fatalf("unexpected first line: %+v", first)
}
if first.AuthorName != "Alice" || first.PreviousCommitSHA != "zzz000" {
t.Fatalf("unexpected first-line author metadata: %+v", first)
}
if first.AuthorTime.IsZero() || first.RawAuthorTime != "2024-01-15T14:32:18Z" {
t.Fatalf("unexpected first-line author time: %+v", first)
}
second := result.Lines[1]
if second.OriginalPath != "src/old.go" {
t.Fatalf("unexpected original_path: %q", second.OriginalPath)
}
if second.PreviousCommitSHA != "" {
t.Fatalf("expected empty previous_commit_sha on second line, got %q", second.PreviousCommitSHA)
}
if second.AuthorName != "Bob" {
t.Fatalf("unexpected second-line author: %q", second.AuthorName)
}
}

func TestBlameOmitsEmptyParams(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("path") != "src/x.go" {
t.Fatalf("expected path query")
}
for _, key := range []string{"ref", "ephemeral", "range", "detect_moves"} {
if _, ok := q[key]; ok {
t.Fatalf("unexpected %q in query: %v", key, q.Get(key))
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ref":"main","path":"src/x.go","commit_sha":"sha","lines":[]}`))
}))
defer server.Close()

client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL})
if err != nil {
t.Fatalf("client error: %v", err)
}
repo := &Repo{ID: "repo", DefaultBranch: "main", client: client}

if _, err := repo.GetBlame(nil, BlameOptions{Path: "src/x.go"}); err != nil {
t.Fatalf("blame error: %v", err)
}
}

func TestBlameRequiresPath(t *testing.T) {
client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: "https://example.invalid"})
if err != nil {
t.Fatalf("client error: %v", err)
}
repo := &Repo{ID: "repo", DefaultBranch: "main", client: client}

if _, err := repo.GetBlame(nil, BlameOptions{}); err == nil {
t.Fatalf("expected error for empty path")
}
}
22 changes: 22 additions & 0 deletions packages/code-storage-go/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ type getCommitResponse struct {
Commit commitInfoRaw `json:"commit"`
}

type blameResponse struct {
Ref string `json:"ref"`
Path string `json:"path"`
CommitSHA string `json:"commit_sha"`
Lines []blameLineRaw `json:"lines"`
}

type blameLineRaw struct {
LineNumber int32 `json:"line_number"`
CommitSHA string `json:"commit_sha"`
OriginalLineNumber int32 `json:"original_line_number"`
OriginalPath string `json:"original_path"`
PreviousCommitSHA string `json:"previous_commit_sha,omitempty"`
AuthorName string `json:"author_name"`
AuthorEmail string `json:"author_email"`
AuthorTime string `json:"author_time"`
CommitterName string `json:"committer_name"`
CommitterEmail string `json:"committer_email"`
CommitterTime string `json:"committer_time"`
Summary string `json:"summary"`
}

type commitInfoRaw struct {
SHA string `json:"sha"`
Message string `json:"message"`
Expand Down
36 changes: 36 additions & 0 deletions packages/code-storage-go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,42 @@ type GetCommitResult struct {
Commit CommitInfo
}

// BlameOptions configures a per-line blame lookup.
type BlameOptions struct {
Comment on lines +477 to +478
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Document the Go blame API

Adding BlameOptions/Repo.GetBlame changes the public Go SDK surface, but packages/code-storage-go/README.md still has no blame/GetBlame mention (checked with rg). The root AGENTS.md says to "Update docs when public API shapes change", and the TypeScript/Python READMEs were updated in this commit, so Go users are left without the new method's options/response shape or an example.

Useful? React with 👍 / 👎.

InvocationOptions
Path string
Ref string
Ephemeral bool
Ranges []string
DetectMoves bool
}

// BlameLine describes blame attribution for a single line in a file.
type BlameLine struct {
LineNumber int32
CommitSHA string
OriginalLineNumber int32
OriginalPath string
PreviousCommitSHA string
AuthorName string
AuthorEmail string
AuthorTime time.Time
RawAuthorTime string
CommitterName string
CommitterEmail string
CommitterTime time.Time
RawCommitterTime string
Summary string
}

// BlameResult is the result returned by Repo.GetBlame.
type BlameResult struct {
Ref string
Path string
CommitSHA string
Lines []BlameLine
}

// NoteAuthor identifies note author.
type NoteAuthor struct {
Name string
Expand Down
2 changes: 1 addition & 1 deletion packages/code-storage-go/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package storage

const (
PackageName = "code-storage-go-sdk"
PackageVersion = "0.5.0"
PackageVersion = "0.6.0"
)

func userAgent() string {
Expand Down
23 changes: 23 additions & 0 deletions packages/code-storage-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,18 @@ print(commits["commits"])
result = await repo.get_commit(sha="abc123...")
print(result["commit"]["message"], result["commit"]["author_name"])

# Blame a file (per-line authorship). The top-level "commit_sha" is the SHA the
# `ref` resolved to; each entry in "lines" carries its own author/committer
# metadata inline.
blame = await repo.get_blame(
path="src/main.go",
ref="main",
ranges=["10,30"],
detect_moves=True,
)
for line in blame["lines"]:
print(f"{line['line_number']} ({line['commit_sha'][:7]}): {line['author_name']} — {line['summary']}")

# Read a git note for a commit
note = await repo.get_note(sha="abc123...")
print(note["note"])
Expand Down Expand Up @@ -754,6 +766,17 @@ class Repo:
ttl: Optional[int] = None,
) -> GetCommitResult: ...

async def get_blame(
self,
*,
path: str,
ref: Optional[str] = None,
ephemeral: Optional[bool] = None,
ranges: Optional[list[str]] = None,
detect_moves: Optional[bool] = None,
ttl: Optional[int] = None,
) -> BlameResult: ...

async def get_branch_diff(
self,
*,
Expand Down
4 changes: 4 additions & 0 deletions packages/code-storage-python/pierre_storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from pierre_storage.errors import ApiError, RefUpdateError
from pierre_storage.types import (
BaseRepo,
BlameLine,
BlameResult,
BranchInfo,
CommitInfo,
CommitMetadata,
Expand Down Expand Up @@ -68,6 +70,8 @@
"RefUpdateError",
# Types
"BaseRepo",
"BlameLine",
"BlameResult",
"BranchInfo",
"CommitMetadata",
"CreateBranchResult",
Expand Down
Loading
Loading