diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index a48d0a9..b2c730a 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -79,6 +79,33 @@ fmt.Println(result.Files[0].LastCommitSHA) fmt.Println(result.Commits[result.Files[0].LastCommitSHA].Author) ``` +### Manage tags + +```go +tags, err := repo.ListTags(context.Background(), storage.ListTagsOptions{Limit: 10}) +if err != nil { + log.Fatal(err) +} +fmt.Println(tags.Tags) + +createdTag, err := repo.CreateTag(context.Background(), storage.CreateTagOptions{ + Name: "v1.0.0", + Target: "0123456789abcdef0123456789abcdef01234567", +}) +if err != nil { + log.Fatal(err) +} +fmt.Println(createdTag.Message) + +deletedTag, err := repo.DeleteTag(context.Background(), storage.DeleteTagOptions{ + Name: "v1.0.0", +}) +if err != nil { + log.Fatal(err) +} +fmt.Println(deletedTag.Message) +``` + ### Create a commit ```go @@ -161,5 +188,5 @@ Make sure the version in `version.go` (`PackageVersion`) matches the tag before - Generate authenticated git remote URLs, including import and ephemeral variants. - Read files, read file metadata, download archives, list branches/commits, and run grep queries. - Create commits via streaming commit-pack or diff-commit endpoints. -- Restore commits, manage git notes, and create branches. +- Restore commits, manage git notes, create branches, and manage tags. - Validate webhook signatures and parse push events. diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index cabe264..3f7a292 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -294,6 +294,50 @@ func (r *Repo) ListBranches(ctx context.Context, options ListBranchesOptions) (L return result, nil } +// ListTags lists tags. +func (r *Repo) ListTags(ctx context.Context, options ListTagsOptions) (ListTagsResult, error) { + ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead}, TTL: ttl}) + if err != nil { + return ListTagsResult{}, err + } + + params := url.Values{} + if options.Cursor != "" { + params.Set("cursor", options.Cursor) + } + if options.Limit > 0 { + params.Set("limit", itoa(options.Limit)) + } + if len(params) == 0 { + params = nil + } + + resp, err := r.client.api.get(ctx, "repos/tags", params, jwtToken, nil) + if err != nil { + return ListTagsResult{}, err + } + defer resp.Body.Close() + + var payload listTagsResponse + if err := decodeJSON(resp, &payload); err != nil { + return ListTagsResult{}, err + } + + result := ListTagsResult{HasMore: payload.HasMore} + if payload.NextCursor != "" { + result.NextCursor = payload.NextCursor + } + for _, tag := range payload.Tags { + result.Tags = append(result.Tags, TagInfo{ + Cursor: tag.Cursor, + Name: tag.Name, + SHA: tag.SHA, + }) + } + return result, nil +} + // ListCommits lists commits. func (r *Repo) ListCommits(ctx context.Context, options ListCommitsOptions) (ListCommitsResult, error) { ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) @@ -768,6 +812,80 @@ func (r *Repo) CreateBranch(ctx context.Context, options CreateBranchOptions) (C return result, nil } +// CreateTag creates a tag. +func (r *Repo) CreateTag(ctx context.Context, options CreateTagOptions) (CreateTagResult, error) { + name := strings.TrimSpace(options.Name) + if name == "" { + return CreateTagResult{}, errors.New("createTag name is required") + } + if strings.HasPrefix(name, "refs/") { + return CreateTagResult{}, errors.New("createTag name must not start with refs/") + } + + target := strings.TrimSpace(options.Target) + if target == "" { + return CreateTagResult{}, errors.New("createTag target is required") + } + + ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + if err != nil { + return CreateTagResult{}, err + } + + body := &createTagRequest{Name: name, Target: target} + resp, err := r.client.api.post(ctx, "repos/tags", nil, body, jwtToken, nil) + if err != nil { + return CreateTagResult{}, err + } + defer resp.Body.Close() + + var payload createTagResponse + if err := decodeJSON(resp, &payload); err != nil { + return CreateTagResult{}, err + } + + return CreateTagResult{ + Name: payload.Name, + SHA: payload.SHA, + Message: payload.Message, + }, nil +} + +// DeleteTag deletes a tag. +func (r *Repo) DeleteTag(ctx context.Context, options DeleteTagOptions) (DeleteTagResult, error) { + name := strings.TrimSpace(options.Name) + if name == "" { + return DeleteTagResult{}, errors.New("deleteTag name is required") + } + if strings.HasPrefix(name, "refs/") { + return DeleteTagResult{}, errors.New("deleteTag name must not start with refs/") + } + + ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead, PermissionGitWrite}, TTL: ttl}) + if err != nil { + return DeleteTagResult{}, err + } + + body := &deleteTagRequest{Name: name} + resp, err := r.client.api.delete(ctx, "repos/tags", nil, body, jwtToken, nil) + if err != nil { + return DeleteTagResult{}, err + } + defer resp.Body.Close() + + var payload deleteTagResponse + if err := decodeJSON(resp, &payload); err != nil { + return DeleteTagResult{}, err + } + + return DeleteTagResult{ + Name: payload.Name, + Message: payload.Message, + }, nil +} + // RestoreCommit restores a commit into a branch. func (r *Repo) RestoreCommit(ctx context.Context, options RestoreCommitOptions) (RestoreCommitResult, error) { targetBranch := strings.TrimSpace(options.TargetBranch) diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 50ed1b0..1f1d2e0 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -551,6 +551,126 @@ func TestCreateBranchPayloadAndResponse(t *testing.T) { } } +func TestListTags(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/tags" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + q := r.URL.Query() + if q.Get("cursor") != "start" || q.Get("limit") != "17" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + headerAgent := r.Header.Get("Code-Storage-Agent") + if headerAgent == "" || !strings.Contains(headerAgent, "code-storage-go-sdk/") { + t.Fatalf("missing Code-Storage-Agent header") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tags":[{"cursor":"c1","name":"v1.0.0","sha":"abc123"},{"cursor":"c2","name":"v1.0.1","sha":"def456"}],"next_cursor":"next","has_more":true}`)) + })) + 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.ListTags(nil, ListTagsOptions{Cursor: "start", Limit: 17}) + if err != nil { + t.Fatalf("list tags error: %v", err) + } + if !result.HasMore || result.NextCursor != "next" { + t.Fatalf("unexpected pagination: %+v", result) + } + if len(result.Tags) != 2 || result.Tags[0].Name != "v1.0.0" || result.Tags[1].SHA != "def456" { + t.Fatalf("unexpected tags result: %+v", result) + } +} + +func TestCreateTag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/tags" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + var body createTagRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Name != "v1.0.0" || body.Target != "0123456789abcdef0123456789abcdef01234567" { + t.Fatalf("unexpected create tag payload: %+v", body) + } + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + claims := parseJWTFromToken(t, token) + if scopes, ok := claims["scopes"].([]interface{}); !ok || len(scopes) != 1 || scopes[0] != string(PermissionGitWrite) { + t.Fatalf("unexpected scopes: %#v", claims["scopes"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"v1.0.0","sha":"0123456789abcdef0123456789abcdef01234567","message":"tag created"}`)) + })) + 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.CreateTag(nil, CreateTagOptions{ + Name: "v1.0.0", + Target: "0123456789abcdef0123456789abcdef01234567", + }) + if err != nil { + t.Fatalf("create tag error: %v", err) + } + if result.Name != "v1.0.0" || result.Message != "tag created" { + t.Fatalf("unexpected create tag result: %+v", result) + } +} + +func TestDeleteTag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/tags" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodDelete { + t.Fatalf("unexpected method: %s", r.Method) + } + var body deleteTagRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Name != "v1.0.0" { + t.Fatalf("unexpected delete tag payload: %+v", body) + } + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + claims := parseJWTFromToken(t, token) + scopes, ok := claims["scopes"].([]interface{}) + if !ok || len(scopes) != 2 || scopes[0] != string(PermissionGitRead) || scopes[1] != string(PermissionGitWrite) { + t.Fatalf("unexpected scopes: %#v", claims["scopes"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"v1.0.0","message":"tag deleted"}`)) + })) + 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.DeleteTag(nil, DeleteTagOptions{Name: "v1.0.0"}) + if err != nil { + t.Fatalf("delete tag error: %v", err) + } + if result.Name != "v1.0.0" || result.Message != "tag deleted" { + t.Fatalf("unexpected delete tag result: %+v", result) + } +} + func TestRestoreCommitSuccess(t *testing.T) { var capturedBody map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/packages/code-storage-go/requests.go b/packages/code-storage-go/requests.go index 57bbac9..d0a920e 100644 --- a/packages/code-storage-go/requests.go +++ b/packages/code-storage-go/requests.go @@ -100,6 +100,15 @@ type createBranchRequest struct { TargetIsEphemeral bool `json:"target_is_ephemeral,omitempty"` } +type createTagRequest struct { + Name string `json:"name"` + Target string `json:"target"` +} + +type deleteTagRequest struct { + Name string `json:"name"` +} + // commitMetadataPayload is the JSON body for commit metadata. type commitMetadataPayload struct { TargetBranch string `json:"target_branch"` diff --git a/packages/code-storage-go/responses.go b/packages/code-storage-go/responses.go index 74887ef..de4da38 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -141,6 +141,29 @@ type createBranchResponse struct { CommitSHA string `json:"commit_sha"` } +type listTagsResponse struct { + Tags []tagInfoRaw `json:"tags"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` +} + +type tagInfoRaw struct { + Cursor string `json:"cursor"` + Name string `json:"name"` + SHA string `json:"sha"` +} + +type createTagResponse struct { + Name string `json:"name"` + SHA string `json:"sha"` + Message string `json:"message"` +} + +type deleteTagResponse struct { + Name string `json:"name"` + Message string `json:"message"` +} + type grepResponse struct { Query struct { Pattern string `json:"pattern"` diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index c363173..19bb6e7 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -302,6 +302,53 @@ type CreateBranchResult struct { CommitSHA string } +// ListTagsOptions configures list tags. +type ListTagsOptions struct { + InvocationOptions + Cursor string + Limit int +} + +// TagInfo describes a tag. +type TagInfo struct { + Cursor string + Name string + SHA string +} + +// ListTagsResult describes tags list. +type ListTagsResult struct { + Tags []TagInfo + NextCursor string + HasMore bool +} + +// CreateTagOptions configures tag creation. +type CreateTagOptions struct { + InvocationOptions + Name string + Target string +} + +// CreateTagResult describes tag creation result. +type CreateTagResult struct { + Name string + SHA string + Message string +} + +// DeleteTagOptions configures tag deletion. +type DeleteTagOptions struct { + InvocationOptions + Name string +} + +// DeleteTagResult describes tag deletion result. +type DeleteTagResult struct { + Name string + Message string +} + // ListCommitsOptions configures list commits. type ListCommitsOptions struct { InvocationOptions diff --git a/packages/code-storage-go/version.go b/packages/code-storage-go/version.go index 6fd44e4..36336fa 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.3.2" + PackageVersion = "0.4.0" ) func userAgent() string { diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 3fe6dd2..e9a2f73 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -206,6 +206,21 @@ branch_result = await repo.create_branch( ) print(branch_result["target_branch"], branch_result.get("commit_sha")) +# List tags +tags = await repo.list_tags(limit=10) +print(tags["tags"]) + +# Create a lightweight tag at a commit SHA +tag_result = await repo.create_tag( + name="v1.0.0", + target="0123456789abcdef0123456789abcdef01234567", +) +print(tag_result["message"]) + +# Delete a tag +delete_result = await repo.delete_tag(name="v1.0.0") +print(delete_result["message"]) + # List commits commits = await repo.list_commits( branch="main", # optional diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index 4dd71ed..c13365a 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -14,6 +14,8 @@ CommitResult, CommitSignature, CreateBranchResult, + CreateTagResult, + DeleteTagResult, DeleteRepoResult, DiffFileState, DiffStats, @@ -31,12 +33,14 @@ ListFilesResult, ListFilesWithMetadataResult, ListReposResult, + ListTagsResult, NoteReadResult, NoteWriteResult, RefUpdate, Repo, RepoInfo, RestoreCommitResult, + TagInfo, ) from pierre_storage.version import PACKAGE_VERSION from pierre_storage.webhook import ( @@ -62,10 +66,12 @@ "BranchInfo", "CommitMetadata", "CreateBranchResult", + "CreateTagResult", "CommitInfo", "CommitResult", "CommitSignature", "DeleteRepoResult", + "DeleteTagResult", "DiffFileState", "DiffStats", "FileWithMetadata", @@ -82,12 +88,14 @@ "ListFilesResult", "ListFilesWithMetadataResult", "ListReposResult", + "ListTagsResult", "NoteReadResult", "NoteWriteResult", "RefUpdate", "RepoInfo", "Repo", "RestoreCommitResult", + "TagInfo", # Webhook "WebhookPushEvent", "parse_signature_header", diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index f49248a..0a88e49 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -23,7 +23,9 @@ CommitResult, CommitSignature, CreateBranchResult, + CreateTagResult, CreateCommitOptions, + DeleteTagResult, DiffFileState, FileDiff, FileSource, @@ -38,10 +40,12 @@ ListCommitsResult, ListFilesResult, ListFilesWithMetadataResult, + ListTagsResult, NoteReadResult, NoteWriteResult, RefUpdate, RestoreCommitResult, + TagInfo, ) from pierre_storage.version import get_user_agent @@ -568,6 +572,168 @@ async def create_branch( result["commit_sha"] = commit_sha return result + async def list_tags( + self, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ttl: Optional[int] = None, + ) -> ListTagsResult: + """List tags in repository.""" + ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS + jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl}) + + params = {} + if cursor: + params["cursor"] = cursor + if limit is not None: + params["limit"] = str(limit) + + url = f"{self.api_base_url}/api/v{self.api_version}/repos/tags" + if params: + url += 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() + + tags: List[TagInfo] = [ + { + "cursor": tag["cursor"], + "name": tag["name"], + "sha": tag["sha"], + } + for tag in data["tags"] + ] + + return { + "tags": tags, + "next_cursor": data.get("next_cursor"), + "has_more": data["has_more"], + } + + async def create_tag( + self, + *, + name: str, + target: str, + ttl: Optional[int] = None, + ) -> CreateTagResult: + """Create a tag.""" + name_clean = name.strip() + if not name_clean: + raise ValueError("create_tag name is required") + if name_clean.startswith("refs/"): + raise ValueError("create_tag name must not start with refs/") + + target_clean = target.strip() + if not target_clean: + raise ValueError("create_tag target is required") + + ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) + jwt = self.generate_jwt( + self._id, + {"permissions": ["git:write"], "ttl": ttl_value}, + ) + + payload = {"name": name_clean, "target": target_clean} + url = f"{self.api_base_url}/api/v{self.api_version}/repos/tags" + + async with httpx.AsyncClient() as client: + response = await client.post( + url, + headers={ + "Authorization": f"Bearer {jwt}", + "Content-Type": "application/json", + "Code-Storage-Agent": get_user_agent(), + }, + json=payload, + timeout=180.0, + ) + + if response.status_code != 200: + message = "Create tag failed" + try: + error_data = response.json() + if isinstance(error_data, dict) and error_data.get("message"): + message = str(error_data["message"]) + elif isinstance(error_data, dict) and error_data.get("error"): + message = str(error_data["error"]) + else: + message = f"{message} with HTTP {response.status_code}" + except Exception: + message = f"{message} with HTTP {response.status_code}" + raise ApiError(message, status_code=response.status_code, response=response) + + data = response.json() + return { + "name": data["name"], + "sha": data["sha"], + "message": data["message"], + } + + async def delete_tag( + self, + *, + name: str, + ttl: Optional[int] = None, + ) -> DeleteTagResult: + """Delete a tag.""" + name_clean = name.strip() + if not name_clean: + raise ValueError("delete_tag name is required") + if name_clean.startswith("refs/"): + raise ValueError("delete_tag name must not start with refs/") + + ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) + jwt = self.generate_jwt( + self._id, + {"permissions": ["git:read", "git:write"], "ttl": ttl_value}, + ) + + url = f"{self.api_base_url}/api/v{self.api_version}/repos/tags" + + async with httpx.AsyncClient() as client: + response = await client.request( + "DELETE", + url, + headers={ + "Authorization": f"Bearer {jwt}", + "Content-Type": "application/json", + "Code-Storage-Agent": get_user_agent(), + }, + json={"name": name_clean}, + timeout=30.0, + ) + + if response.status_code != 200: + message = "Delete tag failed" + try: + error_data = response.json() + if isinstance(error_data, dict) and error_data.get("message"): + message = str(error_data["message"]) + elif isinstance(error_data, dict) and error_data.get("error"): + message = str(error_data["error"]) + else: + message = f"{message} with HTTP {response.status_code}" + except Exception: + message = f"{message} with HTTP {response.status_code}" + raise ApiError(message, status_code=response.status_code, response=response) + + data = response.json() + return { + "name": data["name"], + "message": data["message"], + } + async def promote_ephemeral_branch( self, *, diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 3d8ad10..cb7c38c 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -197,6 +197,37 @@ class CreateBranchResult(TypedDict): commit_sha: NotRequired[str] +class TagInfo(TypedDict): + """Information about a tag.""" + + cursor: str + name: str + sha: str + + +class ListTagsResult(TypedDict): + """Result from listing tags.""" + + tags: List[TagInfo] + next_cursor: Optional[str] + has_more: bool + + +class CreateTagResult(TypedDict): + """Result from creating a tag.""" + + name: str + sha: str + message: str + + +class DeleteTagResult(TypedDict): + """Result from deleting a tag.""" + + name: str + message: str + + # Removed: ListCommitsOptions - now uses **kwargs @@ -565,6 +596,35 @@ async def create_branch( """Create or promote a branch.""" ... + async def list_tags( + self, + *, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ttl: Optional[int] = None, + ) -> ListTagsResult: + """List tags in the repository.""" + ... + + async def create_tag( + self, + *, + name: str, + target: str, + ttl: Optional[int] = None, + ) -> CreateTagResult: + """Create a tag.""" + ... + + async def delete_tag( + self, + *, + name: str, + ttl: Optional[int] = None, + ) -> DeleteTagResult: + """Delete a tag.""" + ... + async def promote_ephemeral_branch( self, *, diff --git a/packages/code-storage-python/pyproject.toml b/packages/code-storage-python/pyproject.toml index bd1640e..b217144 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.4.4" +version = "1.5.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 83e8863..808f9c0 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -1006,6 +1006,103 @@ async def test_create_append_delete_note(self, git_storage_options: dict) -> Non assert delete_call.kwargs["json"] == {"sha": "abc123"} +class TestRepoTagOperations: + """Tests for tag operations.""" + + @pytest.mark.asyncio + async def test_list_tags(self, git_storage_options: dict) -> None: + """Test listing tags.""" + 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"} + + list_tags_response = MagicMock() + list_tags_response.status_code = 200 + list_tags_response.is_success = True + list_tags_response.raise_for_status = MagicMock() + list_tags_response.json.return_value = { + "tags": [ + {"cursor": "c1", "name": "v1.0.0", "sha": "abc123"}, + {"cursor": "c2", "name": "v1.0.1", "sha": "def456"}, + ], + "next_cursor": "next", + "has_more": True, + } + + 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=list_tags_response) + + repo = await storage.create_repo(id="test-repo") + result = await repo.list_tags(cursor="start", limit=17) + + assert result["next_cursor"] == "next" + assert result["has_more"] is True + assert result["tags"][0]["name"] == "v1.0.0" + assert result["tags"][1]["sha"] == "def456" + + list_call = client_instance.get.call_args + assert "cursor=start" in list_call.args[0] + assert "limit=17" in list_call.args[0] + + @pytest.mark.asyncio + async def test_create_and_delete_tag(self, git_storage_options: dict) -> None: + """Test creating and deleting tags.""" + 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"} + + create_tag_response = MagicMock() + create_tag_response.status_code = 200 + create_tag_response.is_success = True + create_tag_response.json.return_value = { + "name": "v1.0.0", + "sha": "0123456789abcdef0123456789abcdef01234567", + "message": "tag created", + } + + delete_tag_response = MagicMock() + delete_tag_response.status_code = 200 + delete_tag_response.is_success = True + delete_tag_response.json.return_value = { + "name": "v1.0.0", + "message": "tag deleted", + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(side_effect=[create_response, create_tag_response]) + client_instance.request = AsyncMock(return_value=delete_tag_response) + + repo = await storage.create_repo(id="test-repo") + + create_result = await repo.create_tag( + name="v1.0.0", + target="0123456789abcdef0123456789abcdef01234567", + ) + assert create_result["message"] == "tag created" + + delete_result = await repo.delete_tag(name="v1.0.0") + assert delete_result["message"] == "tag deleted" + + create_call = client_instance.post.call_args_list[1] + assert create_call.kwargs["json"] == { + "name": "v1.0.0", + "target": "0123456789abcdef0123456789abcdef01234567", + } + + delete_call = client_instance.request.call_args_list[0] + assert delete_call.args[0] == "DELETE" + assert delete_call.kwargs["json"] == {"name": "v1.0.0"} + + class TestRepoDiffOperations: """Tests for diff operations.""" @@ -1711,3 +1808,43 @@ async def capture_post(*args, **kwargs): assert captured_headers is not None assert "Code-Storage-Agent" in captured_headers assert captured_headers["Code-Storage-Agent"] == get_user_agent() + + @pytest.mark.asyncio + async def test_list_tags_includes_agent_header(self, git_storage_options: dict) -> None: + """Test that list_tags includes Code-Storage-Agent header.""" + storage = GitStorage(git_storage_options) + + mock_response = MagicMock() + mock_response.json = MagicMock( + return_value={"repo_id": "test-repo", "url": "https://example.com/repo.git"} + ) + mock_response.status_code = 200 + mock_response.is_success = True + + list_tags_response = MagicMock() + list_tags_response.json = MagicMock(return_value={"tags": [], "has_more": False}) + list_tags_response.status_code = 200 + list_tags_response.is_success = True + list_tags_response.raise_for_status = MagicMock() + + captured_headers = None + + with patch("httpx.AsyncClient") as mock_client: + mock_get = AsyncMock(return_value=list_tags_response) + + async def capture_get(*args, **kwargs): + nonlocal captured_headers + captured_headers = kwargs.get("headers") + return await mock_get(*args, **kwargs) + + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + mock_client.return_value.__aenter__.return_value.get = capture_get + + repo = await storage.create_repo(id="test-repo") + await repo.list_tags() + + assert captured_headers is not None + assert "Code-Storage-Agent" in captured_headers + assert captured_headers["Code-Storage-Agent"] == get_user_agent() diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index 52c5da6..127dc88 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -214,6 +214,24 @@ const branches = await repo.listBranches({ }); console.log(branches.branches); +// List tags +const tags = await repo.listTags({ + limit: 10, + cursor: undefined, // for pagination +}); +console.log(tags.tags); + +// Create a lightweight tag at a commit SHA +const createdTag = await repo.createTag({ + name: 'v1.0.0', + target: '0123456789abcdef0123456789abcdef01234567', +}); +console.log(createdTag.message); + +// Delete a tag +const deletedTag = await repo.deleteTag({ name: 'v1.0.0' }); +console.log(deletedTag.message); + // List commits const commits = await repo.listCommits({ branch: 'main', // optional diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index de936ed..f0f4585 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", - "version": "1.3.2", + "version": "1.4.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 7fdc648..54962fd 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -19,6 +19,8 @@ import { branchDiffResponseSchema, commitDiffResponseSchema, createBranchResponseSchema, + createTagResponseSchema, + deleteTagResponseSchema, errorEnvelopeSchema, grepResponseSchema, listBranchesResponseSchema, @@ -26,6 +28,7 @@ import { listFilesResponseSchema, listFilesWithMetadataResponseSchema, listReposResponseSchema, + listTagsResponseSchema, noteReadResponseSchema, noteWriteResponseSchema, restoreCommitAckSchema, @@ -44,9 +47,15 @@ import type { CreateBranchResult, CreateCommitFromDiffOptions, CreateCommitOptions, + CreateTagOptions, + CreateTagResponse, + CreateTagResult, CreateGitCredentialOptions, CreateNoteOptions, CreateRepoOptions, + DeleteTagOptions, + DeleteTagResponse, + DeleteTagResult, DeleteGitCredentialOptions, DeleteNoteOptions, DeleteRepoOptions, @@ -88,6 +97,9 @@ import type { ListReposOptions, ListReposResponse, ListReposResult, + ListTagsOptions, + ListTagsResponse, + ListTagsResult, NoteWriteResult, PullUpstreamOptions, RawBranchInfo, @@ -96,11 +108,13 @@ import type { RawFileWithMetadata, RawFileDiff, RawFilteredFile, + RawTagInfo, RefUpdate, RepoOptions, Repo, RestoreCommitOptions, RestoreCommitResult, + TagInfo, UpdateGitCredentialOptions, ValidAPIVersion, } from './types'; @@ -444,6 +458,37 @@ function transformCreateBranchResult( }; } +function transformTagInfo(raw: RawTagInfo): TagInfo { + return { + cursor: raw.cursor, + name: raw.name, + sha: raw.sha, + }; +} + +function transformListTagsResult(raw: ListTagsResponse): ListTagsResult { + return { + tags: raw.tags.map(transformTagInfo), + nextCursor: raw.next_cursor ?? undefined, + hasMore: raw.has_more, + }; +} + +function transformCreateTagResult(raw: CreateTagResponse): CreateTagResult { + return { + name: raw.name, + sha: raw.sha, + message: raw.message, + }; +} + +function transformDeleteTagResult(raw: DeleteTagResponse): DeleteTagResult { + return { + name: raw.name, + message: raw.message, + }; +} + function transformListReposResult(raw: ListReposResponse): ListReposResult { return { repos: raw.repos.map((repo) => ({ @@ -799,6 +844,36 @@ class RepoImpl implements Repo { }); } + async listTags(options?: ListTagsOptions): Promise { + const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); + const jwt = await this.generateJWT(this.id, { + permissions: ['git:read'], + ttl, + }); + + const cursor = options?.cursor; + const limit = options?.limit; + + let params: Record | undefined; + + if (typeof cursor === 'string' || typeof limit === 'number') { + params = {}; + if (typeof cursor === 'string') { + params.cursor = cursor; + } + if (typeof limit === 'number') { + params.limit = limit.toString(); + } + } + + const response = await this.api.get({ path: 'repos/tags', params }, jwt); + const raw = listTagsResponseSchema.parse(await response.json()); + return transformListTagsResult({ + ...raw, + next_cursor: raw.next_cursor ?? undefined, + }); + } + async listCommits(options?: ListCommitsOptions): Promise { const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); const jwt = await this.generateJWT(this.id, { @@ -1215,6 +1290,57 @@ class RepoImpl implements Repo { return transformCreateBranchResult(raw); } + async createTag(options: CreateTagOptions): Promise { + const name = options?.name?.trim(); + if (!name) { + throw new Error('createTag name is required'); + } + if (name.startsWith('refs/')) { + throw new Error('createTag name must not start with refs/'); + } + + const target = options?.target?.trim(); + if (!target) { + throw new Error('createTag target is required'); + } + + const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); + const jwt = await this.generateJWT(this.id, { + permissions: ['git:write'], + ttl, + }); + + const response = await this.api.post( + { path: 'repos/tags', body: { name, target } }, + jwt + ); + const raw = createTagResponseSchema.parse(await response.json()); + return transformCreateTagResult(raw); + } + + async deleteTag(options: DeleteTagOptions): Promise { + const name = options?.name?.trim(); + if (!name) { + throw new Error('deleteTag name is required'); + } + if (name.startsWith('refs/')) { + throw new Error('deleteTag name must not start with refs/'); + } + + const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); + const jwt = await this.generateJWT(this.id, { + permissions: ['git:read', 'git:write'], + ttl, + }); + + const response = await this.api.delete( + { path: 'repos/tags', body: { name } }, + jwt + ); + const raw = deleteTagResponseSchema.parse(await response.json()); + return transformDeleteTagResult(raw); + } + async restoreCommit( options: RestoreCommitOptions ): Promise { diff --git a/packages/code-storage-typescript/src/schemas.ts b/packages/code-storage-typescript/src/schemas.ts index 859ed9f..2b2ac40 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -141,6 +141,29 @@ export const createBranchResponseSchema = z.object({ commit_sha: z.string().nullable().optional(), }); +export const tagInfoSchema = z.object({ + cursor: z.string(), + name: z.string(), + sha: z.string(), +}); + +export const listTagsResponseSchema = z.object({ + tags: z.array(tagInfoSchema), + next_cursor: z.string().nullable().optional(), + has_more: z.boolean(), +}); + +export const createTagResponseSchema = z.object({ + name: z.string(), + sha: z.string(), + message: z.string(), +}); + +export const deleteTagResponseSchema = z.object({ + name: z.string(), + message: z.string(), +}); + export const refUpdateResultSchema = z.object({ branch: z.string(), old_sha: z.string(), @@ -244,6 +267,10 @@ export type GetCommitDiffResponseRaw = z.infer; export type CreateBranchResponseRaw = z.infer< typeof createBranchResponseSchema >; +export type RawTagInfo = z.infer; +export type ListTagsResponseRaw = z.infer; +export type CreateTagResponseRaw = z.infer; +export type DeleteTagResponseRaw = z.infer; export type CommitPackAckRaw = z.infer; export type RestoreCommitAckRaw = z.infer; export type GrepResponseRaw = z.infer; diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index e0e8804..149255c 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -3,6 +3,8 @@ */ import type { CreateBranchResponseRaw, + CreateTagResponseRaw, + DeleteTagResponseRaw, GetBranchDiffResponseRaw, GetCommitDiffResponseRaw, ListBranchesResponseRaw, @@ -10,6 +12,7 @@ import type { ListFilesResponseRaw, ListFilesWithMetadataResponseRaw, ListReposResponseRaw, + ListTagsResponseRaw, NoteReadResponseRaw, NoteWriteResponseRaw, RawBranchInfo as SchemaRawBranchInfo, @@ -20,6 +23,7 @@ import type { RawFilteredFile as SchemaRawFilteredFile, RawRepoBaseInfo as SchemaRawRepoBaseInfo, RawRepoInfo as SchemaRawRepoInfo, + RawTagInfo as SchemaRawTagInfo, } from './schemas'; export interface OverrideableGitStorageOptions { @@ -57,7 +61,10 @@ export interface Repo { options?: ListFilesWithMetadataOptions ): Promise; listBranches(options?: ListBranchesOptions): Promise; + listTags(options?: ListTagsOptions): Promise; listCommits(options?: ListCommitsOptions): Promise; + createTag(options: CreateTagOptions): Promise; + deleteTag(options: DeleteTagOptions): Promise; getNote(options: GetNoteOptions): Promise; createNote(options: CreateNoteOptions): Promise; appendNote(options: AppendNoteOptions): Promise; @@ -321,6 +328,51 @@ export interface CreateBranchResult { commitSha?: string; } +export interface ListTagsOptions extends GitStorageInvocationOptions { + cursor?: string; + limit?: number; +} + +export type RawTagInfo = SchemaRawTagInfo; + +export interface TagInfo { + cursor: string; + name: string; + sha: string; +} + +export type ListTagsResponse = ListTagsResponseRaw; + +export interface ListTagsResult { + tags: TagInfo[]; + nextCursor?: string; + hasMore: boolean; +} + +export interface CreateTagOptions extends GitStorageInvocationOptions { + name: string; + target: string; +} + +export type CreateTagResponse = CreateTagResponseRaw; + +export interface CreateTagResult { + name: string; + sha: string; + message: string; +} + +export interface DeleteTagOptions extends GitStorageInvocationOptions { + name: string; +} + +export type DeleteTagResponse = DeleteTagResponseRaw; + +export interface DeleteTagResult { + name: string; + message: string; +} + // List Commits API types export interface ListCommitsOptions extends GitStorageInvocationOptions { branch?: string; diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index cd59180..bca0095 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -1283,6 +1283,133 @@ describe('GitStorage', () => { }); }); + describe('Repo tags', () => { + it('lists tags with pagination', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-list-tags' }); + + mockFetch.mockImplementationOnce((url, init) => { + const requestUrl = new URL(url as string); + expect(requestUrl.pathname).toBe('/api/v1/repos/tags'); + expect(requestUrl.searchParams.get('cursor')).toBe('start'); + expect(requestUrl.searchParams.get('limit')).toBe('17'); + + const headers = init?.headers as Record; + expect(headers.Authorization).toMatch(/^Bearer /); + + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + tags: [ + { cursor: 'c1', name: 'v1.0.0', sha: 'abc123' }, + { cursor: 'c2', name: 'v1.0.1', sha: 'def456' }, + ], + next_cursor: 'next', + has_more: true, + }), + } as any); + }); + + const result = await repo.listTags({ cursor: 'start', limit: 17 }); + + expect(result).toEqual({ + tags: [ + { cursor: 'c1', name: 'v1.0.0', sha: 'abc123' }, + { cursor: 'c2', name: 'v1.0.1', sha: 'def456' }, + ], + nextCursor: 'next', + hasMore: true, + }); + }); + + it('creates and deletes tags with expected scopes', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-tags-write' }); + + mockFetch.mockImplementationOnce((_url, init) => { + const requestInit = init as RequestInit; + expect(requestInit.method).toBe('POST'); + + const headers = requestInit.headers as Record; + const payload = decodeJwtPayload(stripBearer(headers.Authorization)); + expect(payload.scopes).toEqual(['git:write']); + + const body = JSON.parse(requestInit.body as string); + expect(body).toEqual({ + name: 'v1.0.0', + target: '0123456789abcdef0123456789abcdef01234567', + }); + + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + name: 'v1.0.0', + sha: '0123456789abcdef0123456789abcdef01234567', + message: 'tag created', + }), + } as any); + }); + + const createResult = await repo.createTag({ + name: 'v1.0.0', + target: '0123456789abcdef0123456789abcdef01234567', + }); + + expect(createResult).toEqual({ + name: 'v1.0.0', + sha: '0123456789abcdef0123456789abcdef01234567', + message: 'tag created', + }); + + mockFetch.mockImplementationOnce((_url, init) => { + const requestInit = init as RequestInit; + expect(requestInit.method).toBe('DELETE'); + + const headers = requestInit.headers as Record; + const payload = decodeJwtPayload(stripBearer(headers.Authorization)); + expect(payload.scopes).toEqual(['git:read', 'git:write']); + + const body = JSON.parse(requestInit.body as string); + expect(body).toEqual({ name: 'v1.0.0' }); + + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + name: 'v1.0.0', + message: 'tag deleted', + }), + } as any); + }); + + const deleteResult = await repo.deleteTag({ name: 'v1.0.0' }); + expect(deleteResult).toEqual({ + name: 'v1.0.0', + message: 'tag deleted', + }); + }); + + it('validates tag names', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-tag-validation' }); + + await expect( + repo.createTag({ name: '', target: 'abc' }) + ).rejects.toThrow('createTag name is required'); + await expect( + repo.createTag({ name: 'refs/tags/v1.0.0', target: 'abc' }) + ).rejects.toThrow('createTag name must not start with refs/'); + await expect(repo.deleteTag({ name: '' })).rejects.toThrow( + 'deleteTag name is required' + ); + }); + }); + describe('Repo getBranchDiff', () => { it('forwards ephemeralBase flag to the API params', async () => { const store = new GitStorage({ name: 'v0', key }); @@ -1884,6 +2011,32 @@ describe('GitStorage', () => { /code-storage-sdk\/\d+\.\d+\.\d+/ ); }); + + it('should include Code-Storage-Agent header in listTags API calls', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'test-tags' }); + + let capturedHeaders: Record | undefined; + mockFetch.mockImplementationOnce((_url, init) => { + capturedHeaders = init?.headers as Record; + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + tags: [], + has_more: false, + }), + }); + }); + + await repo.listTags(); + + expect(capturedHeaders).toBeDefined(); + expect(capturedHeaders?.['Code-Storage-Agent']).toBeDefined(); + expect(capturedHeaders?.['Code-Storage-Agent']).toMatch( + /code-storage-sdk\/\d+\.\d+\.\d+/ + ); + }); }); describe('URL Generation', () => {