Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion packages/code-storage-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It's probably too late to ask (aka should have asked earlier) so don't feel free blocked on this.

I need to look up but I assume server-side we are shelling out to git for listing tags, and the sorting behavior and sortable options diff a bit between some random-looking git CLI versions. I imagine we would be using the latest and the greatest (i.e., >= 2.4.9) but always good to confirm.

You may take a quick peak on what options there are in https://github.com/gogs/git-module/blob/d5f694fafabb48a5c235dc5bcdd0b0548b7e42d3/repo_tag.go#L145-L157 , because I vaguely recall the default sorting behavior was pretty bizarre, in the sense that, ~impossible to do "load more" style of pagination that feels natural to human.

It is also very likely customers will soon ask about sorting capabilities otherwise they may just almost always pass Limit: 9999 or whatever so that they can sort on their end.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a good thing to check. In our case, our api uses a different technique to solve the sorting problem, but I am omitting details here due to this being a public repo.

Copy link
Copy Markdown
Contributor

@unknwon unknwon Apr 1, 2026

Choose a reason for hiding this comment

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

Hmm, what type of details we are trying to hide here? 🤔 I think given the fact this method provides pagination with a cursor, we would have to be somewhat transparent about how are things getting sorted regardless?

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
Expand Down Expand Up @@ -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.
118 changes: 118 additions & 0 deletions packages/code-storage-go/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ideally, when the tag already exists, this method should return a concrete error type (ideally, propagated from server-side as well) that call sites can asserts in a type-safe manner instead of asserting on strings.

Or has that already been taken care of on the server-side?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This probably fits under a similar scope as above for a separate PR that takes care of this across the sdk. Right now the other write methods do something similar. Server-side, we return a 409 with "tag already exists" which does include the information. In any case, your suggestion would be an improvement.

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
}
Comment on lines +312 to +314
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Is this actually necessarily? Maybe you literally copied it from other places, but I would imagine empty url.Values{} would/should do no harm at all.

It would be great to have a followup PR to clean up this across the Go SDK.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I think a different PR, as you suggest, is the right answer here as the pattern is used throughout the sdk.


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)
Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)
Expand Down
120 changes: 120 additions & 0 deletions packages/code-storage-go/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions packages/code-storage-go/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
23 changes: 23 additions & 0 deletions packages/code-storage-go/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
47 changes: 47 additions & 0 deletions packages/code-storage-go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading