feat(repos): add repository deploy key endpoints
Some checks failed
Security Scan / security (push) Successful in 12s
Test / test (push) Has been cancelled

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 00:45:19 +00:00
parent 20e038265f
commit 1f6cfbfd8b
2 changed files with 195 additions and 0 deletions

View file

@ -20,6 +20,26 @@ type RepoService struct {
Resource[types.Repository, types.CreateRepoOption, types.EditRepoOption]
}
// RepoKeyListOptions controls filtering for repository key listings.
type RepoKeyListOptions struct {
KeyID int64
Fingerprint string
}
func (o RepoKeyListOptions) queryParams() map[string]string {
query := make(map[string]string, 2)
if o.KeyID != 0 {
query["key_id"] = strconv.FormatInt(o.KeyID, 10)
}
if o.Fingerprint != "" {
query["fingerprint"] = o.Fingerprint
}
if len(query) == 0 {
return nil
}
return query
}
func newRepoService(c *Client) *RepoService {
return &RepoService{
Resource: *NewResource[types.Repository, types.CreateRepoOption, types.EditRepoOption](
@ -126,6 +146,44 @@ func (s *RepoService) DeleteTagProtection(ctx context.Context, owner, repo strin
return s.client.Delete(ctx, path)
}
// ListKeys returns all deploy keys for a repository.
func (s *RepoService) ListKeys(ctx context.Context, owner, repo string, filters ...RepoKeyListOptions) ([]types.DeployKey, error) {
path := ResolvePath("/api/v1/repos/{owner}/{repo}/keys", pathParams("owner", owner, "repo", repo))
return ListAll[types.DeployKey](ctx, s.client, path, repoKeyQuery(filters...))
}
// IterKeys returns an iterator over all deploy keys for a repository.
func (s *RepoService) IterKeys(ctx context.Context, owner, repo string, filters ...RepoKeyListOptions) iter.Seq2[types.DeployKey, error] {
path := ResolvePath("/api/v1/repos/{owner}/{repo}/keys", pathParams("owner", owner, "repo", repo))
return ListIter[types.DeployKey](ctx, s.client, path, repoKeyQuery(filters...))
}
// GetKey returns a single deploy key by ID.
func (s *RepoService) GetKey(ctx context.Context, owner, repo string, id int64) (*types.DeployKey, error) {
path := ResolvePath("/api/v1/repos/{owner}/{repo}/keys/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id)))
var out types.DeployKey
if err := s.client.Get(ctx, path, &out); err != nil {
return nil, err
}
return &out, nil
}
// CreateKey adds a deploy key to a repository.
func (s *RepoService) CreateKey(ctx context.Context, owner, repo string, opts *types.CreateKeyOption) (*types.DeployKey, error) {
path := ResolvePath("/api/v1/repos/{owner}/{repo}/keys", pathParams("owner", owner, "repo", repo))
var out types.DeployKey
if err := s.client.Post(ctx, path, opts, &out); err != nil {
return nil, err
}
return &out, nil
}
// DeleteKey removes a deploy key from a repository by ID.
func (s *RepoService) DeleteKey(ctx context.Context, owner, repo string, id int64) error {
path := ResolvePath("/api/v1/repos/{owner}/{repo}/keys/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id)))
return s.client.Delete(ctx, path)
}
// ListStargazers returns all users who starred a repository.
func (s *RepoService) ListStargazers(ctx context.Context, owner, repo string) ([]types.User, error) {
path := ResolvePath("/api/v1/repos/{owner}/{repo}/stargazers", pathParams("owner", owner, "repo", repo))
@ -589,3 +647,23 @@ func (s *RepoService) SyncPushMirrors(ctx context.Context, owner, repo string) e
path := ResolvePath("/api/v1/repos/{owner}/{repo}/push_mirrors-sync", pathParams("owner", owner, "repo", repo))
return s.client.Post(ctx, path, nil, nil)
}
func repoKeyQuery(filters ...RepoKeyListOptions) map[string]string {
if len(filters) == 0 {
return nil
}
query := make(map[string]string, 2)
for _, filter := range filters {
if filter.KeyID != 0 {
query["key_id"] = strconv.FormatInt(filter.KeyID, 10)
}
if filter.Fingerprint != "" {
query["fingerprint"] = filter.Fingerprint
}
}
if len(query) == 0 {
return nil
}
return query
}

View file

@ -628,6 +628,123 @@ func TestRepoService_DeleteTagProtection_Good(t *testing.T) {
}
}
func TestRepoService_ListKeysWithFilters_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/core/go-forge/keys" {
t.Errorf("wrong path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("key_id"); got != "7" {
t.Errorf("got key_id=%q, want %q", got, "7")
}
if got := r.URL.Query().Get("fingerprint"); got != "aa:bb:cc" {
t.Errorf("got fingerprint=%q, want %q", got, "aa:bb:cc")
}
if got := r.URL.Query().Get("page"); got != "1" {
t.Errorf("got page=%q, want %q", got, "1")
}
if got := r.URL.Query().Get("limit"); got != "50" {
t.Errorf("got limit=%q, want %q", got, "50")
}
w.Header().Set("X-Total-Count", "1")
json.NewEncoder(w).Encode([]types.DeployKey{{ID: 7, Title: "deploy"}})
}))
defer srv.Close()
f := NewForge(srv.URL, "tok")
keys, err := f.Repos.ListKeys(context.Background(), "core", "go-forge", RepoKeyListOptions{
KeyID: 7,
Fingerprint: "aa:bb:cc",
})
if err != nil {
t.Fatal(err)
}
if len(keys) != 1 {
t.Fatalf("got %d keys, want 1", len(keys))
}
if keys[0].ID != 7 || keys[0].Title != "deploy" {
t.Fatalf("got %#v", keys[0])
}
}
func TestRepoService_GetKey_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/core/go-forge/keys/7" {
t.Errorf("wrong path: %s", r.URL.Path)
}
json.NewEncoder(w).Encode(types.DeployKey{ID: 7, Title: "deploy"})
}))
defer srv.Close()
f := NewForge(srv.URL, "tok")
key, err := f.Repos.GetKey(context.Background(), "core", "go-forge", 7)
if err != nil {
t.Fatal(err)
}
if key.ID != 7 || key.Title != "deploy" {
t.Fatalf("got %#v", key)
}
}
func TestRepoService_CreateKey_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/core/go-forge/keys" {
t.Errorf("wrong path: %s", r.URL.Path)
}
var opts types.CreateKeyOption
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
t.Fatal(err)
}
if opts.Title != "deploy" || opts.Key != "ssh-ed25519 AAAA..." || !opts.ReadOnly {
t.Fatalf("got %#v", opts)
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(types.DeployKey{ID: 9, Title: opts.Title, Key: opts.Key, ReadOnly: opts.ReadOnly})
}))
defer srv.Close()
f := NewForge(srv.URL, "tok")
key, err := f.Repos.CreateKey(context.Background(), "core", "go-forge", &types.CreateKeyOption{
Title: "deploy",
Key: "ssh-ed25519 AAAA...",
ReadOnly: true,
})
if err != nil {
t.Fatal(err)
}
if key.ID != 9 || key.Title != "deploy" || !key.ReadOnly {
t.Fatalf("got %#v", key)
}
}
func TestRepoService_DeleteKey_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("expected DELETE, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/core/go-forge/keys/7" {
t.Errorf("wrong path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
f := NewForge(srv.URL, "tok")
if err := f.Repos.DeleteKey(context.Background(), "core", "go-forge", 7); err != nil {
t.Fatal(err)
}
}
func TestRepoService_DeleteTag_Bad_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {