From b9694c103ef1dacaf4bc623a9583e4f978d72bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Tue, 5 May 2026 16:58:56 -0700 Subject: [PATCH 1/7] Add GetBlame API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the GET /api/v1/repos/blame gateway endpoint into all three SDKs as `Repo.getBlame` (TS), `Repo.get_blame` (Python), and `Repo.GetBlame` (Go), mirroring the GetCommit (#20) integration shape — required `path`, optional `ref`/`ephemeral`/`start_line`/`end_line`/`detect_moves`, JWT minted with `git:read`, response transformed from snake_case wire to camelCase result. The response surfaces a top-level `commit` (the SHA the input ref resolved to) distinct from `commits` (deduped per-authoring-commit metadata referenced by `lines[]`). Author and committer timestamps are parsed to native date types while preserving the raw RFC strings. SKILL.md and the TS/Python READMEs document the endpoint, two-step `lines → commits` lookup, and that omitting `ref` falls back to the repository default branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/code-storage-go/repo.go | 76 ++++++++ packages/code-storage-go/repo_test.go | 114 ++++++++++++ packages/code-storage-go/responses.go | 27 +++ packages/code-storage-go/types.go | 43 +++++ packages/code-storage-python/README.md | 26 +++ .../pierre_storage/__init__.py | 6 + .../pierre_storage/repo.py | 106 +++++++++++ .../pierre_storage/types.py | 49 ++++++ .../code-storage-python/tests/test_repo.py | 164 ++++++++++++++++++ packages/code-storage-typescript/README.md | 55 ++++++ packages/code-storage-typescript/src/index.ts | 75 ++++++++ .../code-storage-typescript/src/schemas.ts | 30 ++++ packages/code-storage-typescript/src/types.ts | 45 +++++ .../tests/index.test.ts | 144 +++++++++++++++ skills/code-storage/SKILL.md | 39 +++++ 15 files changed, 999 insertions(+) diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index 949f8e1..3046ba1 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -432,6 +432,82 @@ 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) { + path := strings.TrimSpace(options.Path) + if 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", path) + if ref := strings.TrimSpace(options.Ref); ref != "" { + params.Set("ref", ref) + } + if options.Ephemeral { + params.Set("ephemeral", "true") + } + if options.StartLine != 0 { + params.Set("start_line", strconv.FormatInt(int64(options.StartLine), 10)) + } + if options.EndLine != 0 { + params.Set("end_line", strconv.FormatInt(int64(options.EndLine), 10)) + } + 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, + Commit: payload.Commit, + Lines: make([]BlameLine, len(payload.Lines)), + Commits: make(map[string]BlameCommit, len(payload.Commits)), + } + for i, line := range payload.Lines { + result.Lines[i] = BlameLine{ + LineNumber: line.LineNumber, + CommitSHA: line.CommitSHA, + OriginalLineNumber: line.OriginalLineNumber, + OriginalPath: line.OriginalPath, + Text: line.Text, + } + } + for sha, c := range payload.Commits { + result.Commits[sha] = BlameCommit{ + PreviousCommitSHA: c.PreviousCommitSHA, + AuthorName: c.AuthorName, + AuthorEmail: c.AuthorEmail, + AuthorTime: parseTime(c.AuthorTime), + RawAuthorTime: c.AuthorTime, + CommitterName: c.CommitterName, + CommitterEmail: c.CommitterEmail, + CommitterTime: parseTime(c.CommitterTime), + RawCommitterTime: c.CommitterTime, + Summary: c.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 4225a17..0816869 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -1368,3 +1368,117 @@ 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.Get("start_line"); got != "10" { + t.Fatalf("unexpected start_line query: %q", got) + } + if got := q.Get("end_line"); got != "20" { + t.Fatalf("unexpected end_line query: %q", 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": "aaa111", + "lines": [ + {"line_number": 10, "commit_sha": "bbb222", "original_line_number": 5, "original_path": "src/x.go", "text": "package main"}, + {"line_number": 11, "commit_sha": "ccc333", "original_line_number": 11, "original_path": "src/old.go", "text": "import \"fmt\""} + ], + "commits": { + "bbb222": {"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"}, + "ccc333": {"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", + StartLine: 10, + EndLine: 20, + DetectMoves: true, + }) + if err != nil { + t.Fatalf("blame error: %v", err) + } + if result.Ref != "main" || result.Path != "src/x.go" || result.Commit != "aaa111" { + t.Fatalf("unexpected top-level fields: %+v", result) + } + if len(result.Lines) != 2 { + t.Fatalf("unexpected line count: %d", len(result.Lines)) + } + if result.Lines[0].CommitSHA != "bbb222" || result.Lines[0].LineNumber != 10 { + t.Fatalf("unexpected first line: %+v", result.Lines[0]) + } + if result.Lines[1].OriginalPath != "src/old.go" { + t.Fatalf("unexpected original_path: %q", result.Lines[1].OriginalPath) + } + commit := result.Commits["bbb222"] + if commit.AuthorName != "Alice" || commit.PreviousCommitSHA != "zzz000" { + t.Fatalf("unexpected commit metadata: %+v", commit) + } + if commit.AuthorTime.IsZero() || commit.RawAuthorTime != "2024-01-15T14:32:18Z" { + t.Fatalf("unexpected author time: %+v", commit) + } +} + +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", "start_line", "end_line", "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","lines":[],"commits":{}}`)) + })) + 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..109fae9 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -47,6 +47,33 @@ type getCommitResponse struct { Commit commitInfoRaw `json:"commit"` } +type blameResponse struct { + Ref string `json:"ref"` + Path string `json:"path"` + Commit string `json:"commit"` + Lines []blameLineRaw `json:"lines"` + Commits map[string]blameCommitRaw `json:"commits"` +} + +type blameLineRaw struct { + LineNumber int32 `json:"line_number"` + CommitSHA string `json:"commit_sha"` + OriginalLineNumber int32 `json:"original_line_number"` + OriginalPath string `json:"original_path"` + Text string `json:"text"` +} + +type blameCommitRaw struct { + 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 7627390..10ebf46 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -472,6 +472,49 @@ type GetCommitResult struct { Commit CommitInfo } +// BlameOptions configures a per-line blame lookup. +type BlameOptions struct { + InvocationOptions + Path string + Ref string + Ephemeral bool + StartLine int32 + EndLine int32 + DetectMoves bool +} + +// BlameLine describes blame attribution for a single line in a file. +type BlameLine struct { + LineNumber int32 + CommitSHA string + OriginalLineNumber int32 + OriginalPath string + Text string +} + +// BlameCommit describes per-commit metadata referenced by BlameLine entries. +type BlameCommit struct { + 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.Blame. +type BlameResult struct { + Ref string + Path string + Commit string + Lines []BlameLine + Commits map[string]BlameCommit +} + // NoteAuthor identifies note author. type NoteAuthor struct { Name string diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index c96ebb6..35f90f7 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -254,6 +254,20 @@ 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" is the SHA the +# `ref` resolved to; "commits" is a deduped map of authoring commits referenced +# by lines[].commit_sha. +blame = await repo.get_blame( + path="src/main.go", + ref="main", + start_line=10, + end_line=30, + detect_moves=True, +) +for line in blame["lines"]: + author = blame["commits"][line["commit_sha"]] + print(f"{line['line_number']}: {author['author_name']}\t{line['text']}") + # Read a git note for a commit note = await repo.get_note(sha="abc123...") print(note["note"]) @@ -752,6 +766,18 @@ class Repo: ttl: Optional[int] = None, ) -> GetCommitResult: ... + async def get_blame( + self, + *, + path: str, + ref: Optional[str] = None, + ephemeral: Optional[bool] = None, + start_line: Optional[int] = None, + end_line: Optional[int] = 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..adaf9c8 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -8,6 +8,9 @@ from pierre_storage.errors import ApiError, RefUpdateError from pierre_storage.types import ( BaseRepo, + BlameCommit, + BlameLine, + BlameResult, BranchInfo, CommitInfo, CommitMetadata, @@ -68,6 +71,9 @@ "RefUpdateError", # Types "BaseRepo", + "BlameCommit", + "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 0537377..5db0d1c 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -16,6 +16,9 @@ ) from pierre_storage.errors import ApiError, RefUpdateError, infer_ref_update_reason from pierre_storage.types import ( + BlameCommit, + BlameLine, + BlameResult, BranchInfo, CommitBuilder, CommitInfo, @@ -1115,6 +1118,109 @@ async def get_commit( } return {"commit": commit} + async def get_blame( + self, + *, + path: str, + ref: Optional[str] = None, + ephemeral: Optional[bool] = None, + start_line: Optional[int] = None, + end_line: Optional[int] = 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. + start_line: 1-based inclusive start line. Both ``start_line`` and + ``end_line`` zero blames the whole file. + end_line: 1-based inclusive end line. + detect_moves: Follow the file across renames and copies. + ttl: Token TTL in seconds. + + Returns: + Per-line attribution plus a deduped commits map. + """ + path_clean = path.strip() + if not path_clean: + 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: Dict[str, str] = {"path": path_clean} + if ref is not None and ref.strip(): + params["ref"] = ref.strip() + if ephemeral: + params["ephemeral"] = "true" + if start_line: + params["start_line"] = str(start_line) + if end_line: + params["end_line"] = str(end_line) + if detect_moves: + params["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] = [ + { + "line_number": entry["line_number"], + "commit_sha": entry["commit_sha"], + "original_line_number": entry["original_line_number"], + "original_path": entry["original_path"], + "text": entry["text"], + } + for entry in data.get("lines", []) + ] + + commits: Dict[str, BlameCommit] = {} + for sha, raw in (data.get("commits") or {}).items(): + author_time_raw = raw["author_time"] + committer_time_raw = raw["committer_time"] + commits[sha] = { + "previous_commit_sha": raw.get("previous_commit_sha"), + "author_name": raw["author_name"], + "author_email": raw["author_email"], + "author_time": datetime.fromisoformat( + author_time_raw.replace("Z", "+00:00") + ), + "raw_author_time": author_time_raw, + "committer_name": raw["committer_name"], + "committer_email": raw["committer_email"], + "committer_time": datetime.fromisoformat( + committer_time_raw.replace("Z", "+00:00") + ), + "raw_committer_time": committer_time_raw, + "summary": raw["summary"], + } + + return { + "ref": data["ref"], + "path": data["path"], + "commit": data["commit"], + "lines": lines, + "commits": commits, + } + 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 e288b4a..cf03bf2 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -274,6 +274,41 @@ class GetCommitResult(TypedDict): commit: CommitInfo +class BlameLine(TypedDict): + """A single line in blame results.""" + + line_number: int + commit_sha: str + original_line_number: int + original_path: str + text: str + + +class BlameCommit(TypedDict): + """Per-commit metadata referenced by blame lines.""" + + previous_commit_sha: Optional[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: str + lines: List[BlameLine] + commits: Dict[str, BlameCommit] + + # Git notes types class NoteReadResult(TypedDict): """Result from reading a git note.""" @@ -759,6 +794,20 @@ 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, + start_line: Optional[int] = None, + end_line: Optional[int] = 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/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index 56dae19..b906287 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -1335,6 +1335,170 @@ 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": "aaa111", + "lines": [ + { + "line_number": 10, + "commit_sha": "bbb222", + "original_line_number": 5, + "original_path": "src/x.go", + "text": "package main", + }, + { + "line_number": 11, + "commit_sha": "ccc333", + "original_line_number": 11, + "original_path": "src/old.go", + "text": "import \"fmt\"", + }, + ], + "commits": { + "bbb222": { + "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", + }, + "ccc333": { + "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", + start_line=10, + end_line=20, + detect_moves=True, + ) + + assert result["ref"] == "main" + assert result["path"] == "src/x.go" + assert result["commit"] == "aaa111" + assert len(result["lines"]) == 2 + assert result["lines"][0]["commit_sha"] == "bbb222" + assert result["lines"][1]["original_path"] == "src/old.go" + + commit = result["commits"]["bbb222"] + assert commit["author_name"] == "Alice" + assert commit["previous_commit_sha"] == "zzz000" + assert commit["raw_author_time"] == "2024-01-15T14:32:18Z" + assert isinstance(commit["author_time"], datetime) + assert commit["author_time"] == datetime( + 2024, 1, 15, 14, 32, 18, tzinfo=timezone.utc + ) + + 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"], + "start_line": ["10"], + "end_line": ["20"], + "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", + "lines": [], + "commits": {}, + } + + 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-typescript/README.md b/packages/code-storage-typescript/README.md index 1f2cb0b..e299757 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -244,6 +244,21 @@ 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 `commit` is the SHA the +// `ref` resolved to; `commits` is a deduped map of authoring commits referenced +// by `lines[].commitSha`. +const blame = await repo.getBlame({ + path: 'src/main.go', + ref: 'main', + startLine: 10, + endLine: 30, + detectMoves: true, +}); +for (const line of blame.lines) { + const author = blame.commits[line.commitSha]; + console.log(`${line.lineNumber}: ${author.authorName}\t${line.text}`); +} + // Read a git note for a commit const note = await repo.getNote({ sha: 'abc123...' }); console.log(note.note); @@ -515,6 +530,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; @@ -700,6 +716,45 @@ interface GetCommitResult { commit: CommitInfo; } +interface BlameOptions { + path: string; + ref?: string; + ephemeral?: boolean; + startLine?: number; + endLine?: number; + detectMoves?: boolean; + ttl?: number; +} + +interface BlameLine { + lineNumber: number; + commitSha: string; + originalLineNumber: number; + originalPath: string; + text: string; +} + +interface BlameCommit { + 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; + commit: string; // SHA the input ref resolved to at request time + lines: BlameLine[]; + commits: Record; // Per-authoring-commit metadata +} + interface GetBranchDiffOptions { branch: string; base?: string; // Defaults to 'main' diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 8d69adc..5e3224a 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,38 @@ function transformGetCommitResult(raw: GetCommitResponse): GetCommitResult { }; } +function transformBlameResult(raw: BlameResponse): BlameResult { + const commits: BlameResult['commits'] = {}; + for (const [sha, c] of Object.entries(raw.commits)) { + commits[sha] = { + previousCommitSha: c.previous_commit_sha, + authorName: c.author_name, + authorEmail: c.author_email, + authorTime: new Date(c.author_time), + rawAuthorTime: c.author_time, + committerName: c.committer_name, + committerEmail: c.committer_email, + committerTime: new Date(c.committer_time), + rawCommitterTime: c.committer_time, + summary: c.summary, + }; + } + + return { + ref: raw.ref, + path: raw.path, + commit: raw.commit, + lines: raw.lines.map((line) => ({ + lineNumber: line.line_number, + commitSha: line.commit_sha, + originalLineNumber: line.original_line_number, + originalPath: line.original_path, + text: line.text, + })), + commits, + }; +} + function transformFileWithMetadata(raw: RawFileWithMetadata): FileWithMetadata { return { path: raw.path, @@ -974,6 +1010,45 @@ class RepoImpl implements Repo { return transformGetCommitResult(raw); } + async getBlame(options: BlameOptions): Promise { + const path = options?.path?.trim(); + if (!path) { + 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 }; + const ref = options.ref?.trim(); + if (ref) { + params.ref = ref; + } + if (options.ephemeral) { + params.ephemeral = 'true'; + } + if (options.startLine) { + params.start_line = String(options.startLine); + } + if (options.endLine) { + params.end_line = String(options.endLine); + } + 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..2c8cbdd 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -57,6 +57,33 @@ 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(), + text: z.string(), +}); + +export const blameCommitRawSchema = z.object({ + 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: z.string(), + lines: z.array(blameLineRawSchema), + commits: z.record(blameCommitRawSchema), +}); + export const repoBaseInfoSchema = z.object({ provider: z.string(), owner: z.string(), @@ -288,6 +315,9 @@ 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 BlameCommitRaw = 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 f692028..f4521f9 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -6,6 +6,9 @@ import type { CreateTagResponseRaw, DeleteBranchResponseRaw, DeleteTagResponseRaw, + BlameCommitRaw as SchemaBlameCommitRaw, + BlameLineRaw as SchemaBlameLineRaw, + BlameResponseRaw, GetBranchDiffResponseRaw, GetCommitDiffResponseRaw, GetCommitResponseRaw, @@ -76,6 +79,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; @@ -440,6 +444,47 @@ export interface GetCommitResult { commit: CommitInfo; } +// Blame API types +export interface BlameOptions extends GitStorageInvocationOptions { + path: string; + ref?: string; + ephemeral?: boolean; + startLine?: number; + endLine?: number; + detectMoves?: boolean; +} + +export type BlameResponse = BlameResponseRaw; + +export interface BlameLine { + lineNumber: number; + commitSha: string; + originalLineNumber: number; + originalPath: string; + text: string; +} + +export interface BlameCommit { + 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; + commit: string; + lines: BlameLine[]; + commits: Record; +} + // 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 3a36f1c..3039070 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -318,6 +318,150 @@ 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.get('start_line')).toBe('10'); + expect(requestUrl.searchParams.get('end_line')).toBe('20'); + 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: 'aaa111', + lines: [ + { + line_number: 10, + commit_sha: 'bbb222', + original_line_number: 5, + original_path: 'src/x.go', + text: 'package main', + }, + { + line_number: 11, + commit_sha: 'ccc333', + original_line_number: 11, + original_path: 'src/old.go', + text: 'import "fmt"', + }, + ], + commits: { + bbb222: { + 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', + }, + ccc333: { + 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', + startLine: 10, + endLine: 20, + detectMoves: true, + }); + + expect(result.ref).toBe('main'); + expect(result.path).toBe('src/x.go'); + expect(result.commit).toBe('aaa111'); + expect(result.lines).toHaveLength(2); + expect(result.lines[0].commitSha).toBe('bbb222'); + expect(result.lines[1].originalPath).toBe('src/old.go'); + + const commit = result.commits.bbb222; + expect(commit.authorName).toBe('Alice'); + expect(commit.previousCommitSha).toBe('zzz000'); + expect(commit.rawAuthorTime).toBe('2024-01-15T14:32:18Z'); + expect(commit.authorTime).toBeInstanceOf(Date); + expect(commit.authorTime.toISOString()).toBe('2024-01-15T14:32:18.000Z'); + }); + + 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', + 'start_line', + 'end_line', + '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', + lines: [], + commits: {}, + }), + } as any); + }); + + const result = await repo.getBlame({ path: 'src/x.go' }); + expect(result.lines).toEqual([]); + expect(result.commits).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 dd5fec2..ee249ed 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** | | | | @@ -436,6 +437,44 @@ 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&start_line=10&end_line=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), `start_line`/`end_line` (1-based inclusive; both zero +blames the whole file), `detect_moves` (follow renames and copies). +Response: +```json +{ + "ref": "main", + "path": "src/main.go", + "commit": "", + "lines": [{ + "line_number": 1, + "commit_sha": "...", + "original_line_number": 1, + "original_path": "src/main.go", + "text": "..." + }], + "commits": { + "": { + "previous_commit_sha": "...", + "author_name": "...", "author_email": "...", "author_time": "...", + "committer_name": "...", "committer_email": "...", "committer_time": "...", + "summary": "..." + } + } +} +``` +The top-level `commit` is the SHA the input ref resolved to. The `commits` map +holds per-commit metadata for every authoring commit referenced by `lines[]`, +deduped by SHA. Errors: `400` missing/invalid params, `404` ref/path not found. + ## POST /repos/grep — Search Content (Beta) ```bash From ce5100824c6ae25bbb42021b9b4c2650ba3a6f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Wed, 6 May 2026 15:23:53 -0700 Subject: [PATCH 2/7] Align blame SDK with gateway wire format The initial GetBlame implementation in all three SDKs decoded against the storage-layer connectrpc proto shape, but the gateway HTTP route flattens that before it goes on the wire. Production responses would have failed to parse (TS zod, Python KeyError) or silently dropped per-line author metadata (Go). Reshape the SDK types to the gateway shape (gateway/internal/gitapi/blame.go): - Top-level field renamed `commit` -> `commit_sha`. - `lines[]` carries `previous_commit_sha`, `author_*`, `committer_*`, and `summary` inline; the separate top-level `commits` map is gone. - The nonexistent `text` field is removed from BlameLine. - BlameCommit type / TypedDict / struct is dropped. Updates the SKILL.md response example and unit tests across all three packages to the same shape. Verified against the monorepo gateway TestBlameResponse_JSONFieldNames golden JSON. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/code-storage-go/repo.go | 34 ++++++------- packages/code-storage-go/repo_test.go | 41 +++++++++------- packages/code-storage-go/responses.go | 29 +++++------ packages/code-storage-go/types.go | 40 +++++++-------- .../pierre_storage/__init__.py | 2 - .../pierre_storage/repo.py | 36 ++++++-------- .../pierre_storage/types.py | 13 ++--- .../code-storage-python/tests/test_repo.py | 49 +++++++++---------- packages/code-storage-typescript/src/index.ts | 30 +++++------- .../code-storage-typescript/src/schemas.ts | 8 +-- packages/code-storage-typescript/src/types.ts | 9 +--- .../tests/index.test.ts | 43 +++++++--------- skills/code-storage/SKILL.md | 24 ++++----- 13 files changed, 145 insertions(+), 213 deletions(-) diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index 3046ba1..d232b46 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -475,11 +475,10 @@ func (r *Repo) GetBlame(ctx context.Context, options BlameOptions) (BlameResult, } result := BlameResult{ - Ref: payload.Ref, - Path: payload.Path, - Commit: payload.Commit, - Lines: make([]BlameLine, len(payload.Lines)), - Commits: make(map[string]BlameCommit, len(payload.Commits)), + 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{ @@ -487,21 +486,16 @@ func (r *Repo) GetBlame(ctx context.Context, options BlameOptions) (BlameResult, CommitSHA: line.CommitSHA, OriginalLineNumber: line.OriginalLineNumber, OriginalPath: line.OriginalPath, - Text: line.Text, - } - } - for sha, c := range payload.Commits { - result.Commits[sha] = BlameCommit{ - PreviousCommitSHA: c.PreviousCommitSHA, - AuthorName: c.AuthorName, - AuthorEmail: c.AuthorEmail, - AuthorTime: parseTime(c.AuthorTime), - RawAuthorTime: c.AuthorTime, - CommitterName: c.CommitterName, - CommitterEmail: c.CommitterEmail, - CommitterTime: parseTime(c.CommitterTime), - RawCommitterTime: c.CommitterTime, - Summary: c.Summary, + 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, } } diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 0816869..1706f8a 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -1394,15 +1394,11 @@ func TestBlame(t *testing.T) { _, _ = w.Write([]byte(`{ "ref": "main", "path": "src/x.go", - "commit": "aaa111", + "commit_sha": "aaa111", "lines": [ - {"line_number": 10, "commit_sha": "bbb222", "original_line_number": 5, "original_path": "src/x.go", "text": "package main"}, - {"line_number": 11, "commit_sha": "ccc333", "original_line_number": 11, "original_path": "src/old.go", "text": "import \"fmt\""} - ], - "commits": { - "bbb222": {"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"}, - "ccc333": {"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"} - } + {"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() @@ -1423,24 +1419,31 @@ func TestBlame(t *testing.T) { if err != nil { t.Fatalf("blame error: %v", err) } - if result.Ref != "main" || result.Path != "src/x.go" || result.Commit != "aaa111" { + 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)) } - if result.Lines[0].CommitSHA != "bbb222" || result.Lines[0].LineNumber != 10 { - t.Fatalf("unexpected first line: %+v", result.Lines[0]) + first := result.Lines[0] + if first.CommitSHA != "bbb222" || first.LineNumber != 10 { + t.Fatalf("unexpected first line: %+v", first) } - if result.Lines[1].OriginalPath != "src/old.go" { - t.Fatalf("unexpected original_path: %q", result.Lines[1].OriginalPath) + if first.AuthorName != "Alice" || first.PreviousCommitSHA != "zzz000" { + t.Fatalf("unexpected first-line author metadata: %+v", first) } - commit := result.Commits["bbb222"] - if commit.AuthorName != "Alice" || commit.PreviousCommitSHA != "zzz000" { - t.Fatalf("unexpected commit metadata: %+v", commit) + 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 commit.AuthorTime.IsZero() || commit.RawAuthorTime != "2024-01-15T14:32:18Z" { - t.Fatalf("unexpected author time: %+v", commit) + if second.AuthorName != "Bob" { + t.Fatalf("unexpected second-line author: %q", second.AuthorName) } } @@ -1456,7 +1459,7 @@ func TestBlameOmitsEmptyParams(t *testing.T) { } } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ref":"main","path":"src/x.go","commit":"sha","lines":[],"commits":{}}`)) + _, _ = w.Write([]byte(`{"ref":"main","path":"src/x.go","commit_sha":"sha","lines":[]}`)) })) defer server.Close() diff --git a/packages/code-storage-go/responses.go b/packages/code-storage-go/responses.go index 109fae9..65cde81 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -48,11 +48,10 @@ type getCommitResponse struct { } type blameResponse struct { - Ref string `json:"ref"` - Path string `json:"path"` - Commit string `json:"commit"` - Lines []blameLineRaw `json:"lines"` - Commits map[string]blameCommitRaw `json:"commits"` + Ref string `json:"ref"` + Path string `json:"path"` + CommitSHA string `json:"commit_sha"` + Lines []blameLineRaw `json:"lines"` } type blameLineRaw struct { @@ -60,18 +59,14 @@ type blameLineRaw struct { CommitSHA string `json:"commit_sha"` OriginalLineNumber int32 `json:"original_line_number"` OriginalPath string `json:"original_path"` - Text string `json:"text"` -} - -type blameCommitRaw struct { - 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"` + 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 { diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 10ebf46..10aa096 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -489,30 +489,24 @@ type BlameLine struct { CommitSHA string OriginalLineNumber int32 OriginalPath string - Text string -} - -// BlameCommit describes per-commit metadata referenced by BlameLine entries. -type BlameCommit struct { - 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.Blame. + 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 - Commit string - Lines []BlameLine - Commits map[string]BlameCommit + Ref string + Path string + CommitSHA string + Lines []BlameLine } // NoteAuthor identifies note author. diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index adaf9c8..f895975 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -8,7 +8,6 @@ from pierre_storage.errors import ApiError, RefUpdateError from pierre_storage.types import ( BaseRepo, - BlameCommit, BlameLine, BlameResult, BranchInfo, @@ -71,7 +70,6 @@ "RefUpdateError", # Types "BaseRepo", - "BlameCommit", "BlameLine", "BlameResult", "BranchInfo", diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 5db0d1c..1014168 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -16,7 +16,6 @@ ) from pierre_storage.errors import ApiError, RefUpdateError, infer_ref_update_reason from pierre_storage.types import ( - BlameCommit, BlameLine, BlameResult, BranchInfo, @@ -1181,44 +1180,39 @@ async def get_blame( response.raise_for_status() data = response.json() - lines: List[BlameLine] = [ - { + 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"], - "text": entry["text"], - } - for entry in data.get("lines", []) - ] - - commits: Dict[str, BlameCommit] = {} - for sha, raw in (data.get("commits") or {}).items(): - author_time_raw = raw["author_time"] - committer_time_raw = raw["committer_time"] - commits[sha] = { - "previous_commit_sha": raw.get("previous_commit_sha"), - "author_name": raw["author_name"], - "author_email": raw["author_email"], + "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": raw["committer_name"], - "committer_email": raw["committer_email"], + "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": raw["summary"], + "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": data["commit"], + "commit_sha": data["commit_sha"], "lines": lines, - "commits": commits, } async def get_note( diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index cf03bf2..11676ff 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -275,19 +275,13 @@ class GetCommitResult(TypedDict): class BlameLine(TypedDict): - """A single line in blame results.""" + """A single line in blame results with inline commit metadata.""" line_number: int commit_sha: str original_line_number: int original_path: str - text: str - - -class BlameCommit(TypedDict): - """Per-commit metadata referenced by blame lines.""" - - previous_commit_sha: Optional[str] + previous_commit_sha: NotRequired[str] author_name: str author_email: str author_time: datetime @@ -304,9 +298,8 @@ class BlameResult(TypedDict): ref: str path: str - commit: str + commit_sha: str lines: List[BlameLine] - commits: Dict[str, BlameCommit] # Git notes types diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index b906287..a0a3aa2 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -1352,25 +1352,13 @@ async def test_get_blame(self, git_storage_options: dict) -> None: blame_response.json.return_value = { "ref": "main", "path": "src/x.go", - "commit": "aaa111", + "commit_sha": "aaa111", "lines": [ { "line_number": 10, "commit_sha": "bbb222", "original_line_number": 5, "original_path": "src/x.go", - "text": "package main", - }, - { - "line_number": 11, - "commit_sha": "ccc333", - "original_line_number": 11, - "original_path": "src/old.go", - "text": "import \"fmt\"", - }, - ], - "commits": { - "bbb222": { "previous_commit_sha": "zzz000", "author_name": "Alice", "author_email": "alice@example.com", @@ -1380,7 +1368,11 @@ async def test_get_blame(self, git_storage_options: dict) -> None: "committer_time": "2024-01-15T14:32:18Z", "summary": "init", }, - "ccc333": { + { + "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", @@ -1389,7 +1381,7 @@ async def test_get_blame(self, git_storage_options: dict) -> None: "committer_time": "2024-02-20T09:00:00Z", "summary": "fix", }, - }, + ], } with patch("httpx.AsyncClient") as mock_client: @@ -1408,20 +1400,24 @@ async def test_get_blame(self, git_storage_options: dict) -> None: assert result["ref"] == "main" assert result["path"] == "src/x.go" - assert result["commit"] == "aaa111" + assert result["commit_sha"] == "aaa111" assert len(result["lines"]) == 2 - assert result["lines"][0]["commit_sha"] == "bbb222" - assert result["lines"][1]["original_path"] == "src/old.go" - - commit = result["commits"]["bbb222"] - assert commit["author_name"] == "Alice" - assert commit["previous_commit_sha"] == "zzz000" - assert commit["raw_author_time"] == "2024-01-15T14:32:18Z" - assert isinstance(commit["author_time"], datetime) - assert commit["author_time"] == datetime( + + 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") @@ -1457,9 +1453,8 @@ async def test_get_blame_omits_optional_params(self, git_storage_options: dict) blame_response.json.return_value = { "ref": "main", "path": "src/x.go", - "commit": "sha", + "commit_sha": "sha", "lines": [], - "commits": {}, } with patch("httpx.AsyncClient") as mock_client: diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 5e3224a..9b2bf36 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -362,34 +362,26 @@ function transformGetCommitResult(raw: GetCommitResponse): GetCommitResult { } function transformBlameResult(raw: BlameResponse): BlameResult { - const commits: BlameResult['commits'] = {}; - for (const [sha, c] of Object.entries(raw.commits)) { - commits[sha] = { - previousCommitSha: c.previous_commit_sha, - authorName: c.author_name, - authorEmail: c.author_email, - authorTime: new Date(c.author_time), - rawAuthorTime: c.author_time, - committerName: c.committer_name, - committerEmail: c.committer_email, - committerTime: new Date(c.committer_time), - rawCommitterTime: c.committer_time, - summary: c.summary, - }; - } - return { ref: raw.ref, path: raw.path, - commit: raw.commit, + 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, - text: line.text, + 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, })), - commits, }; } diff --git a/packages/code-storage-typescript/src/schemas.ts b/packages/code-storage-typescript/src/schemas.ts index 2c8cbdd..0b22d67 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -62,10 +62,6 @@ export const blameLineRawSchema = z.object({ commit_sha: z.string(), original_line_number: z.number(), original_path: z.string(), - text: z.string(), -}); - -export const blameCommitRawSchema = z.object({ previous_commit_sha: z.string().optional(), author_name: z.string(), author_email: z.string(), @@ -79,9 +75,8 @@ export const blameCommitRawSchema = z.object({ export const blameResponseSchema = z.object({ ref: z.string(), path: z.string(), - commit: z.string(), + commit_sha: z.string(), lines: z.array(blameLineRawSchema), - commits: z.record(blameCommitRawSchema), }); export const repoBaseInfoSchema = z.object({ @@ -316,7 +311,6 @@ export type RawCommitInfo = z.infer; export type ListCommitsResponseRaw = z.infer; export type GetCommitResponseRaw = z.infer; export type BlameLineRaw = z.infer; -export type BlameCommitRaw = z.infer; export type BlameResponseRaw = z.infer; export type RawRepoBaseInfo = z.infer; export type RawRepoInfo = z.infer; diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index f4521f9..9b8e589 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -6,8 +6,6 @@ import type { CreateTagResponseRaw, DeleteBranchResponseRaw, DeleteTagResponseRaw, - BlameCommitRaw as SchemaBlameCommitRaw, - BlameLineRaw as SchemaBlameLineRaw, BlameResponseRaw, GetBranchDiffResponseRaw, GetCommitDiffResponseRaw, @@ -461,10 +459,6 @@ export interface BlameLine { commitSha: string; originalLineNumber: number; originalPath: string; - text: string; -} - -export interface BlameCommit { previousCommitSha?: string; authorName: string; authorEmail: string; @@ -480,9 +474,8 @@ export interface BlameCommit { export interface BlameResult { ref: string; path: string; - commit: string; + commitSha: string; lines: BlameLine[]; - commits: Record; } // Git notes API types diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index 3039070..8df90fc 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -344,25 +344,13 @@ describe('GitStorage', () => { json: async () => ({ ref: 'main', path: 'src/x.go', - commit: 'aaa111', + commit_sha: 'aaa111', lines: [ { line_number: 10, commit_sha: 'bbb222', original_line_number: 5, original_path: 'src/x.go', - text: 'package main', - }, - { - line_number: 11, - commit_sha: 'ccc333', - original_line_number: 11, - original_path: 'src/old.go', - text: 'import "fmt"', - }, - ], - commits: { - bbb222: { previous_commit_sha: 'zzz000', author_name: 'Alice', author_email: 'alice@example.com', @@ -372,7 +360,11 @@ describe('GitStorage', () => { committer_time: '2024-01-15T14:32:18Z', summary: 'init', }, - ccc333: { + { + 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', @@ -381,7 +373,7 @@ describe('GitStorage', () => { committer_time: '2024-02-20T09:00:00Z', summary: 'fix', }, - }, + ], }), } as any); }); @@ -396,17 +388,17 @@ describe('GitStorage', () => { expect(result.ref).toBe('main'); expect(result.path).toBe('src/x.go'); - expect(result.commit).toBe('aaa111'); + 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'); - - const commit = result.commits.bbb222; - expect(commit.authorName).toBe('Alice'); - expect(commit.previousCommitSha).toBe('zzz000'); - expect(commit.rawAuthorTime).toBe('2024-01-15T14:32:18Z'); - expect(commit.authorTime).toBeInstanceOf(Date); - expect(commit.authorTime.toISOString()).toBe('2024-01-15T14:32:18.000Z'); + expect(result.lines[1].previousCommitSha).toBeUndefined(); + expect(result.lines[1].authorName).toBe('Bob'); }); it('omits optional blame params when not provided', async () => { @@ -432,16 +424,15 @@ describe('GitStorage', () => { json: async () => ({ ref: 'main', path: 'src/x.go', - commit: 'sha', + commit_sha: 'sha', lines: [], - commits: {}, }), } as any); }); const result = await repo.getBlame({ path: 'src/x.go' }); + expect(result.commitSha).toBe('sha'); expect(result.lines).toEqual([]); - expect(result.commits).toEqual({}); }); it('rejects blame when path is missing or blank', async () => { diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index ee249ed..5c4d38c 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -453,27 +453,23 @@ Response: { "ref": "main", "path": "src/main.go", - "commit": "", + "commit_sha": "", "lines": [{ "line_number": 1, "commit_sha": "...", "original_line_number": 1, "original_path": "src/main.go", - "text": "..." - }], - "commits": { - "": { - "previous_commit_sha": "...", - "author_name": "...", "author_email": "...", "author_time": "...", - "committer_name": "...", "committer_email": "...", "committer_time": "...", - "summary": "..." - } - } + "previous_commit_sha": "...", + "author_name": "...", "author_email": "...", "author_time": "...", + "committer_name": "...", "committer_email": "...", "committer_time": "...", + "summary": "..." + }] } ``` -The top-level `commit` is the SHA the input ref resolved to. The `commits` map -holds per-commit metadata for every authoring commit referenced by `lines[]`, -deduped by SHA. Errors: `400` missing/invalid params, `404` ref/path not found. +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) From dac2f1c82dc4ddc3fc30f3cb903122ecae46f06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Fri, 8 May 2026 10:07:17 -0700 Subject: [PATCH 3/7] fix read me --- packages/code-storage-python/README.md | 9 ++++----- packages/code-storage-typescript/README.md | 16 +++++----------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 35f90f7..91a86b1 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -254,9 +254,9 @@ 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" is the SHA the -# `ref` resolved to; "commits" is a deduped map of authoring commits referenced -# by lines[].commit_sha. +# 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", @@ -265,8 +265,7 @@ blame = await repo.get_blame( detect_moves=True, ) for line in blame["lines"]: - author = blame["commits"][line["commit_sha"]] - print(f"{line['line_number']}: {author['author_name']}\t{line['text']}") + 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...") diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index e299757..9bee4f8 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -244,9 +244,9 @@ 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 `commit` is the SHA the -// `ref` resolved to; `commits` is a deduped map of authoring commits referenced -// by `lines[].commitSha`. +// 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', @@ -255,8 +255,7 @@ const blame = await repo.getBlame({ detectMoves: true, }); for (const line of blame.lines) { - const author = blame.commits[line.commitSha]; - console.log(`${line.lineNumber}: ${author.authorName}\t${line.text}`); + console.log(`${line.lineNumber} (${line.commitSha.slice(0, 7)}): ${line.authorName} — ${line.summary}`); } // Read a git note for a commit @@ -731,10 +730,6 @@ interface BlameLine { commitSha: string; originalLineNumber: number; originalPath: string; - text: string; -} - -interface BlameCommit { previousCommitSha?: string; authorName: string; authorEmail: string; @@ -750,9 +745,8 @@ interface BlameCommit { interface BlameResult { ref: string; // The ref passed in (or default branch resolved) path: string; - commit: string; // SHA the input ref resolved to at request time + commitSha: string; // SHA the input ref resolved to at request time lines: BlameLine[]; - commits: Record; // Per-authoring-commit metadata } interface GetBranchDiffOptions { From 664083630c57daeee1f47b396d2cecbcb07f4f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Fri, 8 May 2026 10:20:05 -0700 Subject: [PATCH 4/7] not trim path --- packages/code-storage-go/repo.go | 5 ++--- packages/code-storage-python/pierre_storage/repo.py | 5 ++--- packages/code-storage-typescript/src/index.ts | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index d232b46..b72c382 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -434,8 +434,7 @@ func (r *Repo) GetCommit(ctx context.Context, options GetCommitOptions) (GetComm // GetBlame returns per-line authorship for a file at a ref. func (r *Repo) GetBlame(ctx context.Context, options BlameOptions) (BlameResult, error) { - path := strings.TrimSpace(options.Path) - if path == "" { + if strings.TrimSpace(options.Path) == "" { return BlameResult{}, errors.New("getBlame path is required") } @@ -446,7 +445,7 @@ func (r *Repo) GetBlame(ctx context.Context, options BlameOptions) (BlameResult, } params := url.Values{} - params.Set("path", path) + params.Set("path", options.Path) if ref := strings.TrimSpace(options.Ref); ref != "" { params.Set("ref", ref) } diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 1014168..1d36f9f 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -1144,14 +1144,13 @@ async def get_blame( Returns: Per-line attribution plus a deduped commits map. """ - path_clean = path.strip() - if not path_clean: + 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: Dict[str, str] = {"path": path_clean} + params: Dict[str, str] = {"path": path} if ref is not None and ref.strip(): params["ref"] = ref.strip() if ephemeral: diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 9b2bf36..2bb2724 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -1003,8 +1003,7 @@ class RepoImpl implements Repo { } async getBlame(options: BlameOptions): Promise { - const path = options?.path?.trim(); - if (!path) { + if (!options?.path?.trim()) { throw new Error('getBlame path is required'); } @@ -1014,7 +1013,7 @@ class RepoImpl implements Repo { ttl, }); - const params: Record = { path }; + const params: Record = { path: options.path }; const ref = options.ref?.trim(); if (ref) { params.ref = ref; From 0e7c65d3dc82d76d18c9e1e053c628f06a4521be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Fri, 8 May 2026 11:04:34 -0700 Subject: [PATCH 5/7] fix start line and end line --- packages/code-storage-python/pierre_storage/repo.py | 10 +++++++--- skills/code-storage/SKILL.md | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 1d36f9f..02dd9eb 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -1135,9 +1135,13 @@ async def get_blame( ref: Branch, tag, or commit SHA to blame at. Defaults to the repository default branch. ephemeral: Resolve ``ref`` from the ephemeral namespace. - start_line: 1-based inclusive start line. Both ``start_line`` and - ``end_line`` zero blames the whole file. - end_line: 1-based inclusive end line. + start_line: 1-based inclusive start line. May be omitted on its + own — passing only ``end_line`` blames from line 1 through + that line. Omitting both blames the whole file. + end_line: 1-based inclusive end line. May be omitted on its + own — passing only ``start_line`` blames from that line + through EOF. When both are set, ``end_line`` must be + ≥ ``start_line``. detect_moves: Follow the file across renames and copies. ttl: Token TTL in seconds. diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 5c4d38c..38e09c8 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -446,8 +446,10 @@ curl "$CODE_STORAGE_BASE_URL/repos/blame?path=src/main.go&ref=main&start_line=10 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), `start_line`/`end_line` (1-based inclusive; both zero -blames the whole file), `detect_moves` (follow renames and copies). +the ephemeral namespace), `start_line`/`end_line` (1-based inclusive; either may +be omitted — only `start_line` blames to EOF, only `end_line` blames from line +1, both omitted blames the whole file; when both are set, `end_line` must be +≥ `start_line`), `detect_moves` (follow renames and copies). Response: ```json { From 4a05279548bcc23177dd7de0bdf8a088516fc704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Tue, 12 May 2026 02:39:55 +0800 Subject: [PATCH 6/7] -L fix --- packages/code-storage-go/README.md | 24 +++++++++++++++++ packages/code-storage-go/repo.go | 7 ++--- packages/code-storage-go/repo_test.go | 12 +++------ packages/code-storage-go/types.go | 3 +-- packages/code-storage-python/README.md | 6 ++--- .../pierre_storage/repo.py | 27 +++++++------------ .../pierre_storage/types.py | 3 +-- .../code-storage-python/tests/test_repo.py | 6 ++--- packages/code-storage-typescript/README.md | 6 ++--- packages/code-storage-typescript/src/index.ts | 9 +++---- packages/code-storage-typescript/src/types.ts | 3 +-- .../tests/index.test.ts | 9 +++---- skills/code-storage/SKILL.md | 11 ++++---- 13 files changed, 61 insertions(+), 65 deletions(-) diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index 56e0d6c..001eda6 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 diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index b72c382..2818fce 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -452,11 +452,8 @@ func (r *Repo) GetBlame(ctx context.Context, options BlameOptions) (BlameResult, if options.Ephemeral { params.Set("ephemeral", "true") } - if options.StartLine != 0 { - params.Set("start_line", strconv.FormatInt(int64(options.StartLine), 10)) - } - if options.EndLine != 0 { - params.Set("end_line", strconv.FormatInt(int64(options.EndLine), 10)) + for _, spec := range options.Ranges { + params.Add("range", spec) } if options.DetectMoves { params.Set("detect_moves", "true") diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 1706f8a..07ca598 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -1381,11 +1381,8 @@ func TestBlame(t *testing.T) { if got := q.Get("ref"); got != "main" { t.Fatalf("unexpected ref query: %q", got) } - if got := q.Get("start_line"); got != "10" { - t.Fatalf("unexpected start_line query: %q", got) - } - if got := q.Get("end_line"); got != "20" { - t.Fatalf("unexpected end_line 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) @@ -1412,8 +1409,7 @@ func TestBlame(t *testing.T) { result, err := repo.GetBlame(nil, BlameOptions{ Path: "src/x.go", Ref: "main", - StartLine: 10, - EndLine: 20, + Ranges: []string{"10,20", "/getUser/,+30"}, DetectMoves: true, }) if err != nil { @@ -1453,7 +1449,7 @@ func TestBlameOmitsEmptyParams(t *testing.T) { if q.Get("path") != "src/x.go" { t.Fatalf("expected path query") } - for _, key := range []string{"ref", "ephemeral", "start_line", "end_line", "detect_moves"} { + 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)) } diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 10aa096..f06c51f 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -478,8 +478,7 @@ type BlameOptions struct { Path string Ref string Ephemeral bool - StartLine int32 - EndLine int32 + Ranges []string DetectMoves bool } diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 91a86b1..5174155 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -260,8 +260,7 @@ print(result["commit"]["message"], result["commit"]["author_name"]) blame = await repo.get_blame( path="src/main.go", ref="main", - start_line=10, - end_line=30, + ranges=["10,30"], detect_moves=True, ) for line in blame["lines"]: @@ -771,8 +770,7 @@ class Repo: path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, - start_line: Optional[int] = None, - end_line: Optional[int] = None, + ranges: Optional[list[str]] = None, detect_moves: Optional[bool] = None, ttl: Optional[int] = None, ) -> BlameResult: ... diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 02dd9eb..00b01d4 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -1123,8 +1123,7 @@ async def get_blame( path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, - start_line: Optional[int] = None, - end_line: Optional[int] = None, + ranges: Optional[List[str]] = None, detect_moves: Optional[bool] = None, ttl: Optional[int] = None, ) -> BlameResult: @@ -1135,13 +1134,8 @@ async def get_blame( ref: Branch, tag, or commit SHA to blame at. Defaults to the repository default branch. ephemeral: Resolve ``ref`` from the ephemeral namespace. - start_line: 1-based inclusive start line. May be omitted on its - own — passing only ``end_line`` blames from line 1 through - that line. Omitting both blames the whole file. - end_line: 1-based inclusive end line. May be omitted on its - own — passing only ``start_line`` blames from that line - through EOF. When both are set, ``end_line`` must be - ≥ ``start_line``. + 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. @@ -1154,17 +1148,16 @@ async def get_blame( ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl}) - params: Dict[str, str] = {"path": path} + params: List[tuple[str, str]] = [("path", path)] if ref is not None and ref.strip(): - params["ref"] = ref.strip() + params.append(("ref", ref.strip())) if ephemeral: - params["ephemeral"] = "true" - if start_line: - params["start_line"] = str(start_line) - if end_line: - params["end_line"] = str(end_line) + params.append(("ephemeral", "true")) + if ranges: + for spec in ranges: + params.append(("range", spec)) if detect_moves: - params["detect_moves"] = "true" + params.append(("detect_moves", "true")) url = ( f"{self.api_base_url}/api/v{self.api_version}/repos/blame" diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 11676ff..3e77f8c 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -793,8 +793,7 @@ async def get_blame( path: str, ref: Optional[str] = None, ephemeral: Optional[bool] = None, - start_line: Optional[int] = None, - end_line: Optional[int] = None, + ranges: Optional[List[str]] = None, detect_moves: Optional[bool] = None, ttl: Optional[int] = None, ) -> BlameResult: diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index a0a3aa2..e30bbde 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -1393,8 +1393,7 @@ async def test_get_blame(self, git_storage_options: dict) -> None: result = await repo.get_blame( path="src/x.go", ref="main", - start_line=10, - end_line=20, + ranges=["10,20", "/getUser/,+30"], detect_moves=True, ) @@ -1424,8 +1423,7 @@ async def test_get_blame(self, git_storage_options: dict) -> None: assert parse_qs(parsed.query) == { "path": ["src/x.go"], "ref": ["main"], - "start_line": ["10"], - "end_line": ["20"], + "range": ["10,20", "/getUser/,+30"], "detect_moves": ["true"], } diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index 9bee4f8..4636512 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -250,8 +250,7 @@ console.log(commit.message, commit.authorName); const blame = await repo.getBlame({ path: 'src/main.go', ref: 'main', - startLine: 10, - endLine: 30, + ranges: ['10,30'], detectMoves: true, }); for (const line of blame.lines) { @@ -719,8 +718,7 @@ interface BlameOptions { path: string; ref?: string; ephemeral?: boolean; - startLine?: number; - endLine?: number; + ranges?: string[]; detectMoves?: boolean; ttl?: number; } diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 2bb2724..f08b7b2 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -1013,7 +1013,7 @@ class RepoImpl implements Repo { ttl, }); - const params: Record = { path: options.path }; + const params: Record = { path: options.path }; const ref = options.ref?.trim(); if (ref) { params.ref = ref; @@ -1021,11 +1021,8 @@ class RepoImpl implements Repo { if (options.ephemeral) { params.ephemeral = 'true'; } - if (options.startLine) { - params.start_line = String(options.startLine); - } - if (options.endLine) { - params.end_line = String(options.endLine); + if (options.ranges && options.ranges.length > 0) { + params.range = options.ranges; } if (options.detectMoves) { params.detect_moves = 'true'; diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 9b8e589..d9d8cc3 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -447,8 +447,7 @@ export interface BlameOptions extends GitStorageInvocationOptions { path: string; ref?: string; ephemeral?: boolean; - startLine?: number; - endLine?: number; + ranges?: string[]; detectMoves?: boolean; } diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index 8df90fc..ab09771 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -328,8 +328,7 @@ describe('GitStorage', () => { 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.get('start_line')).toBe('10'); - expect(requestUrl.searchParams.get('end_line')).toBe('20'); + expect(requestUrl.searchParams.getAll('range')).toEqual(['10,20', '/getUser/,+30']); expect(requestUrl.searchParams.get('detect_moves')).toBe('true'); const headers = (init?.headers ?? {}) as Record; @@ -381,8 +380,7 @@ describe('GitStorage', () => { const result = await repo.getBlame({ path: 'src/x.go', ref: 'main', - startLine: 10, - endLine: 20, + ranges: ['10,20', '/getUser/,+30'], detectMoves: true, }); @@ -411,8 +409,7 @@ describe('GitStorage', () => { for (const key of [ 'ref', 'ephemeral', - 'start_line', - 'end_line', + 'range', 'detect_moves', ]) { expect(requestUrl.searchParams.has(key)).toBe(false); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 38e09c8..125596f 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -440,16 +440,17 @@ 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&start_line=10&end_line=30&detect_moves=true" \ +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), `start_line`/`end_line` (1-based inclusive; either may -be omitted — only `start_line` blames to EOF, only `end_line` blames from line -1, both omitted blames the whole file; when both are set, `end_line` must be -≥ `start_line`), `detect_moves` (follow renames and copies). +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 { From f7f7668417a811be21ea708462493d70d3b73f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=80a?= <76558220+rkdud007@users.noreply.github.com> Date: Tue, 12 May 2026 03:42:11 +0800 Subject: [PATCH 7/7] version minor bump --- packages/code-storage-go/README.md | 4 ++-- packages/code-storage-go/version.go | 2 +- packages/code-storage-python/pierre_storage/version.py | 2 +- packages/code-storage-python/pyproject.toml | 2 +- packages/code-storage-python/uv.lock | 2 +- packages/code-storage-typescript/package.json | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index 84343b5..f0a208b 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -224,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/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/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/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/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",