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
44 changes: 44 additions & 0 deletions packages/code-storage-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,50 @@ if err != nil {
fmt.Println(deletedEphemeral.Ephemeral)
```

### Manage notes

```go
// Create and read a note. Notes default to refs/notes/commits. Set Ref to
// target another notes ref; a bare name like "reviews" is placed under
// refs/notes/ (a fully-qualified refs/notes/* ref also works). Custom refs must
// be enabled server-side.
if _, err := repo.CreateNote(context.Background(), storage.CreateNoteOptions{
SHA: "0123456789abcdef0123456789abcdef01234567",
Note: "LGTM",
Ref: "reviews",
}); err != nil {
log.Fatal(err)
}

note, err := repo.GetNote(context.Background(), storage.GetNoteOptions{
SHA: "0123456789abcdef0123456789abcdef01234567",
Ref: "reviews",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(note.Note)

// Discover custom notes namespaces with cursor pagination. Requires the custom
// notes refs feature to be enabled server-side.
refs, err := repo.ListNotesRefs(context.Background(), storage.ListNotesRefsOptions{
Prefix: "reviews/",
Limit: 20,
})
if err != nil {
log.Fatal(err)
}
for _, entry := range refs.Refs {
fmt.Println(entry.Ref, entry.SHA)
}
if refs.HasMore {
_, _ = repo.ListNotesRefs(context.Background(), storage.ListNotesRefsOptions{
Prefix: "reviews/",
Cursor: refs.NextCursor,
})
}
```

### Preview merge

```go
Expand Down
65 changes: 62 additions & 3 deletions packages/code-storage-go/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,9 @@ func (r *Repo) GetNote(ctx context.Context, options GetNoteOptions) (GetNoteResu

params := url.Values{}
params.Set("sha", sha)
if ref := strings.TrimSpace(options.Ref); ref != "" {
params.Set("ref", ref)
}

resp, err := r.client.api.get(ctx, "repos/notes", params, jwtToken, nil)
if err != nil {
Expand All @@ -675,12 +678,12 @@ func (r *Repo) GetNote(ctx context.Context, options GetNoteOptions) (GetNoteResu

// CreateNote adds a git note.
func (r *Repo) CreateNote(ctx context.Context, options CreateNoteOptions) (NoteWriteResult, error) {
return r.writeNote(ctx, options.InvocationOptions, "add", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.RefPolicies)
return r.writeNote(ctx, options.InvocationOptions, "add", options.SHA, options.Note, options.ExpectedRefSHA, options.Ref, options.Author, options.RefPolicies)
}

// AppendNote appends to a git note.
func (r *Repo) AppendNote(ctx context.Context, options AppendNoteOptions) (NoteWriteResult, error) {
return r.writeNote(ctx, options.InvocationOptions, "append", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.RefPolicies)
return r.writeNote(ctx, options.InvocationOptions, "append", options.SHA, options.Note, options.ExpectedRefSHA, options.Ref, options.Author, options.RefPolicies)
}

// DeleteNote deletes a git note.
Expand All @@ -700,6 +703,9 @@ func (r *Repo) DeleteNote(ctx context.Context, options DeleteNoteOptions) (NoteW
if strings.TrimSpace(options.ExpectedRefSHA) != "" {
body.ExpectedRefSHA = options.ExpectedRefSHA
}
if ref := strings.TrimSpace(options.Ref); ref != "" {
body.Ref = ref
}
if options.Author != nil {
if strings.TrimSpace(options.Author.Name) == "" || strings.TrimSpace(options.Author.Email) == "" {
return NoteWriteResult{}, errors.New("deleteNote author name and email are required when provided")
Expand Down Expand Up @@ -731,7 +737,7 @@ func (r *Repo) DeleteNote(ctx context.Context, options DeleteNoteOptions) (NoteW
return result, nil
}

func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, action string, sha string, note string, expectedRefSHA string, author *NoteAuthor, refPolicies RefPolicyList) (NoteWriteResult, error) {
func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, action string, sha string, note string, expectedRefSHA string, ref string, author *NoteAuthor, refPolicies RefPolicyList) (NoteWriteResult, error) {
sha = strings.TrimSpace(sha)
if sha == "" {
return NoteWriteResult{}, errors.New("note sha is required")
Expand All @@ -756,6 +762,9 @@ func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, acti
if strings.TrimSpace(expectedRefSHA) != "" {
body.ExpectedRefSHA = expectedRefSHA
}
if ref := strings.TrimSpace(ref); ref != "" {
body.Ref = ref
}
if author != nil {
if strings.TrimSpace(author.Name) == "" || strings.TrimSpace(author.Email) == "" {
return NoteWriteResult{}, errors.New("note author name and email are required when provided")
Expand Down Expand Up @@ -791,6 +800,56 @@ func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, acti
return result, nil
}

// ListNotesRefs lists git notes refs under a prefix, with cursor pagination.
// Use it to discover custom notes namespaces before reading individual notes.
// It requires the custom notes refs feature to be enabled server-side; when it
// is not, the request fails with an *APIError (HTTP 400).
func (r *Repo) ListNotesRefs(ctx context.Context, options ListNotesRefsOptions) (ListNotesRefsResult, error) {
ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL)
jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead}, TTL: ttl})
if err != nil {
return ListNotesRefsResult{}, err
}

params := url.Values{}
if prefix := strings.TrimSpace(options.Prefix); prefix != "" {
params.Set("prefix", prefix)
}
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/notes/refs", params, jwtToken, nil)
if err != nil {
return ListNotesRefsResult{}, err
}
defer resp.Body.Close()

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

result := ListNotesRefsResult{HasMore: payload.HasMore, Prefix: payload.Prefix}
if payload.NextCursor != "" {
result.NextCursor = payload.NextCursor
}
for _, ref := range payload.Refs {
result.Refs = append(result.Refs, NotesRefInfo{
Cursor: ref.Cursor,
Ref: ref.Ref,
SHA: ref.SHA,
})
}
return result, nil
}

// GetBranchDiff returns a diff for a branch.
func (r *Repo) GetBranchDiff(ctx context.Context, options GetBranchDiffOptions) (GetBranchDiffResult, error) {
if strings.TrimSpace(options.Branch) == "" {
Expand Down
126 changes: 126 additions & 0 deletions packages/code-storage-go/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1817,6 +1817,132 @@ func TestGetNote(t *testing.T) {
}
}

func TestNoteRefTargeting(t *testing.T) {
var postBody, deleteBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/notes" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
if got := r.URL.Query().Get("ref"); got != "reviews" {
t.Fatalf("unexpected ref query: %q", got)
}
_, _ = w.Write([]byte(`{"sha":"abc123","note":"reviewed","ref_sha":"def456"}`))
case http.MethodPost:
postBody, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{"sha":"abc123","target_ref":"refs/notes/reviews","new_ref_sha":"def456","result":{"success":true,"status":"ok"}}`))
case http.MethodDelete:
deleteBody, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{"sha":"abc123","target_ref":"refs/notes/reviews","new_ref_sha":"def456","result":{"success":true,"status":"ok"}}`))
default:
t.Fatalf("unexpected method: %s", r.Method)
}
}))
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.GetNote(nil, GetNoteOptions{SHA: "abc123", Ref: "reviews"}); err != nil {
t.Fatalf("get note error: %v", err)
}

if _, err := repo.CreateNote(nil, CreateNoteOptions{SHA: "abc123", Note: "LGTM", Ref: "reviews"}); err != nil {
t.Fatalf("create note error: %v", err)
}
var postPayload map[string]interface{}
_ = json.Unmarshal(postBody, &postPayload)
if postPayload["ref"] != "reviews" {
t.Fatalf("expected create note ref reviews, got %v", postPayload["ref"])
}

if _, err := repo.DeleteNote(nil, DeleteNoteOptions{SHA: "abc123", Ref: "refs/notes/reviews"}); err != nil {
t.Fatalf("delete note error: %v", err)
}
var deletePayload map[string]interface{}
_ = json.Unmarshal(deleteBody, &deletePayload)
if deletePayload["ref"] != "refs/notes/reviews" {
t.Fatalf("expected delete note ref refs/notes/reviews, got %v", deletePayload["ref"])
}
}

func TestListNotesRefs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/notes/refs" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
q := r.URL.Query()
if q.Get("prefix") != "reviews/" {
t.Fatalf("unexpected prefix: %q", q.Get("prefix"))
}
if q.Get("limit") != "50" {
t.Fatalf("unexpected limit: %q", q.Get("limit"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"refs":[{"cursor":"refs/notes/reviews/session-a","ref":"refs/notes/reviews/session-a","sha":"a1b2c3"}],"next_cursor":"refs/notes/reviews/session-b","has_more":true,"prefix":"refs/notes/reviews/"}`))
}))
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.ListNotesRefs(nil, ListNotesRefsOptions{Prefix: "reviews/", Limit: 50})
if err != nil {
t.Fatalf("list notes refs error: %v", err)
}
if len(result.Refs) != 1 || result.Refs[0].Ref != "refs/notes/reviews/session-a" {
t.Fatalf("unexpected refs: %+v", result.Refs)
}
if result.Refs[0].SHA != "a1b2c3" {
t.Fatalf("unexpected sha: %q", result.Refs[0].SHA)
}
if result.NextCursor != "refs/notes/reviews/session-b" || !result.HasMore {
t.Fatalf("unexpected pagination: %+v", result)
}
if result.Prefix != "refs/notes/reviews/" {
t.Fatalf("unexpected prefix: %q", result.Prefix)
}
}

func TestListNotesRefsNoOptions(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/notes/refs" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.RawQuery != "" {
t.Fatalf("expected no query string, got %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"refs":[],"has_more":false,"prefix":"refs/notes/"}`))
}))
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.ListNotesRefs(nil, ListNotesRefsOptions{})
if err != nil {
t.Fatalf("list notes refs error: %v", err)
}
if len(result.Refs) != 0 || result.HasMore {
t.Fatalf("unexpected result: %+v", result)
}
if result.Prefix != "refs/notes/" {
t.Fatalf("unexpected prefix: %q", result.Prefix)
}
}

func TestFileStreamEphemeral(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/file" {
Expand Down
1 change: 1 addition & 0 deletions packages/code-storage-go/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type noteWriteRequest struct {
Action string `json:"action,omitempty"`
Note string `json:"note,omitempty"`
ExpectedRefSHA string `json:"expected_ref_sha,omitempty"`
Ref string `json:"ref,omitempty"`
Author *authorInfo `json:"author,omitempty"`
}

Expand Down
13 changes: 13 additions & 0 deletions packages/code-storage-go/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,19 @@ type noteResult struct {
Message string `json:"message"`
}

type listNotesRefsResponse struct {
Refs []notesRefInfoRaw `json:"refs"`
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
Prefix string `json:"prefix"`
}

type notesRefInfoRaw struct {
Cursor string `json:"cursor"`
Ref string `json:"ref"`
SHA string `json:"sha"`
}

type diffStatsRaw struct {
Files int `json:"files"`
Additions int `json:"additions"`
Expand Down
Loading
Loading