diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index c43c971..f0a208b 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -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 @@ -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. diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index 03b9fe6..d88cc04 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -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) diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index f78497d..c0e4dcf 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -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") + } +} diff --git a/packages/code-storage-go/responses.go b/packages/code-storage-go/responses.go index 6e3fd22..65cde81 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -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"` diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 6fe2741..f56309b 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -474,6 +474,42 @@ type GetCommitResult struct { Commit CommitInfo } +// BlameOptions configures a per-line blame lookup. +type BlameOptions struct { + 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 diff --git a/packages/code-storage-go/version.go b/packages/code-storage-go/version.go index 3f53d5d..2cbde7b 100644 --- a/packages/code-storage-go/version.go +++ b/packages/code-storage-go/version.go @@ -2,7 +2,7 @@ package storage const ( PackageName = "code-storage-go-sdk" - PackageVersion = "0.5.0" + PackageVersion = "0.6.0" ) func userAgent() string { diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 8a44e5b..6ce9397 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -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"]) @@ -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, *, diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index b1627c7..f895975 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -8,6 +8,8 @@ from pierre_storage.errors import ApiError, RefUpdateError from pierre_storage.types import ( BaseRepo, + BlameLine, + BlameResult, BranchInfo, CommitInfo, CommitMetadata, @@ -68,6 +70,8 @@ "RefUpdateError", # Types "BaseRepo", + "BlameLine", + "BlameResult", "BranchInfo", "CommitMetadata", "CreateBranchResult", diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 490c379..1f1dbf6 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -16,6 +16,8 @@ ) from pierre_storage.errors import ApiError, RefUpdateError, infer_ref_update_reason from pierre_storage.types import ( + BlameLine, + BlameResult, BranchInfo, CommitBuilder, CommitInfo, @@ -1123,6 +1125,100 @@ async def get_commit( } return {"commit": commit} + 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: + """Return per-line authorship for a file at a ref. + + Args: + path: Repository-relative file path to blame. + ref: Branch, tag, or commit SHA to blame at. Defaults to the + repository default branch. + ephemeral: Resolve ``ref`` from the ephemeral namespace. + ranges: ``git blame -L``-style range specs. When omitted, the + whole file is blamed. + detect_moves: Follow the file across renames and copies. + ttl: Token TTL in seconds. + + Returns: + Per-line attribution plus a deduped commits map. + """ + if not path.strip(): + raise ValueError("get_blame path is required") + + ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS + jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl}) + + params: List[tuple[str, str]] = [("path", path)] + if ref is not None and ref.strip(): + params.append(("ref", ref.strip())) + if ephemeral: + params.append(("ephemeral", "true")) + if ranges: + for spec in ranges: + params.append(("range", spec)) + if detect_moves: + params.append(("detect_moves", "true")) + + url = ( + f"{self.api_base_url}/api/v{self.api_version}/repos/blame" + f"?{urlencode(params)}" + ) + + async with httpx.AsyncClient() as client: + response = await client.get( + url, + headers={ + "Authorization": f"Bearer {jwt}", + "Code-Storage-Agent": get_user_agent(), + }, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + + lines: List[BlameLine] = [] + for entry in data.get("lines", []): + author_time_raw = entry["author_time"] + committer_time_raw = entry["committer_time"] + line: BlameLine = { + "line_number": entry["line_number"], + "commit_sha": entry["commit_sha"], + "original_line_number": entry["original_line_number"], + "original_path": entry["original_path"], + "author_name": entry["author_name"], + "author_email": entry["author_email"], + "author_time": datetime.fromisoformat( + author_time_raw.replace("Z", "+00:00") + ), + "raw_author_time": author_time_raw, + "committer_name": entry["committer_name"], + "committer_email": entry["committer_email"], + "committer_time": datetime.fromisoformat( + committer_time_raw.replace("Z", "+00:00") + ), + "raw_committer_time": committer_time_raw, + "summary": entry["summary"], + } + previous = entry.get("previous_commit_sha") + if previous: + line["previous_commit_sha"] = previous + lines.append(line) + + return { + "ref": data["ref"], + "path": data["path"], + "commit_sha": data["commit_sha"], + "lines": lines, + } + async def get_note( self, *, diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 689ab41..d422b30 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -274,6 +274,34 @@ class GetCommitResult(TypedDict): commit: CommitInfo +class BlameLine(TypedDict): + """A single line in blame results with inline commit metadata.""" + + line_number: int + commit_sha: str + original_line_number: int + original_path: str + previous_commit_sha: NotRequired[str] + author_name: str + author_email: str + author_time: datetime + raw_author_time: str + committer_name: str + committer_email: str + committer_time: datetime + raw_committer_time: str + summary: str + + +class BlameResult(TypedDict): + """Result from running blame on a file.""" + + ref: str + path: str + commit_sha: str + lines: List[BlameLine] + + # Git notes types class NoteReadResult(TypedDict): """Result from reading a git note.""" @@ -761,6 +789,19 @@ async def get_commit( """Fetch metadata for a single commit (no diff).""" ... + 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: + """Return per-line authorship for a file at a ref.""" + ... + async def get_note( self, *, diff --git a/packages/code-storage-python/pierre_storage/version.py b/packages/code-storage-python/pierre_storage/version.py index 2cbbe4f..41b8cdd 100644 --- a/packages/code-storage-python/pierre_storage/version.py +++ b/packages/code-storage-python/pierre_storage/version.py @@ -1,7 +1,7 @@ """Version information for Pierre Storage SDK.""" PACKAGE_NAME = "code-storage-py-sdk" -PACKAGE_VERSION = "1.6.0" +PACKAGE_VERSION = "1.7.0" def get_user_agent() -> str: diff --git a/packages/code-storage-python/pyproject.toml b/packages/code-storage-python/pyproject.toml index 2763d2c..2c387f2 100644 --- a/packages/code-storage-python/pyproject.toml +++ b/packages/code-storage-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pierre-storage" -version = "1.6.0" +version = "1.7.0" description = "Pierre Git Storage SDK for Python" readme = "README.md" license = "MIT" diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index 9515ae1..468470a 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -1437,6 +1437,163 @@ async def test_get_commit_requires_sha(self, git_storage_options: dict) -> None: client_instance.get.assert_not_called() + @pytest.mark.asyncio + async def test_get_blame(self, git_storage_options: dict) -> None: + """Test blaming a file.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + blame_response = MagicMock() + blame_response.status_code = 200 + blame_response.is_success = True + blame_response.raise_for_status = MagicMock() + blame_response.json.return_value = { + "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", + }, + ], + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.get = AsyncMock(return_value=blame_response) + + repo = await storage.create_repo(id="test-repo") + result = await repo.get_blame( + path="src/x.go", + ref="main", + ranges=["10,20", "/getUser/,+30"], + detect_moves=True, + ) + + assert result["ref"] == "main" + assert result["path"] == "src/x.go" + assert result["commit_sha"] == "aaa111" + assert len(result["lines"]) == 2 + + first = result["lines"][0] + assert first["commit_sha"] == "bbb222" + assert first["author_name"] == "Alice" + assert first["previous_commit_sha"] == "zzz000" + assert first["raw_author_time"] == "2024-01-15T14:32:18Z" + assert isinstance(first["author_time"], datetime) + assert first["author_time"] == datetime( + 2024, 1, 15, 14, 32, 18, tzinfo=timezone.utc + ) + + second = result["lines"][1] + assert second["original_path"] == "src/old.go" + assert "previous_commit_sha" not in second + assert second["author_name"] == "Bob" + + called_url = client_instance.get.call_args.args[0] + parsed = urlparse(called_url) + assert parsed.path.endswith("/api/v1/repos/blame") + assert parse_qs(parsed.query) == { + "path": ["src/x.go"], + "ref": ["main"], + "range": ["10,20", "/getUser/,+30"], + "detect_moves": ["true"], + } + + headers = client_instance.get.call_args.kwargs["headers"] + assert headers["Code-Storage-Agent"] == get_user_agent() + token = headers["Authorization"].replace("Bearer ", "") + payload = jwt.decode(token, options={"verify_signature": False}) + assert payload["scopes"] == ["git:read"] + assert payload["repo"] == "test-repo" + + @pytest.mark.asyncio + async def test_get_blame_omits_optional_params(self, git_storage_options: dict) -> None: + """blame should send only the path when no other knobs are supplied.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + blame_response = MagicMock() + blame_response.status_code = 200 + blame_response.is_success = True + blame_response.raise_for_status = MagicMock() + blame_response.json.return_value = { + "ref": "main", + "path": "src/x.go", + "commit_sha": "sha", + "lines": [], + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.get = AsyncMock(return_value=blame_response) + + repo = await storage.create_repo(id="test-repo") + await repo.get_blame(path="src/x.go") + + called_url = client_instance.get.call_args.args[0] + parsed = urlparse(called_url) + assert parse_qs(parsed.query) == {"path": ["src/x.go"]} + + @pytest.mark.asyncio + async def test_get_blame_requires_path(self, git_storage_options: dict) -> None: + """blame should reject blank or whitespace-only path values.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.get = AsyncMock() + + repo = await storage.create_repo(id="test-repo") + + with pytest.raises(ValueError, match="get_blame path is required"): + await repo.get_blame(path="") + + with pytest.raises(ValueError, match="get_blame path is required"): + await repo.get_blame(path=" ") + + client_instance.get.assert_not_called() + @pytest.mark.asyncio async def test_restore_commit(self, git_storage_options: dict) -> None: """Test restoring to a previous commit.""" diff --git a/packages/code-storage-python/uv.lock b/packages/code-storage-python/uv.lock index 6cf8ed1..d975f57 100644 --- a/packages/code-storage-python/uv.lock +++ b/packages/code-storage-python/uv.lock @@ -915,7 +915,7 @@ wheels = [ [[package]] name = "pierre-storage" -version = "1.6.0" +version = "1.7.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index 4dc043f..b429a8a 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -244,6 +244,19 @@ console.log(commits.commits); const { commit } = await repo.getCommit({ sha: 'abc123...' }); console.log(commit.message, commit.authorName); +// Blame a file (per-line authorship). The top-level `commitSha` is the SHA the +// `ref` resolved to; each entry in `lines` carries its own author/committer +// metadata inline. +const blame = await repo.getBlame({ + path: 'src/main.go', + ref: 'main', + ranges: ['10,30'], + detectMoves: true, +}); +for (const line of blame.lines) { + console.log(`${line.lineNumber} (${line.commitSha.slice(0, 7)}): ${line.authorName} — ${line.summary}`); +} + // Read a git note for a commit const note = await repo.getNote({ sha: 'abc123...' }); console.log(note.note); @@ -515,6 +528,7 @@ interface Repo { listBranches(options?: ListBranchesOptions): Promise; listCommits(options?: ListCommitsOptions): Promise; getCommit(options: GetCommitOptions): Promise; + getBlame(options: BlameOptions): Promise; getNote(options: GetNoteOptions): Promise; createNote(options: CreateNoteOptions): Promise; appendNote(options: AppendNoteOptions): Promise; @@ -702,6 +716,39 @@ interface GetCommitResult { commit: CommitInfo; } +interface BlameOptions { + path: string; + ref?: string; + ephemeral?: boolean; + ranges?: string[]; + detectMoves?: boolean; + ttl?: number; +} + +interface BlameLine { + lineNumber: number; + commitSha: string; + originalLineNumber: number; + originalPath: string; + previousCommitSha?: string; + authorName: string; + authorEmail: string; + authorTime: Date; + rawAuthorTime: string; + committerName: string; + committerEmail: string; + committerTime: Date; + rawCommitterTime: string; + summary: string; +} + +interface BlameResult { + ref: string; // The ref passed in (or default branch resolved) + path: string; + commitSha: string; // SHA the input ref resolved to at request time + lines: BlameLine[]; +} + interface GetBranchDiffOptions { branch: string; base?: string; // Defaults to 'main' diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index 36c8dfc..b1a9926 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", - "version": "1.5.0", + "version": "1.6.0", "description": "Pierre Git Storage SDK", "repository": { "type": "git", diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 040110b..6aee17f 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -23,6 +23,7 @@ import { deleteBranchResponseSchema, deleteTagResponseSchema, errorEnvelopeSchema, + blameResponseSchema, getCommitResponseSchema, grepResponseSchema, listBranchesResponseSchema, @@ -78,6 +79,9 @@ import type { GetCommitDiffOptions, GetCommitDiffResponse, GetCommitDiffResult, + BlameOptions, + BlameResponse, + BlameResult, GetCommitOptions, GetCommitResponse, GetCommitResult, @@ -357,6 +361,30 @@ function transformGetCommitResult(raw: GetCommitResponse): GetCommitResult { }; } +function transformBlameResult(raw: BlameResponse): BlameResult { + return { + ref: raw.ref, + path: raw.path, + commitSha: raw.commit_sha, + lines: raw.lines.map((line) => ({ + lineNumber: line.line_number, + commitSha: line.commit_sha, + originalLineNumber: line.original_line_number, + originalPath: line.original_path, + previousCommitSha: line.previous_commit_sha, + authorName: line.author_name, + authorEmail: line.author_email, + authorTime: new Date(line.author_time), + rawAuthorTime: line.author_time, + committerName: line.committer_name, + committerEmail: line.committer_email, + committerTime: new Date(line.committer_time), + rawCommitterTime: line.committer_time, + summary: line.summary, + })), + }; +} + function transformFileWithMetadata(raw: RawFileWithMetadata): FileWithMetadata { return { path: raw.path, @@ -990,6 +1018,41 @@ class RepoImpl implements Repo { return transformGetCommitResult(raw); } + async getBlame(options: BlameOptions): Promise { + if (!options?.path?.trim()) { + throw new Error('getBlame path is required'); + } + + const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); + const jwt = await this.generateJWT(this.id, { + permissions: ['git:read'], + ttl, + }); + + const params: Record = { path: options.path }; + const ref = options.ref?.trim(); + if (ref) { + params.ref = ref; + } + if (options.ephemeral) { + params.ephemeral = 'true'; + } + if (options.ranges && options.ranges.length > 0) { + params.range = options.ranges; + } + if (options.detectMoves) { + params.detect_moves = 'true'; + } + + const response = await this.api.get( + { path: 'repos/blame', params }, + jwt + ); + + const raw = blameResponseSchema.parse(await response.json()); + return transformBlameResult(raw); + } + async getNote(options: GetNoteOptions): Promise { const sha = options?.sha?.trim(); if (!sha) { diff --git a/packages/code-storage-typescript/src/schemas.ts b/packages/code-storage-typescript/src/schemas.ts index dd2fc5a..0b22d67 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -57,6 +57,28 @@ export const getCommitResponseSchema = z.object({ commit: commitInfoRawSchema, }); +export const blameLineRawSchema = z.object({ + line_number: z.number(), + commit_sha: z.string(), + original_line_number: z.number(), + original_path: z.string(), + previous_commit_sha: z.string().optional(), + author_name: z.string(), + author_email: z.string(), + author_time: z.string(), + committer_name: z.string(), + committer_email: z.string(), + committer_time: z.string(), + summary: z.string(), +}); + +export const blameResponseSchema = z.object({ + ref: z.string(), + path: z.string(), + commit_sha: z.string(), + lines: z.array(blameLineRawSchema), +}); + export const repoBaseInfoSchema = z.object({ provider: z.string(), owner: z.string(), @@ -288,6 +310,8 @@ export type ListBranchesResponseRaw = z.infer< export type RawCommitInfo = z.infer; export type ListCommitsResponseRaw = z.infer; export type GetCommitResponseRaw = z.infer; +export type BlameLineRaw = z.infer; +export type BlameResponseRaw = z.infer; export type RawRepoBaseInfo = z.infer; export type RawRepoInfo = z.infer; export type ListReposResponseRaw = z.infer; diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 1cc971c..590274c 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -6,6 +6,7 @@ import type { CreateTagResponseRaw, DeleteBranchResponseRaw, DeleteTagResponseRaw, + BlameResponseRaw, GetBranchDiffResponseRaw, GetCommitDiffResponseRaw, GetCommitResponseRaw, @@ -76,6 +77,7 @@ export interface Repo { listTags(options?: ListTagsOptions): Promise; listCommits(options?: ListCommitsOptions): Promise; getCommit(options: GetCommitOptions): Promise; + getBlame(options: BlameOptions): Promise; createTag(options: CreateTagOptions): Promise; deleteTag(options: DeleteTagOptions): Promise; getNote(options: GetNoteOptions): Promise; @@ -442,6 +444,41 @@ export interface GetCommitResult { commit: CommitInfo; } +// Blame API types +export interface BlameOptions extends GitStorageInvocationOptions { + path: string; + ref?: string; + ephemeral?: boolean; + ranges?: string[]; + detectMoves?: boolean; +} + +export type BlameResponse = BlameResponseRaw; + +export interface BlameLine { + lineNumber: number; + commitSha: string; + originalLineNumber: number; + originalPath: string; + previousCommitSha?: string; + authorName: string; + authorEmail: string; + authorTime: Date; + rawAuthorTime: string; + committerName: string; + committerEmail: string; + committerTime: Date; + rawCommitterTime: string; + summary: string; +} + +export interface BlameResult { + ref: string; + path: string; + commitSha: string; + lines: BlameLine[]; +} + // Git notes API types export interface GetNoteOptions extends GitStorageInvocationOptions { sha: string; diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index b2c0797..7ea58e7 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -355,6 +355,138 @@ describe('GitStorage', () => { ); }); + it('blames a file with full options', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-blame' }); + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('GET'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/blame')).toBe(true); + expect(requestUrl.searchParams.get('path')).toBe('src/x.go'); + expect(requestUrl.searchParams.get('ref')).toBe('main'); + expect(requestUrl.searchParams.getAll('range')).toEqual(['10,20', '/getUser/,+30']); + expect(requestUrl.searchParams.get('detect_moves')).toBe('true'); + + const headers = (init?.headers ?? {}) as Record; + const payload = decodeJwtPayload(stripBearer(headers.Authorization)); + expect(payload.scopes).toEqual(['git:read']); + expect(payload.repo).toBe('repo-blame'); + + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + 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', + }, + ], + }), + } as any); + }); + + const result = await repo.getBlame({ + path: 'src/x.go', + ref: 'main', + ranges: ['10,20', '/getUser/,+30'], + detectMoves: true, + }); + + expect(result.ref).toBe('main'); + expect(result.path).toBe('src/x.go'); + expect(result.commitSha).toBe('aaa111'); + expect(result.lines).toHaveLength(2); + expect(result.lines[0].commitSha).toBe('bbb222'); + expect(result.lines[0].authorName).toBe('Alice'); + expect(result.lines[0].previousCommitSha).toBe('zzz000'); + expect(result.lines[0].rawAuthorTime).toBe('2024-01-15T14:32:18Z'); + expect(result.lines[0].authorTime).toBeInstanceOf(Date); + expect(result.lines[0].authorTime.toISOString()).toBe('2024-01-15T14:32:18.000Z'); + expect(result.lines[1].originalPath).toBe('src/old.go'); + expect(result.lines[1].previousCommitSha).toBeUndefined(); + expect(result.lines[1].authorName).toBe('Bob'); + }); + + it('omits optional blame params when not provided', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-blame-defaults' }); + + mockFetch.mockImplementationOnce((url) => { + const requestUrl = new URL(url as string); + expect(requestUrl.searchParams.get('path')).toBe('src/x.go'); + for (const key of [ + 'ref', + 'ephemeral', + 'range', + 'detect_moves', + ]) { + expect(requestUrl.searchParams.has(key)).toBe(false); + } + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + ref: 'main', + path: 'src/x.go', + commit_sha: 'sha', + lines: [], + }), + } as any); + }); + + const result = await repo.getBlame({ path: 'src/x.go' }); + expect(result.commitSha).toBe('sha'); + expect(result.lines).toEqual([]); + }); + + it('rejects blame when path is missing or blank', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-blame-validation' }); + + await expect( + // @ts-expect-error - exercising runtime validation when path is omitted + repo.getBlame({}) + ).rejects.toThrow('getBlame path is required'); + + await expect(repo.getBlame({ path: '' })).rejects.toThrow( + 'getBlame path is required' + ); + + await expect(repo.getBlame({ path: ' ' })).rejects.toThrow( + 'getBlame path is required' + ); + }); + it('sends note payloads with createNote, appendNote, and deleteNote', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = await store.createRepo({ id: 'repo-notes-write' }); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index e819a1e..d1c44d3 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -107,6 +107,7 @@ Username is always `t`. Password is the JWT. | List files at ref | GET | `/repos/files` | `git:read` | | List files with metadata | GET | `/repos/files/metadata` | `git:read` | | Get file content (stream) | GET | `/repos/file` | `git:read` | +| Blame file at ref | GET | `/repos/blame` | `git:read` | | Search content (grep) | POST | `/repos/grep` | `git:read` | | Download archive (tar.gz) | POST | `/repos/archive` | `git:read` | | **TAGS** | | | | @@ -440,6 +441,43 @@ curl "$CODE_STORAGE_BASE_URL/repos/file?path=src/main.go&ref=main" \ Response: raw file bytes (streaming), `Content-Type` set appropriately. +## GET /repos/blame — Blame File + +```bash +curl "$CODE_STORAGE_BASE_URL/repos/blame?path=src/main.go&ref=main&range=10,30&range=/getUser/,+30&detect_moves=true" \ + -H "Authorization: Bearer $CODE_STORAGE_TOKEN" +``` + +Params: `path` (required — repository-relative file path), `ref` (branch, tag, or +SHA; defaults to the repository default branch), `ephemeral` (resolve `ref` from +the ephemeral namespace), `range` (repeatable `git blame -L`-style spec, up to +16 per request — each value is one `-L` argument: e.g. `10,20`, `10,+5`, +`/getUser/,/^}/`, `/getUser/,+30`, `10,`, `,20`, `10`, `:^func .*Foo`, +`:funcname`; when omitted, the whole file is blamed), `detect_moves` (follow +renames and copies). +Response: +```json +{ + "ref": "main", + "path": "src/main.go", + "commit_sha": "", + "lines": [{ + "line_number": 1, + "commit_sha": "...", + "original_line_number": 1, + "original_path": "src/main.go", + "previous_commit_sha": "...", + "author_name": "...", "author_email": "...", "author_time": "...", + "committer_name": "...", "committer_email": "...", "committer_time": "...", + "summary": "..." + }] +} +``` +The top-level `commit_sha` is the SHA the input ref resolved to. Each entry in +`lines[]` carries its authoring commit's metadata inline; `previous_commit_sha` +is omitted when the line has no prior version (e.g. introduced in the initial +commit). Errors: `400` missing/invalid params, `404` ref/path not found. + ## POST /repos/grep — Search Content (Beta) ```bash