diff --git a/admin.go b/admin.go index 3af3560..9974a44 100644 --- a/admin.go +++ b/admin.go @@ -54,6 +54,18 @@ func (o AdminActionsRunListOptions) queryParams() map[string]string { return query } +// AdminUnadoptedListOptions controls filtering for unadopted repository listings. +type AdminUnadoptedListOptions struct { + Pattern string +} + +func (o AdminUnadoptedListOptions) queryParams() map[string]string { + if o.Pattern == "" { + return nil + } + return map[string]string{"pattern": o.Pattern} +} + func newAdminService(c *Client) *AdminService { return &AdminService{client: c} } @@ -350,6 +362,16 @@ func (s *AdminService) DeleteQuotaRule(ctx context.Context, quotarule string) er return s.client.Delete(ctx, path) } +// ListUnadoptedRepos returns all unadopted repositories on the instance. +func (s *AdminService) ListUnadoptedRepos(ctx context.Context, filters ...AdminUnadoptedListOptions) ([]string, error) { + return ListAll[string](ctx, s.client, "/api/v1/admin/unadopted", adminUnadoptedQuery(filters...)) +} + +// IterUnadoptedRepos returns an iterator over all unadopted repositories on the instance. +func (s *AdminService) IterUnadoptedRepos(ctx context.Context, filters ...AdminUnadoptedListOptions) iter.Seq2[string, error] { + return ListIter[string](ctx, s.client, "/api/v1/admin/unadopted", adminUnadoptedQuery(filters...)) +} + // SearchEmails searches all email addresses by keyword (admin only). func (s *AdminService) SearchEmails(ctx context.Context, q string) ([]types.Email, error) { return ListAll[types.Email](ctx, s.client, "/api/v1/admin/emails/search", map[string]string{"q": q}) @@ -450,6 +472,29 @@ func (s *AdminService) AdoptRepo(ctx context.Context, owner, repo string) error return s.client.Post(ctx, path, nil, nil) } +// DeleteUnadoptedRepo deletes an unadopted repository's files. +func (s *AdminService) DeleteUnadoptedRepo(ctx context.Context, owner, repo string) error { + path := ResolvePath("/api/v1/admin/unadopted/{owner}/{repo}", Params{"owner": owner, "repo": repo}) + return s.client.Delete(ctx, path) +} + +func adminUnadoptedQuery(filters ...AdminUnadoptedListOptions) map[string]string { + if len(filters) == 0 { + return nil + } + + query := make(map[string]string, 1) + for _, filter := range filters { + if filter.Pattern != "" { + query["pattern"] = filter.Pattern + } + } + if len(query) == 0 { + return nil + } + return query +} + // GenerateRunnerToken generates an actions runner registration token. func (s *AdminService) GenerateRunnerToken(ctx context.Context) (string, error) { var out struct { diff --git a/admin_test.go b/admin_test.go index b6bc11f..8b31cb2 100644 --- a/admin_test.go +++ b/admin_test.go @@ -867,6 +867,58 @@ func TestAdminService_AdoptRepo_Good(t *testing.T) { } } +func TestAdminService_ListUnadoptedRepos_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/admin/unadopted" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("pattern"); got != "core/*" { + t.Errorf("got pattern=%q, want %q", got, "core/*") + } + 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([]string{"core/myrepo"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repos, err := f.Admin.ListUnadoptedRepos(context.Background(), AdminUnadoptedListOptions{Pattern: "core/*"}) + if err != nil { + t.Fatal(err) + } + if len(repos) != 1 || repos[0] != "core/myrepo" { + t.Fatalf("unexpected result: %#v", repos) + } +} + +func TestAdminService_DeleteUnadoptedRepo_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/admin/unadopted/alice/myrepo" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Admin.DeleteUnadoptedRepo(context.Background(), "alice", "myrepo"); err != nil { + t.Fatal(err) + } +} + func TestAdminService_ListActionsRuns_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/issues.go b/issues.go index f72d2b2..5490dd1 100644 --- a/issues.go +++ b/issues.go @@ -26,6 +26,26 @@ type AttachmentUploadOptions struct { UpdatedAt *time.Time } +// RepoCommentListOptions controls filtering for repository-wide issue comment listings. +type RepoCommentListOptions struct { + Since *time.Time + Before *time.Time +} + +func (o RepoCommentListOptions) queryParams() map[string]string { + query := make(map[string]string, 2) + if o.Since != nil { + query["since"] = o.Since.Format(time.RFC3339) + } + if o.Before != nil { + query["before"] = o.Before.Format(time.RFC3339) + } + if len(query) == 0 { + return nil + } + return query +} + func newIssueService(c *Client) *IssueService { return &IssueService{ Resource: *NewResource[types.Issue, types.CreateIssueOption, types.EditIssueOption]( @@ -288,6 +308,44 @@ func (s *IssueService) DeleteComment(ctx context.Context, owner, repo string, in return s.client.Delete(ctx, path) } +// ListRepoComments returns all comments in a repository. +func (s *IssueService) ListRepoComments(ctx context.Context, owner, repo string, filters ...RepoCommentListOptions) ([]types.Comment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Comment](ctx, s.client, path, repoCommentQuery(filters...)) +} + +// IterRepoComments returns an iterator over all comments in a repository. +func (s *IssueService) IterRepoComments(ctx context.Context, owner, repo string, filters ...RepoCommentListOptions) iter.Seq2[types.Comment, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Comment](ctx, s.client, path, repoCommentQuery(filters...)) +} + +// GetRepoComment returns a single comment in a repository. +func (s *IssueService) GetRepoComment(ctx context.Context, owner, repo string, id int64) (*types.Comment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + var out types.Comment + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditRepoComment updates a repository comment. +func (s *IssueService) EditRepoComment(ctx context.Context, owner, repo string, id int64, opts *types.EditIssueCommentOption) (*types.Comment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + var out types.Comment + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteRepoComment deletes a repository comment. +func (s *IssueService) DeleteRepoComment(ctx context.Context, owner, repo string, id int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return s.client.Delete(ctx, path) +} + // ListCommentReactions returns all reactions on an issue comment. func (s *IssueService) ListCommentReactions(ctx context.Context, owner, repo string, id int64) ([]types.Reaction, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/reactions", pathParams("owner", owner, "repo", repo, "id", int64String(id))) @@ -552,6 +610,23 @@ func toAnySlice(ids []int64) []any { return out } +func repoCommentQuery(filters ...RepoCommentListOptions) map[string]string { + if len(filters) == 0 { + return nil + } + + query := make(map[string]string, 2) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + return nil + } + return query +} + func issueTimeQuery(user string, since, before *time.Time) map[string]string { query := make(map[string]string, 3) if user != "" { diff --git a/issues_test.go b/issues_test.go index e9d2ef5..6d65335 100644 --- a/issues_test.go +++ b/issues_test.go @@ -353,6 +353,119 @@ func TestIssueService_CreateComment_Good(t *testing.T) { } } +func TestIssueService_ListRepoComments_Good(t *testing.T) { + since := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC) + before := time.Date(2026, 4, 2, 10, 0, 0, 0, time.UTC) + 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/issues/comments" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("since"); got != since.Format(time.RFC3339) { + t.Errorf("got since=%q, want %q", got, since.Format(time.RFC3339)) + } + if got := r.URL.Query().Get("before"); got != before.Format(time.RFC3339) { + t.Errorf("got before=%q, want %q", got, before.Format(time.RFC3339)) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Comment{ + {ID: 7, Body: "repo-wide comment"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + comments, err := f.Issues.ListRepoComments(context.Background(), "core", "go-forge", RepoCommentListOptions{ + Since: &since, + Before: &before, + }) + if err != nil { + t.Fatal(err) + } + if len(comments) != 1 || comments[0].Body != "repo-wide comment" { + t.Fatalf("unexpected result: %#v", comments) + } +} + +func TestIssueService_GetRepoComment_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/issues/comments/7" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.Comment{ID: 7, Body: "repo-wide comment"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + comment, err := f.Issues.GetRepoComment(context.Background(), "core", "go-forge", 7) + if err != nil { + t.Fatal(err) + } + if comment.Body != "repo-wide comment" { + t.Fatalf("got body=%q", comment.Body) + } +} + +func TestIssueService_EditRepoComment_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/comments/7" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.EditIssueCommentOption + json.NewDecoder(r.Body).Decode(&body) + if body.Body != "updated comment" { + t.Fatalf("got body=%#v", body) + } + json.NewEncoder(w).Encode(types.Comment{ID: 7, Body: body.Body}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + comment, err := f.Issues.EditRepoComment(context.Background(), "core", "go-forge", 7, &types.EditIssueCommentOption{ + Body: "updated comment", + }) + if err != nil { + t.Fatal(err) + } + if comment.Body != "updated comment" { + t.Fatalf("got body=%q", comment.Body) + } +} + +func TestIssueService_DeleteRepoComment_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/issues/comments/7" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.DeleteRepoComment(context.Background(), "core", "go-forge", 7); err != nil { + t.Fatal(err) + } +} + func TestIssueService_ListReactions_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/misc.go b/misc.go index 85e23d3..d53d15f 100644 --- a/misc.go +++ b/misc.go @@ -135,6 +135,15 @@ func (s *MiscService) GetNodeInfo(ctx context.Context) (*types.NodeInfo, error) return &out, nil } +// GetSigningKey returns the instance's default signing key. +func (s *MiscService) GetSigningKey(ctx context.Context) (string, error) { + data, err := s.client.GetRaw(ctx, "/api/v1/signing-key.gpg") + if err != nil { + return "", err + } + return string(data), nil +} + // GetAPISettings returns the instance's global API settings. func (s *MiscService) GetAPISettings(ctx context.Context) (*types.GeneralAPISettings, error) { var out types.GeneralAPISettings diff --git a/misc_test.go b/misc_test.go index 14f5c98..3e03580 100644 --- a/misc_test.go +++ b/misc_test.go @@ -451,6 +451,29 @@ func TestMiscService_GetNodeInfo_Good(t *testing.T) { } } +func TestMiscService_GetSigningKey_Good(t *testing.T) { + want := "-----BEGIN PGP PUBLIC KEY BLOCK-----\n..." + 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/signing-key.gpg" { + t.Errorf("wrong path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(want)) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Misc.GetSigningKey(context.Background()) + if err != nil { + t.Fatal(err) + } + if key != want { + t.Fatalf("got %q, want %q", key, want) + } +} + func TestMiscService_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound)