diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index abe6eb7..b62c9d2 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -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 diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index d60a001..82d7bdc 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -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 { @@ -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. @@ -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") @@ -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") @@ -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") @@ -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) == "" { diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 529d5fa..374c647 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -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" { diff --git a/packages/code-storage-go/requests.go b/packages/code-storage-go/requests.go index a3f1476..9439b9f 100644 --- a/packages/code-storage-go/requests.go +++ b/packages/code-storage-go/requests.go @@ -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"` } diff --git a/packages/code-storage-go/responses.go b/packages/code-storage-go/responses.go index 007d936..a4a5c8b 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -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"` diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 81e13a3..a3d2b3f 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -697,6 +697,11 @@ type NoteAuthor struct { type GetNoteOptions struct { InvocationOptions SHA string + // Ref is the notes ref to read from. A bare name like "reviews" is placed + // under refs/notes/; a fully-qualified refs/notes/* ref is also accepted. + // Defaults to refs/notes/commits. Custom refs require the feature to be + // enabled server-side. + Ref string } // GetNoteResult describes note read. @@ -713,6 +718,11 @@ type CreateNoteOptions struct { Note string ExpectedRefSHA string Author *NoteAuthor + // Ref is the notes ref to target. A bare name like "reviews" is placed + // under refs/notes/; a fully-qualified refs/notes/* ref is also accepted. + // Defaults to refs/notes/commits. Custom refs require the feature to be + // enabled server-side, and RefPolicies must permit writing to it. + Ref string // RefPolicies is evaluated in declaration order. The first matching rule wins. RefPolicies RefPolicyList } @@ -724,6 +734,8 @@ type AppendNoteOptions struct { Note string ExpectedRefSHA string Author *NoteAuthor + // Ref is the notes ref to target. See CreateNoteOptions.Ref. + Ref string // RefPolicies is evaluated in declaration order. The first matching rule wins. RefPolicies RefPolicyList } @@ -734,13 +746,17 @@ type DeleteNoteOptions struct { SHA string ExpectedRefSHA string Author *NoteAuthor + // Ref is the notes ref to target. See CreateNoteOptions.Ref. + Ref string // RefPolicies is evaluated in declaration order. The first matching rule wins. RefPolicies RefPolicyList } // NoteWriteResult describes note write response. type NoteWriteResult struct { - SHA string + SHA string + // TargetRef is the notes ref the operation targeted (the resolved value of + // the request Ref, defaulting to refs/notes/commits). TargetRef string BaseCommit string NewRefSHA string @@ -754,6 +770,34 @@ type NoteResult struct { Message string } +// ListNotesRefsOptions configures listing notes refs. +type ListNotesRefsOptions struct { + InvocationOptions + // Prefix is the notes ref prefix to enumerate. A bare prefix like "reviews/" + // is placed under refs/notes/; a fully-qualified refs/notes/* prefix is also + // accepted. Defaults to refs/notes/. + Prefix string + Cursor string + // Limit is the maximum number of notes refs to return. Defaults to 20. + Limit int +} + +// NotesRefInfo describes a single notes ref entry. +type NotesRefInfo struct { + Cursor string + Ref string + SHA string +} + +// ListNotesRefsResult describes a notes refs listing. +type ListNotesRefsResult struct { + Refs []NotesRefInfo + NextCursor string + HasMore bool + // Prefix is the normalized notes ref prefix used for the listing. + Prefix string +} + // DiffFileState normalizes diff status. type DiffFileState string diff --git a/packages/code-storage-go/version.go b/packages/code-storage-go/version.go index 0712dca..98d3abf 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.12.0" + PackageVersion = "1.15.0" ) func userAgent() string { diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 6a31633..ef90d35 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -332,6 +332,20 @@ await repo.append_note( # Delete a git note await repo.delete_note(sha="abc123...") +# Notes default to refs/notes/commits. Pass `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. +await repo.create_note(sha="abc123...", note="LGTM", ref="reviews") +review_note = await repo.get_note(sha="abc123...", ref="reviews") + +# Discover custom notes namespaces with cursor pagination. Requires the custom +# notes refs feature to be enabled server-side. +notes_refs = await repo.list_notes_refs(prefix="reviews/", limit=20) +for entry in notes_refs["refs"]: + print(entry["ref"], entry["sha"]) +if notes_refs["has_more"]: + await repo.list_notes_refs(prefix="reviews/", cursor=notes_refs["next_cursor"]) + # Get branch diff branch_diff = await repo.get_branch_diff( branch="feature-branch", @@ -893,6 +907,7 @@ class Repo: self, *, sha: str, + ref: Optional[str] = None, ttl: Optional[int] = None, ) -> NoteReadResult: ... @@ -903,6 +918,7 @@ class Repo: note: str, expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, + ref: Optional[str] = None, ttl: Optional[int] = None, ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: ... @@ -914,6 +930,7 @@ class Repo: note: str, expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, + ref: Optional[str] = None, ttl: Optional[int] = None, ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: ... @@ -924,10 +941,20 @@ class Repo: sha: str, expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, + ref: Optional[str] = None, ttl: Optional[int] = None, ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: ... + async def list_notes_refs( + self, + *, + prefix: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ttl: Optional[int] = None, + ) -> ListNotesRefsResult: ... + 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 3a5648f..6852549 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -39,9 +39,11 @@ ListCommitsResult, ListFilesResult, ListFilesWithMetadataResult, + ListNotesRefsResult, ListReposResult, ListTagsResult, NoteReadResult, + NotesRefInfo, NoteWriteResult, Op, Ops, @@ -114,9 +116,11 @@ "ListCommitsResult", "ListFilesResult", "ListFilesWithMetadataResult", + "ListNotesRefsResult", "ListReposResult", "ListTagsResult", "NoteReadResult", + "NotesRefInfo", "NoteWriteResult", "PreviewMergeBlob", "PreviewMergeConflict", diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 87b0252..57bc7d1 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -47,10 +47,12 @@ ListCommitsResult, ListFilesResult, ListFilesWithMetadataResult, + ListNotesRefsResult, ListTagsResult, MergeBranchesResult, MergeStrategy, NoteReadResult, + NotesRefInfo, NoteWriteResult, PreviewMergeResult, Refs, @@ -1574,9 +1576,19 @@ async def get_note( self, *, sha: str, + ref: Optional[str] = None, ttl: Optional[int] = None, ) -> NoteReadResult: - """Read a git note.""" + """Read a git note. + + Args: + sha: Git object SHA whose note to read. + ref: Notes ref to read from. A bare name like ``reviews`` is placed + under ``refs/notes/``; a fully-qualified ``refs/notes/*`` ref is + also accepted. Defaults to ``refs/notes/commits``. Custom refs + require the feature to be enabled server-side. + ttl: Token TTL in seconds. + """ sha_clean = sha.strip() if not sha_clean: raise ValueError("get_note sha is required") @@ -1584,7 +1596,11 @@ async def get_note( ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl}) - url = f"{self.api_base_url}/api/v{self.api_version}/repos/notes?{urlencode({'sha': sha_clean})}" + params = {"sha": sha_clean} + if ref and ref.strip(): + params["ref"] = ref.strip() + + url = f"{self.api_base_url}/api/v{self.api_version}/repos/notes?{urlencode(params)}" async with httpx.AsyncClient() as client: response = await client.get( @@ -1610,10 +1626,18 @@ async def create_note( note: str, expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, + ref: Optional[str] = None, ttl: Optional[int] = None, ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: - """Create a git note.""" + """Create a git note. + + Set ``ref`` to target a notes ref other than ``refs/notes/commits``. A + bare name like ``reviews`` is placed under ``refs/notes/``; a + fully-qualified ``refs/notes/*`` ref is also accepted. Custom refs + require the feature to be enabled server-side, and the JWT + ``ref_policies`` must permit writing to the target ref. + """ return await self._write_note( action_label="create_note", action="add", @@ -1621,6 +1645,7 @@ async def create_note( note=note, expected_ref_sha=expected_ref_sha, author=author, + ref=ref, ttl=ttl, ref_policies=ref_policies, ) @@ -1632,10 +1657,14 @@ async def append_note( note: str, expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, + ref: Optional[str] = None, ttl: Optional[int] = None, ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: - """Append to a git note.""" + """Append to a git note. + + See :meth:`create_note` for how ``ref`` selects the notes ref. + """ return await self._write_note( action_label="append_note", action="append", @@ -1643,6 +1672,7 @@ async def append_note( note=note, expected_ref_sha=expected_ref_sha, author=author, + ref=ref, ttl=ttl, ref_policies=ref_policies, ) @@ -1653,10 +1683,15 @@ async def delete_note( sha: str, expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, + ref: Optional[str] = None, ttl: Optional[int] = None, ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: - """Delete a git note.""" + """Delete a git note. + + Set ``ref`` to target a notes ref other than ``refs/notes/commits``; a + bare name like ``reviews`` is placed under ``refs/notes/``. + """ sha_clean = sha.strip() if not sha_clean: raise ValueError("delete_note sha is required") @@ -1665,6 +1700,8 @@ async def delete_note( jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, ref_policies)) payload: Dict[str, Any] = {"sha": sha_clean} + if ref and ref.strip(): + payload["ref"] = ref.strip() if expected_ref_sha and expected_ref_sha.strip(): payload["expected_ref_sha"] = expected_ref_sha.strip() if author: @@ -1691,6 +1728,77 @@ async def delete_note( return self._parse_note_write_response(response, "delete_note") + async def list_notes_refs( + self, + *, + prefix: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ttl: Optional[int] = None, + ) -> ListNotesRefsResult: + """List git notes refs under a prefix, with cursor pagination. + + Use this to discover custom notes namespaces before reading individual + notes. + + Args: + prefix: Notes ref prefix to enumerate. A bare prefix like + ``reviews/`` is placed under ``refs/notes/``; a fully-qualified + ``refs/notes/*`` prefix is also accepted. Defaults to + ``refs/notes/``. + cursor: Pagination cursor from a previous response. + limit: Maximum number of notes refs to return (default 20). + ttl: Token TTL in seconds. + + Returns: + Notes refs with pagination info. + + Requires the custom notes refs feature to be enabled server-side; when + it is not, the server responds with HTTP 400. + """ + ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS + jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl}) + + params: Dict[str, str] = {} + if prefix and prefix.strip(): + params["prefix"] = prefix.strip() + 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/notes/refs" + 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() + + refs: List[NotesRefInfo] = [ + { + "cursor": r["cursor"], + "ref": r["ref"], + "sha": r["sha"], + } + for r in data["refs"] + ] + + return { + "refs": refs, + "next_cursor": data.get("next_cursor"), + "has_more": data["has_more"], + "prefix": data["prefix"], + } + async def get_branch_diff( self, *, @@ -2271,6 +2379,7 @@ async def _write_note( note: str, expected_ref_sha: Optional[str], author: Optional[CommitSignature], + ref: Optional[str] = None, ttl: Optional[int], ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: @@ -2290,6 +2399,8 @@ async def _write_note( "action": action, "note": note_clean, } + if ref and ref.strip(): + payload["ref"] = ref.strip() if expected_ref_sha and expected_ref_sha.strip(): payload["expected_ref_sha"] = expected_ref_sha.strip() if author: diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 366ef6d..e2d0512 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -402,6 +402,23 @@ class NoteWriteResult(TypedDict): result: NoteWriteResultPayload +class NotesRefInfo(TypedDict): + """A single notes ref entry.""" + + cursor: str + ref: str + sha: str + + +class ListNotesRefsResult(TypedDict): + """Result from listing git notes refs.""" + + refs: List[NotesRefInfo] + next_cursor: Optional[str] + has_more: bool + prefix: str + + # Diff types class DiffStats(TypedDict): """Statistics about a diff.""" @@ -970,6 +987,7 @@ async def get_note( self, *, sha: str, + ref: Optional[str] = None, ttl: Optional[int] = None, ) -> NoteReadResult: """Read a git note.""" @@ -982,6 +1000,7 @@ async def create_note( note: str, expected_ref_sha: Optional[str] = None, author: Optional["CommitSignature"] = None, + ref: Optional[str] = None, ttl: Optional[int] = None, ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: @@ -995,6 +1014,7 @@ async def append_note( note: str, expected_ref_sha: Optional[str] = None, author: Optional["CommitSignature"] = None, + ref: Optional[str] = None, ttl: Optional[int] = None, ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: @@ -1007,12 +1027,24 @@ async def delete_note( sha: str, expected_ref_sha: Optional[str] = None, author: Optional["CommitSignature"] = None, + ref: Optional[str] = None, ttl: Optional[int] = None, ref_policies: Optional[Refs] = None, ) -> NoteWriteResult: """Delete a git note.""" ... + async def list_notes_refs( + self, + *, + prefix: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + ttl: Optional[int] = None, + ) -> ListNotesRefsResult: + """List git notes refs under a prefix.""" + ... + async def get_branch_diff( self, *, diff --git a/packages/code-storage-python/pierre_storage/version.py b/packages/code-storage-python/pierre_storage/version.py index 7a0f928..7b02fee 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.13.0" +PACKAGE_VERSION = "1.15.0" def get_user_agent() -> str: diff --git a/packages/code-storage-python/pyproject.toml b/packages/code-storage-python/pyproject.toml index a69010e..08cf3fe 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.13.0" +version = "1.15.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 069c613..ee2803e 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -2367,6 +2367,160 @@ async def test_create_append_delete_note(self, git_storage_options: dict) -> Non assert delete_call.args[0] == "DELETE" assert delete_call.kwargs["json"] == {"sha": "abc123"} + @pytest.mark.asyncio + async def test_note_ref_targeting(self, git_storage_options: dict) -> None: + """A custom notes ref is forwarded on read (query) and write (body).""" + 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"} + + note_read_response = MagicMock() + note_read_response.status_code = 200 + note_read_response.is_success = True + note_read_response.raise_for_status = MagicMock() + note_read_response.json.return_value = { + "sha": "abc123", + "note": "reviewed", + "ref_sha": "def456", + } + + create_note_response = MagicMock() + create_note_response.status_code = 201 + create_note_response.is_success = True + create_note_response.json.return_value = { + "sha": "abc123", + "target_ref": "refs/notes/reviews", + "new_ref_sha": "def456", + "result": {"success": True, "status": "ok"}, + } + + delete_note_response = MagicMock() + delete_note_response.status_code = 200 + delete_note_response.is_success = True + delete_note_response.json.return_value = { + "sha": "abc123", + "target_ref": "refs/notes/reviews", + "new_ref_sha": "def456", + "result": {"success": True, "status": "ok"}, + } + + 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_note_response] + ) + mock_get = AsyncMock(return_value=note_read_response) + client_instance.get = mock_get + client_instance.request = AsyncMock(return_value=delete_note_response) + + repo = await storage.create_repo(id="test-repo") + + read_result = await repo.get_note(sha="abc123", ref="reviews") + assert read_result["note"] == "reviewed" + assert "ref=reviews" in mock_get.await_args.args[0] + + await repo.create_note(sha="abc123", note="LGTM", ref="reviews") + create_call = client_instance.post.call_args_list[1] + assert create_call.kwargs["json"] == { + "sha": "abc123", + "action": "add", + "note": "LGTM", + "ref": "reviews", + } + + await repo.delete_note(sha="abc123", ref="refs/notes/reviews") + delete_call = client_instance.request.call_args_list[0] + assert delete_call.kwargs["json"] == { + "sha": "abc123", + "ref": "refs/notes/reviews", + } + + @pytest.mark.asyncio + async def test_list_notes_refs(self, git_storage_options: dict) -> None: + """list_notes_refs sends prefix/limit and parses the paginated result.""" + 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_response = MagicMock() + list_response.status_code = 200 + list_response.is_success = True + list_response.raise_for_status = MagicMock() + list_response.json.return_value = { + "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/", + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + mock_get = AsyncMock(return_value=list_response) + client_instance.get = mock_get + + repo = await storage.create_repo(id="test-repo") + result = await repo.list_notes_refs(prefix="reviews/", limit=50) + + called_url = mock_get.await_args.args[0] + assert "/repos/notes/refs" in called_url + assert "prefix=reviews" in called_url + assert "limit=50" in called_url + + assert result["refs"][0]["ref"] == "refs/notes/reviews/session-a" + assert result["next_cursor"] == "refs/notes/reviews/session-b" + assert result["has_more"] is True + assert result["prefix"] == "refs/notes/reviews/" + + @pytest.mark.asyncio + async def test_list_notes_refs_no_options( + self, git_storage_options: dict + ) -> None: + """With no options, no query string is sent and an empty page parses.""" + 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_response = MagicMock() + list_response.status_code = 200 + list_response.is_success = True + list_response.raise_for_status = MagicMock() + list_response.json.return_value = { + "refs": [], + "next_cursor": None, + "has_more": False, + "prefix": "refs/notes/", + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + mock_get = AsyncMock(return_value=list_response) + client_instance.get = mock_get + + repo = await storage.create_repo(id="test-repo") + result = await repo.list_notes_refs() + + called_url = mock_get.await_args.args[0] + assert called_url.endswith("/repos/notes/refs") + assert result["refs"] == [] + assert result["has_more"] is False + class TestRepoTagOperations: """Tests for tag operations.""" diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index 88fe451..5216407 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -308,6 +308,26 @@ await repo.appendNote({ // Delete a git note await repo.deleteNote({ sha: 'abc123...' }); +// Notes default to refs/notes/commits. Pass `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. +await repo.createNote({ + sha: 'abc123...', + note: 'LGTM', + ref: 'reviews', +}); +const reviewNote = await repo.getNote({ sha: 'abc123...', ref: 'reviews' }); + +// Discover custom notes namespaces with cursor pagination. Requires the custom +// notes refs feature to be enabled server-side. +const notesRefs = await repo.listNotesRefs({ prefix: 'reviews/', limit: 20 }); +for (const entry of notesRefs.refs) { + console.log(entry.ref, entry.sha); +} +if (notesRefs.hasMore) { + await repo.listNotesRefs({ prefix: 'reviews/', cursor: notesRefs.nextCursor }); +} + // Get branch diff const branchDiff = await repo.getBranchDiff({ branch: 'feature-branch', @@ -598,6 +618,7 @@ interface Repo { createNote(options: CreateNoteOptions): Promise; appendNote(options: AppendNoteOptions): Promise; deleteNote(options: DeleteNoteOptions): Promise; + listNotesRefs(options?: ListNotesRefsOptions): Promise; getBranchDiff(options: GetBranchDiffOptions): Promise; getCommitDiff(options: GetCommitDiffOptions): Promise; restoreCommit(options: RestoreCommitOptions): Promise; @@ -725,6 +746,7 @@ interface ListFilesWithMetadataResult { interface GetNoteOptions { sha: string; // Commit SHA to look up notes for + ref?: string; // Notes ref (default refs/notes/commits; bare names go under refs/notes/) ttl?: number; } @@ -739,6 +761,7 @@ interface CreateNoteOptions { note: string; expectedRefSha?: string; author?: { name: string; email: string }; + ref?: string; // Notes ref to target (default refs/notes/commits) ttl?: number; } @@ -747,6 +770,7 @@ interface AppendNoteOptions { note: string; expectedRefSha?: string; author?: { name: string; email: string }; + ref?: string; // Notes ref to target (default refs/notes/commits) ttl?: number; } @@ -754,12 +778,13 @@ interface DeleteNoteOptions { sha: string; expectedRefSha?: string; author?: { name: string; email: string }; + ref?: string; // Notes ref to target (default refs/notes/commits) ttl?: number; } interface NoteWriteResult { sha: string; - targetRef: string; + targetRef: string; // Resolved notes ref the write targeted baseCommit?: string; newRefSha: string; result: { @@ -769,6 +794,26 @@ interface NoteWriteResult { }; } +interface ListNotesRefsOptions { + prefix?: string; // Notes ref prefix (default refs/notes/; bare prefixes go under refs/notes/) + cursor?: string; + limit?: number; // Default 20 + ttl?: number; +} + +interface NotesRefInfo { + cursor: string; + ref: string; + sha: string; +} + +interface ListNotesRefsResult { + refs: NotesRefInfo[]; + nextCursor?: string; + hasMore: boolean; + prefix: string; // Normalized prefix used for the listing +} + interface ListBranchesOptions { cursor?: string; limit?: number; diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index d586139..95444ea 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", - "version": "1.14.0", + "version": "1.15.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 8f3496b..71b2b69 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -39,6 +39,7 @@ import { mergeResponseSchema, previewMergeResponseSchema, listTagsResponseSchema, + listNotesRefsResponseSchema, noteReadResponseSchema, noteWriteResponseSchema, restoreCommitAckSchema, @@ -125,9 +126,14 @@ import type { ListTagsOptions, ListTagsResponse, ListTagsResult, + ListNotesRefsOptions, + ListNotesRefsResponse, + ListNotesRefsResult, + NotesRefInfo, NoteWriteResult, PullUpstreamOptions, RawBranchInfo, + RawNotesRefInfo, RawCommitMetadata, RawCommitInfo, RawFileWithMetadata, @@ -699,11 +705,34 @@ function transformNoteWriteResult(raw: { }; } +function transformNotesRefInfo(raw: RawNotesRefInfo): NotesRefInfo { + return { + cursor: raw.cursor, + ref: raw.ref, + sha: raw.sha, + }; +} + +function transformListNotesRefsResult( + raw: ListNotesRefsResponse +): ListNotesRefsResult { + return { + refs: raw.refs.map(transformNotesRefInfo), + nextCursor: raw.next_cursor ?? undefined, + hasMore: raw.has_more, + prefix: raw.prefix, + }; +} + function buildNoteWriteBody( sha: string, note: string, action: 'add' | 'append', - options: { expectedRefSha?: string; author?: { name: string; email: string } } + options: { + expectedRefSha?: string; + author?: { name: string; email: string }; + ref?: string; + } ): Record { const body: Record = { sha, @@ -711,6 +740,11 @@ function buildNoteWriteBody( note, }; + const ref = options.ref?.trim(); + if (ref) { + body.ref = ref; + } + const expectedRefSha = options.expectedRefSha?.trim(); if (expectedRefSha) { body.expected_ref_sha = expectedRefSha; @@ -1268,8 +1302,14 @@ class RepoImpl implements Repo { ttl, }); + const params: Record = { sha }; + const ref = options?.ref?.trim(); + if (ref) { + params.ref = ref; + } + const response = await this.api.get( - { path: 'repos/notes', params: { sha } }, + { path: 'repos/notes', params }, jwt ); const raw = noteReadResponseSchema.parse(await response.json()); @@ -1297,6 +1337,7 @@ class RepoImpl implements Repo { const body = buildNoteWriteBody(sha, note, 'add', { expectedRefSha: options.expectedRefSha, author: options.author, + ref: options.ref, }); const response = await this.api.post({ path: 'repos/notes', body }, jwt, { @@ -1343,6 +1384,7 @@ class RepoImpl implements Repo { const body = buildNoteWriteBody(sha, note, 'append', { expectedRefSha: options.expectedRefSha, author: options.author, + ref: options.ref, }); const response = await this.api.post({ path: 'repos/notes', body }, jwt, { @@ -1385,6 +1427,11 @@ class RepoImpl implements Repo { sha, }; + const ref = options.ref?.trim(); + if (ref) { + body.ref = ref; + } + const expectedRefSha = options.expectedRefSha?.trim(); if (expectedRefSha) { body.expected_ref_sha = expectedRefSha; @@ -1427,6 +1474,56 @@ class RepoImpl implements Repo { return result; } + /** + * List Git notes refs under a prefix, with cursor pagination. Use this to + * discover custom notes namespaces before reading individual notes. + * + * Requires the custom notes refs feature to be enabled server-side; when it + * is not, the request fails with a 400 (`ApiError`). + */ + async listNotesRefs( + options?: ListNotesRefsOptions + ): Promise { + const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS); + const jwt = await this.generateJWT(this.id, { + permissions: ['git:read'], + ttl, + }); + + const prefix = options?.prefix?.trim(); + const cursor = options?.cursor; + const limit = options?.limit; + + let params: Record | undefined; + if ( + (typeof prefix === 'string' && prefix !== '') || + typeof cursor === 'string' || + typeof limit === 'number' + ) { + params = {}; + if (typeof prefix === 'string' && prefix !== '') { + params.prefix = prefix; + } + if (typeof cursor === 'string') { + params.cursor = cursor; + } + if (typeof limit === 'number') { + params.limit = limit.toString(); + } + } + + const response = await this.api.get( + { path: 'repos/notes/refs', params }, + jwt + ); + + const raw = listNotesRefsResponseSchema.parse(await response.json()); + return transformListNotesRefsResult({ + ...raw, + next_cursor: raw.next_cursor ?? undefined, + }); + } + async getBranchDiff( options: GetBranchDiffOptions ): Promise { diff --git a/packages/code-storage-typescript/src/schemas.ts b/packages/code-storage-typescript/src/schemas.ts index 7aa70f6..66c0c64 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -144,6 +144,19 @@ export const noteWriteResponseSchema = z.object({ result: noteResultSchema, }); +export const notesRefInfoSchema = z.object({ + cursor: z.string(), + ref: z.string(), + sha: z.string(), +}); + +export const listNotesRefsResponseSchema = z.object({ + refs: z.array(notesRefInfoSchema), + next_cursor: z.string().nullable().optional(), + has_more: z.boolean(), + prefix: z.string(), +}); + export const diffStatsSchema = z.object({ files: z.number(), additions: z.number(), @@ -381,6 +394,10 @@ export type RawRepoInfo = z.infer; export type ListReposResponseRaw = z.infer; export type NoteReadResponseRaw = z.infer; export type NoteWriteResponseRaw = z.infer; +export type RawNotesRefInfo = z.infer; +export type ListNotesRefsResponseRaw = z.infer< + typeof listNotesRefsResponseSchema +>; export type RawFileDiff = z.infer; export type RawFilteredFile = z.infer; export type GetBranchDiffResponseRaw = z.infer; diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 440b74c..ef9bbc3 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -21,9 +21,11 @@ import type { PreviewMergeFilteredConflictRaw as SchemaPreviewMergeFilteredConflict, PreviewMergeResponseRaw, ListTagsResponseRaw, + ListNotesRefsResponseRaw, NoteReadResponseRaw, NoteWriteResponseRaw, RawBranchInfo as SchemaRawBranchInfo, + RawNotesRefInfo as SchemaRawNotesRefInfo, RawCommitMetadata as SchemaRawCommitMetadata, RawCommitInfo as SchemaRawCommitInfo, RawFileWithMetadata as SchemaRawFileWithMetadata, @@ -130,6 +132,7 @@ export interface Repo { createNote(options: CreateNoteOptions): Promise; appendNote(options: AppendNoteOptions): Promise; deleteNote(options: DeleteNoteOptions): Promise; + listNotesRefs(options?: ListNotesRefsOptions): Promise; getBranchDiff(options: GetBranchDiffOptions): Promise; getCommitDiff(options: GetCommitDiffOptions): Promise; grep(options: GrepOptions): Promise; @@ -605,6 +608,13 @@ export interface BlameResult { // Git notes API types export interface GetNoteOptions extends GitStorageInvocationOptions { sha: string; + /** + * Notes ref to read from. A bare name like `reviews` is placed under + * `refs/notes/`; a fully-qualified `refs/notes/*` ref is also accepted. + * Defaults to `refs/notes/commits`. Custom refs require the feature to be + * enabled server-side. + */ + ref?: string; } export type GetNoteResponse = NoteReadResponseRaw; @@ -621,6 +631,13 @@ interface NoteWriteBaseOptions note: string; expectedRefSha?: string; author?: CommitSignature; + /** + * Notes ref to target. A bare name like `reviews` is placed under + * `refs/notes/`; a fully-qualified `refs/notes/*` ref is also accepted. + * Defaults to `refs/notes/commits`. Custom refs require the feature to be + * enabled server-side, and the JWT `refPolicies` must permit writing to it. + */ + ref?: string; } export type CreateNoteOptions = NoteWriteBaseOptions; @@ -632,6 +649,12 @@ export interface DeleteNoteOptions sha: string; expectedRefSha?: string; author?: CommitSignature; + /** + * Notes ref to target. A bare name like `reviews` is placed under + * `refs/notes/`; a fully-qualified `refs/notes/*` ref is also accepted. + * Defaults to `refs/notes/commits`. + */ + ref?: string; } export interface NoteWriteResultPayload { @@ -644,12 +667,46 @@ export type NoteWriteResponse = NoteWriteResponseRaw; export interface NoteWriteResult { sha: string; + /** + * The notes ref the operation targeted (the resolved value of the request + * `ref`, defaulting to `refs/notes/commits`). + */ targetRef: string; baseCommit?: string; newRefSha: string; result: NoteWriteResultPayload; } +// List notes refs API types +export interface ListNotesRefsOptions extends GitStorageInvocationOptions { + /** + * Notes ref prefix to enumerate. A bare prefix like `reviews/` is placed + * under `refs/notes/`; a fully-qualified `refs/notes/*` prefix is also + * accepted. Defaults to `refs/notes/`. + */ + prefix?: string; + cursor?: string; + limit?: number; +} + +export type RawNotesRefInfo = SchemaRawNotesRefInfo; + +export interface NotesRefInfo { + cursor: string; + ref: string; + sha: string; +} + +export type ListNotesRefsResponse = ListNotesRefsResponseRaw; + +export interface ListNotesRefsResult { + refs: NotesRefInfo[]; + nextCursor?: string; + hasMore: boolean; + /** Normalized notes ref prefix used for the listing. */ + prefix: string; +} + // Branch Diff API types export interface GetBranchDiffOptions 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 a584119..3858491 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -676,6 +676,152 @@ describe('GitStorage', () => { expect(deleteResult.targetRef).toBe('refs/notes/commits'); }); + it('targets a custom notes ref on read and write', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-notes-ref' }); + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('GET'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/notes')).toBe(true); + expect(requestUrl.searchParams.get('sha')).toBe('abc123'); + expect(requestUrl.searchParams.get('ref')).toBe('reviews'); + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + sha: 'abc123', + note: 'reviewed', + ref_sha: 'def456', + }), + } as any); + }); + + await repo.getNote({ sha: 'abc123', ref: 'reviews' }); + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('POST'); + const payload = JSON.parse(init?.body as string); + expect(payload).toEqual({ + sha: 'abc123', + action: 'add', + note: 'note content', + ref: 'reviews', + }); + return Promise.resolve({ + ok: true, + status: 201, + statusText: 'Created', + headers: { get: () => 'application/json' } as any, + json: async () => ({ + sha: 'abc123', + target_ref: 'refs/notes/reviews', + new_ref_sha: 'def456', + result: { success: true, status: 'ok' }, + }), + } as any); + }); + + const createResult = await repo.createNote({ + sha: 'abc123', + note: 'note content', + ref: 'reviews', + }); + expect(createResult.targetRef).toBe('refs/notes/reviews'); + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('DELETE'); + const payload = JSON.parse(init?.body as string); + expect(payload).toEqual({ sha: 'abc123', ref: 'refs/notes/reviews' }); + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => 'application/json' } as any, + json: async () => ({ + sha: 'abc123', + target_ref: 'refs/notes/reviews', + new_ref_sha: 'def456', + result: { success: true, status: 'ok' }, + }), + } as any); + }); + + await repo.deleteNote({ sha: 'abc123', ref: 'refs/notes/reviews' }); + }); + + it('lists notes refs with prefix and pagination', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-notes-list' }); + + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.method).toBe('GET'); + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/notes/refs')).toBe(true); + expect(requestUrl.searchParams.get('prefix')).toBe('reviews/'); + expect(requestUrl.searchParams.get('limit')).toBe('50'); + + const headers = (init?.headers ?? {}) as Record; + const payload = decodeJwtPayload(stripBearer(headers.Authorization)); + expect(payload.scopes).toEqual(['git:read']); + + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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/', + }), + } as any); + }); + + const result = await repo.listNotesRefs({ prefix: 'reviews/', limit: 50 }); + expect(result).toEqual({ + refs: [ + { + cursor: 'refs/notes/reviews/session-a', + ref: 'refs/notes/reviews/session-a', + sha: 'a1b2c3', + }, + ], + nextCursor: 'refs/notes/reviews/session-b', + hasMore: true, + prefix: 'refs/notes/reviews/', + }); + }); + + it('lists notes refs with no options and no next cursor', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-notes-list-empty' }); + + mockFetch.mockImplementationOnce((url) => { + const requestUrl = new URL(url as string); + expect(requestUrl.pathname.endsWith('/repos/notes/refs')).toBe(true); + // No query params when no options are supplied. + expect(requestUrl.search).toBe(''); + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ refs: [], has_more: false, prefix: 'refs/notes/' }), + } as any); + }); + + const result = await repo.listNotesRefs(); + expect(result.refs).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); + expect(result.prefix).toBe('refs/notes/'); + }); + it('passes ephemeral flag to getFileStream', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = await store.createRepo({ id: 'repo-ephemeral-file' }); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index d50ca27..40d0880 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -164,6 +164,7 @@ Username is always `t`. Password is the JWT. | Append to note | POST | `/repos/notes` (action:"append") | `git:write` | | Get note for commit | GET | `/repos/notes?sha=SHA` | `git:read` | | Delete note | DELETE | `/repos/notes` | `git:write` | +| List notes refs | GET | `/repos/notes/refs` | `git:read` | | **GIT SYNC** | | | | | Pull from upstream | POST | `/repos/pull-upstream` | `git:write` | | Detach upstream | DELETE | `/repos/base` | `git:write` | @@ -723,12 +724,52 @@ curl "$CODE_STORAGE_BASE_URL/repos/notes?sha=COMMIT_SHA" \ curl "$CODE_STORAGE_BASE_URL/repos/notes" -X DELETE \ -H "Authorization: Bearer $CODE_STORAGE_TOKEN" -H "Content-Type: application/json" \ -d '{"sha":"COMMIT_SHA","author":{"name":"CI","email":"ci@x.com"}}' + +# Create/read a note on a custom notes ref (default is refs/notes/commits). +# The write `ref` field and the read `ref` query param accept a bare name like +# "reviews" (placed under refs/notes/), the "notes/reviews" shorthand, or a +# fully-qualified "refs/notes/reviews". +curl "$CODE_STORAGE_BASE_URL/repos/notes" -X POST \ + -H "Authorization: Bearer $CODE_STORAGE_TOKEN" -H "Content-Type: application/json" \ + -d '{"sha":"COMMIT_SHA","action":"add","note":"LGTM","ref":"reviews"}' +curl "$CODE_STORAGE_BASE_URL/repos/notes?sha=COMMIT_SHA&ref=reviews" \ + -H "Authorization: Bearer $CODE_STORAGE_TOKEN" ``` -Optional fields on writes: `expected_ref_sha` (optimistic guard), `author` (`name`+`email`). -Write response: `{ "sha", "target_ref": "refs/notes/commits", "base_commit?", "new_ref_sha", "result": { "success", "status", "message?" } }` +Optional fields on writes: `expected_ref_sha` (optimistic guard), `author` (`name`+`email`), +`ref` (target notes ref). `add` fails if a note already exists; use `append` to +extend one, or delete then add to replace it. +Write response: `{ "sha", "target_ref", "base_commit?", "new_ref_sha", "result": { "success", "status", "message?" } }` +(`target_ref` echoes the resolved `ref`, defaulting to `refs/notes/commits`). Read response: `{ "sha", "note", "ref_sha" }` +**Notes refs.** Every note lives under a `refs/notes/*` ref. Omit `ref` to use +the default `refs/notes/commits`. A non-default `ref` requires custom notes refs +to be enabled server-side (otherwise HTTP 400 `custom notes refs are not +enabled`); when writing to a custom ref, the JWT's per-ref policies must permit +it. Notes are not synced to a connected GitHub remote — custom refs stay +internal. + +## List Notes Refs (GET /repos/notes/refs) + +Enumerate `refs/notes/*` refs under a prefix with cursor pagination — use it to +discover custom notes namespaces before reading individual notes. Requires +custom notes refs to be enabled server-side (otherwise HTTP 400). Scope: +`git:read`. + +```bash +# List notes refs under refs/notes/reviews/ +curl "$CODE_STORAGE_BASE_URL/repos/notes/refs?prefix=reviews/&limit=20" \ + -H "Authorization: Bearer $CODE_STORAGE_TOKEN" +``` + +Query params: `prefix` (notes ref prefix; a bare `reviews/` is placed under +`refs/notes/`, a fully-qualified `refs/notes/*` prefix also works; defaults to +`refs/notes/`), `cursor` (from a previous response), `limit` (default 20). +Response: `{ "refs": [{ "cursor", "ref", "sha" }], "next_cursor?", "has_more", "prefix" }` +where `prefix` is the normalized prefix used for the listing. Paginate by passing +`next_cursor` back as `cursor` until `has_more` is `false`. + ## POST /repos/pull-upstream — Sync from Upstream ```bash @@ -1055,7 +1096,7 @@ git push origin feature-branch | Ephemeral namespace | Set `ephemeral:true` on commits/files; URL: `REPO_ID+ephemeral.git`; no GitHub sync | | Forking | One-time copy from Code Storage repo. Independent after fork. Same org only. | | Git Sync | Upstream sync via GitHub App or generic HTTPS Git providers with stored credentials. | -| Notes | Attach metadata to commits without modifying commit SHA. Stored in `refs/notes/commits`| +| Notes | Attach metadata to commits without modifying commit SHA. Default ref `refs/notes/commits`; pass `ref` to target another `refs/notes/*` ref (custom refs must be enabled server-side). List refs via `GET /repos/notes/refs`. | | Pagination | Cursor-based. Pass `next_cursor` as `cursor` param. Stop when `has_more: false`. | | Blob data encoding | Always base64. Max 4 MiB per chunk. Use multiple chunks for large files. | | `expected_head_sha` | Optimistic lock. Provide current branch tip SHA to enforce fast-forward semantics. |