diff --git a/actions.go b/actions.go index bb26a99..ef2ad89 100644 --- a/actions.go +++ b/actions.go @@ -3,11 +3,8 @@ package forge import ( "context" "iter" - "net/url" - "strconv" - core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // ActionsService handles CI/CD actions operations across repositories and @@ -213,19 +210,14 @@ func (s *ActionsService) ListRepoTasks(ctx context.Context, owner, repo string, path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/tasks", pathParams("owner", owner, "repo", repo)) if opts.Page > 0 || opts.Limit > 0 { - u, err := url.Parse(path) - if err != nil { - return nil, core.E("ActionsService.ListRepoTasks", "forge: parse path", err) - } - q := u.Query() - if opts.Page > 0 { - q.Set("page", strconv.Itoa(opts.Page)) - } - if opts.Limit > 0 { - q.Set("limit", strconv.Itoa(opts.Limit)) - } - u.RawQuery = q.Encode() - path = u.String() + path = appendQuery(path, func(q *queryBuilder) { + if opts.Page > 0 { + q.Set("page", intString(opts.Page)) + } + if opts.Limit > 0 { + q.Set("limit", intString(opts.Limit)) + } + }) } var out types.ActionTaskResponse diff --git a/actions_test.go b/actions_test.go index 755ce7b..38d59c8 100644 --- a/actions_test.go +++ b/actions_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestActionsService_ListRepoSecrets_Good(t *testing.T) { diff --git a/activitypub.go b/activitypub.go index e3141cb..4138fed 100644 --- a/activitypub.go +++ b/activitypub.go @@ -3,7 +3,7 @@ package forge import ( "context" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // ActivityPubService handles ActivityPub actor and inbox endpoints. diff --git a/activitypub_test.go b/activitypub_test.go index 6ad2442..07d65a6 100644 --- a/activitypub_test.go +++ b/activitypub_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestActivityPubService_GetInstanceActor_Good(t *testing.T) { diff --git a/admin.go b/admin.go index 331c18f..6768680 100644 --- a/admin.go +++ b/admin.go @@ -8,7 +8,7 @@ import ( "strconv" core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // AdminService handles site administration operations. diff --git a/admin_test.go b/admin_test.go index 8b31cb2..12c009e 100644 --- a/admin_test.go +++ b/admin_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestAdminService_ListUsers_Good(t *testing.T) { diff --git a/ax_stringer_test.go b/ax_stringer_test.go index e638ca5..7f17317 100644 --- a/ax_stringer_test.go +++ b/ax_stringer_test.go @@ -35,8 +35,8 @@ func TestParams_String_NilSafe(t *testing.T) { } func TestListOptions_String_Good(t *testing.T) { - opts := ListOptions{Page: 2, Limit: 25} - want := "forge.ListOptions{page=2, limit=25}" + opts := ListOptions{Page: 2, PageSize: 25} + want := "forge.ListOptions{page=2, page_size=25}" if got := opts.String(); got != want { t.Fatalf("got String()=%q, want %q", got, want) } diff --git a/branches.go b/branches.go index e7d4320..234d46a 100644 --- a/branches.go +++ b/branches.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // BranchService handles branch operations within a repository. @@ -25,6 +25,12 @@ func newBranchService(c *Client) *BranchService { } } +// ListBranchesPage returns a single page of branches for a repository. +func (s *BranchService) ListBranchesPage(ctx context.Context, owner, repo string, opts ListOptions) (*PagedResult[types.Branch], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branches", pathParams("owner", owner, "repo", repo)) + return ListPage[types.Branch](ctx, s.client, path, nil, opts) +} + // ListBranches returns all branches for a repository. func (s *BranchService) ListBranches(ctx context.Context, owner, repo string) ([]types.Branch, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/branches", pathParams("owner", owner, "repo", repo)) diff --git a/branches_extra_test.go b/branches_extra_test.go index ce1bbfb..c6dca0b 100644 --- a/branches_extra_test.go +++ b/branches_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestBranchService_ListBranches_Good(t *testing.T) { diff --git a/branches_test.go b/branches_test.go index 650507d..2534f57 100644 --- a/branches_test.go +++ b/branches_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestBranchService_List_Good(t *testing.T) { diff --git a/client.go b/client.go index 1b650a7..15b6b4b 100644 --- a/client.go +++ b/client.go @@ -1,17 +1,19 @@ package forge import ( - "bytes" + // Note: AX-6 — request cancellation and deadlines are structural to HTTP calls; no core context primitive exists. "context" + // Note: goccy/go-json — faster drop-in encoding/json replacement; no core equivalent for JSON-decoder performance profile. json "github.com/goccy/go-json" + // Note: AX-6 — multipart upload bodies require the standard multipart writer; core-io provides storage, not multipart encoding. "mime/multipart" + // Note: AX-6 — this low-level Forgejo client owns the HTTP boundary; no core.Client equivalent exists. "net/http" - "net/url" - "strconv" - + // Note: AX-6 — stream types, multipart content piping, and bounded HTTP error reads require io; coreio Medium only replaces multipart buffering below. goio "io" core "dappco.re/go/core" + coreio "dappco.re/go/io" ) // APIError represents an error response from the Forgejo API. @@ -36,7 +38,7 @@ func (e *APIError) Error() string { if e == nil { return "forge.APIError{}" } - return core.Concat("forge: ", e.URL, " ", strconv.Itoa(e.StatusCode), ": ", e.Message) + return core.Concat("forge: ", e.URL, " ", core.Itoa(e.StatusCode), ": ", e.Message) } // String returns a safe summary of the API error. @@ -135,11 +137,11 @@ type RateLimit struct { func (r RateLimit) String() string { return core.Concat( "forge.RateLimit{limit=", - strconv.Itoa(r.Limit), + core.Itoa(r.Limit), ", remaining=", - strconv.Itoa(r.Remaining), + core.Itoa(r.Remaining), ", reset=", - strconv.FormatInt(r.Reset, 10), + core.FormatInt(r.Reset, 10), "}", ) } @@ -226,7 +228,7 @@ func (c *Client) String() string { if c.HasToken() { tokenState = "set" } - return core.Concat("forge.Client{baseURL=", strconv.Quote(c.baseURL), ", token=", tokenState, ", userAgent=", strconv.Quote(c.userAgent), "}") + return core.Concat("forge.Client{baseURL=", core.Sprintf("%q", c.baseURL), ", token=", tokenState, ", userAgent=", core.Sprintf("%q", c.userAgent), "}") } // GoString returns a safe Go-syntax summary of the client configuration. @@ -351,21 +353,24 @@ func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, er func (c *Client) postRawJSON(ctx context.Context, path string, body any) ([]byte, error) { url := c.baseURL + path - var bodyReader goio.Reader - if body != nil { + var req *http.Request + var err error + if body == nil { + req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + } else { data, err := json.Marshal(body) if err != nil { return nil, core.E("Client.PostRaw", "forge: marshal body", err) } - bodyReader = bytes.NewReader(data) + req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, core.NewReader(string(data))) } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bodyReader) if err != nil { return nil, core.E("Client.PostRaw", "forge: create request", err) } - req.Header.Set("Authorization", "token "+c.token) + if auth := c.authorizationHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } req.Header.Set("Content-Type", "application/json") if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) @@ -383,7 +388,7 @@ func (c *Client) postRawJSON(ctx context.Context, path string, body any) ([]byte return nil, c.parseError(resp, path) } - data, err := goio.ReadAll(resp.Body) + data, err := readBody(resp.Body) if err != nil { return nil, core.E("Client.PostRaw", "forge: read response body", err) } @@ -394,12 +399,14 @@ func (c *Client) postRawJSON(ctx context.Context, path string, body any) ([]byte func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, error) { url := c.baseURL + path - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, core.NewReader(body)) if err != nil { return nil, core.E("Client.PostText", "forge: create request", err) } - req.Header.Set("Authorization", "token "+c.token) + if auth := c.authorizationHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } req.Header.Set("Accept", "text/html") req.Header.Set("Content-Type", "text/plain") if c.userAgent != "" { @@ -418,7 +425,7 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er return nil, c.parseError(resp, path) } - data, err := goio.ReadAll(resp.Body) + data, err := readBody(resp.Body) if err != nil { return nil, core.E("Client.PostText", "forge: read response body", err) } @@ -427,20 +434,47 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er } func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[string]string, fields map[string]string, fieldName, fileName string, content goio.Reader, out any) error { - target, err := url.Parse(c.baseURL + path) - if err != nil { + target := c.baseURL + path + parsedTarget := core.URLParse(target) + if !parsedTarget.OK { + var err error + if parseErr, ok := parsedTarget.Value.(error); ok { + err = parseErr + } return core.E("Client.PostMultipart", "forge: parse url", err) } + targetStringer, ok := parsedTarget.Value.(interface{ String() string }) + if !ok { + return core.E("Client.PostMultipart", "forge: parse url", nil) + } + target = targetStringer.String() if len(query) > 0 { - values := target.Query() + values := newQueryBuilder() for key, value := range query { values.Set(key, value) } - target.RawQuery = values.Encode() + if encoded := values.Encode(); encoded != "" { + separator := "?" + if core.Contains(target, "?") { + separator = "&" + } + target = core.Concat(target, separator, encoded) + } + } + + body := coreio.NewMemoryMedium() + bodyWriter, err := body.Create("multipart") + if err != nil { + return core.E("Client.PostMultipart", "forge: create multipart body", err) } + bodyWriterOpen := true + defer func() { + if bodyWriterOpen { + _ = bodyWriter.Close() + } + }() - var body bytes.Buffer - writer := multipart.NewWriter(&body) + writer := multipart.NewWriter(bodyWriter) for key, value := range fields { if err := writer.WriteField(key, value); err != nil { return core.E("Client.PostMultipart", "forge: create multipart form field", err) @@ -460,13 +494,27 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s if err := writer.Close(); err != nil { return core.E("Client.PostMultipart", "forge: close multipart writer", err) } + if err := bodyWriter.Close(); err != nil { + bodyWriterOpen = false + return core.E("Client.PostMultipart", "forge: close multipart body", err) + } + bodyWriterOpen = false - req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), &body) + bodyReader, err := body.ReadStream("multipart") + if err != nil { + return core.E("Client.PostMultipart", "forge: read multipart body", err) + } + defer bodyReader.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bodyReader) if err != nil { return core.E("Client.PostMultipart", "forge: create request", err) } - req.Header.Set("Authorization", "token "+c.token) + if auth := c.authorizationHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } + req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", writer.FormDataContentType()) if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) @@ -478,6 +526,8 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s } defer resp.Body.Close() + c.updateRateLimit(resp) + if resp.StatusCode >= 400 { return c.parseError(resp, path) } @@ -505,7 +555,9 @@ func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { return nil, core.E("Client.GetRaw", "forge: create request", err) } - req.Header.Set("Authorization", "token "+c.token) + if auth := c.authorizationHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) } @@ -522,7 +574,7 @@ func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { return nil, c.parseError(resp, path) } - data, err := goio.ReadAll(resp.Body) + data, err := readBody(resp.Body) if err != nil { return nil, core.E("Client.GetRaw", "forge: read response body", err) } @@ -538,21 +590,24 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) (*http.Response, error) { url := c.baseURL + path - var bodyReader goio.Reader - if body != nil { + var req *http.Request + var err error + if body == nil { + req, err = http.NewRequestWithContext(ctx, method, url, nil) + } else { data, err := json.Marshal(body) if err != nil { return nil, core.E("Client.doJSON", "forge: marshal body", err) } - bodyReader = bytes.NewReader(data) + req, err = http.NewRequestWithContext(ctx, method, url, core.NewReader(string(data))) } - - req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { return nil, core.E("Client.doJSON", "forge: create request", err) } - req.Header.Set("Authorization", "token "+c.token) + if auth := c.authorizationHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") @@ -588,7 +643,7 @@ func (c *Client) parseError(resp *http.Response, path string) error { } // Read a bit of the body to see if we can get a message - data, _ := goio.ReadAll(goio.LimitReader(resp.Body, 1024)) + data, _ := readBody(goio.LimitReader(resp.Body, 1024)) _ = json.Unmarshal(data, &errBody) msg := errBody.Message @@ -606,14 +661,59 @@ func (c *Client) parseError(resp *http.Response, path string) error { } } +func readBody(reader any) ([]byte, error) { + result := core.ReadAll(reader) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, err + } + return nil, core.E("Client.readBody", "forge: read body", nil) + } + + body, ok := result.Value.(string) + if !ok { + return nil, core.E("Client.readBody", "forge: read body", nil) + } + return []byte(body), nil +} + func (c *Client) updateRateLimit(resp *http.Response) { if limit := resp.Header.Get("X-RateLimit-Limit"); limit != "" { - c.rateLimit.Limit, _ = strconv.Atoi(limit) + c.rateLimit.Limit = parseRateLimitInt(limit) } if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining != "" { - c.rateLimit.Remaining, _ = strconv.Atoi(remaining) + c.rateLimit.Remaining = parseRateLimitInt(remaining) } if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" { - c.rateLimit.Reset, _ = strconv.ParseInt(reset, 10, 64) + c.rateLimit.Reset = parseRateLimitInt64(reset) + } +} + +func parseRateLimitInt(value string) int { + result := core.Atoi(value) + if !result.OK { + return 0 + } + if parsed, ok := result.Value.(int); ok { + return parsed + } + return 0 +} + +func parseRateLimitInt64(value string) int64 { + result := core.ParseInt(value, 10, 64) + if !result.OK { + return 0 + } + if parsed, ok := result.Value.(int64); ok { + return parsed + } + return 0 +} + +func (c *Client) authorizationHeader() string { + if c == nil || c.token == "" { + return "" } + return "Bearer " + c.token } diff --git a/client_test.go b/client_test.go index 818383a..84b5a10 100644 --- a/client_test.go +++ b/client_test.go @@ -6,6 +6,7 @@ import ( json "github.com/goccy/go-json" "net/http" "net/http/httptest" + "strings" "testing" core "dappco.re/go/core" @@ -16,7 +17,7 @@ func TestClient_Get_Good(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } - if r.Header.Get("Authorization") != "token test-token" { + if r.Header.Get("Authorization") != "Bearer test-token" { t.Errorf("missing auth header") } if r.URL.Path != "/api/v1/user" { @@ -139,6 +140,81 @@ func TestClient_GetRaw_Good(t *testing.T) { } } +func TestClient_PostMultipartJSON_RateLimit_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 got := r.Header.Get("Accept"); got != "application/json" { + t.Errorf("got Accept=%q, want %q", got, "application/json") + } + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("got Authorization=%q", got) + } + if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "multipart/form-data; boundary=") { + t.Errorf("got Content-Type=%q", got) + } + w.Header().Set("X-RateLimit-Limit", "70") + w.Header().Set("X-RateLimit-Remaining", "69") + w.Header().Set("X-RateLimit-Reset", "1700000003") + json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "artifact"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + var out map[string]any + err := c.postMultipartJSON( + context.Background(), + "/api/v1/repos/core/go-forge/releases/1/assets", + map[string]string{"name": "artifact"}, + nil, + "attachment", + "artifact.tar.gz", + strings.NewReader("payload"), + &out, + ) + if err != nil { + t.Fatal(err) + } + if out["name"] != "artifact" { + t.Fatalf("got out=%#v", out) + } + rl := c.RateLimit() + if rl.Limit != 70 || rl.Remaining != 69 || rl.Reset != 1700000003 { + t.Fatalf("unexpected rate limit: %+v", rl) + } +} + +func TestClient_PostMultipartJSON_RateLimitOnError_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Limit", "80") + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", "1700000004") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{"message": "forbidden"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + err := c.postMultipartJSON( + context.Background(), + "/api/v1/repos/core/go-forge/releases/1/assets", + nil, + nil, + "attachment", + "artifact.tar.gz", + strings.NewReader("payload"), + nil, + ) + if !IsForbidden(err) { + t.Fatalf("expected forbidden, got %v", err) + } + rl := c.RateLimit() + if rl.Limit != 80 || rl.Remaining != 0 || rl.Reset != 1700000004 { + t.Fatalf("unexpected rate limit: %+v", rl) + } +} + func TestClient_ServerError_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) diff --git a/cmd/forgegen/generator.go b/cmd/forgegen/generator.go index 83f89b2..82162c7 100644 --- a/cmd/forgegen/generator.go +++ b/cmd/forgegen/generator.go @@ -10,7 +10,7 @@ import ( "text/template" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) // typeGrouping maps type name prefixes to output file names. diff --git a/cmd/forgegen/generator_test.go b/cmd/forgegen/generator_test.go index 68ebf7f..af6a445 100644 --- a/cmd/forgegen/generator_test.go +++ b/cmd/forgegen/generator_test.go @@ -4,7 +4,7 @@ import ( "testing" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) func TestGenerate_CreatesFiles_Good(t *testing.T) { diff --git a/cmd/forgegen/main_test.go b/cmd/forgegen/main_test.go index 426a10e..cc109ef 100644 --- a/cmd/forgegen/main_test.go +++ b/cmd/forgegen/main_test.go @@ -4,7 +4,7 @@ import ( "testing" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) func TestMain_Run_Good(t *testing.T) { diff --git a/cmd/forgegen/parser.go b/cmd/forgegen/parser.go index a73a38f..45756fd 100644 --- a/cmd/forgegen/parser.go +++ b/cmd/forgegen/parser.go @@ -6,7 +6,7 @@ import ( "slices" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) // Spec represents a Swagger 2.0 specification document. @@ -171,11 +171,28 @@ func ExtractTypes(spec *Spec) map[string]*GoType { slices.SortFunc(gt.Fields, func(a, b GoField) int { return cmp.Compare(a.GoName, b.GoName) }) + applyRFCCompatOverrides(gt) result[name] = gt } return result } +func applyRFCCompatOverrides(gt *GoType) { + if gt == nil { + return + } + + switch gt.Name { + case "CreateIssueOption", "CreatePullRequestOption", "EditPullRequestOption": + for i := range gt.Fields { + if gt.Fields[i].JSONName == "labels" { + gt.Fields[i].GoType = "any" + gt.Fields[i].Comment = "list of label ids or names (RFC compatibility)" + } + } + } +} + func definitionAliasType(def SchemaDefinition, defs map[string]SchemaDefinition) (string, bool) { if def.Ref != "" { return refName(def.Ref), true diff --git a/cmd/forgegen/parser_test.go b/cmd/forgegen/parser_test.go index e88da5f..739866e 100644 --- a/cmd/forgegen/parser_test.go +++ b/cmd/forgegen/parser_test.go @@ -167,3 +167,35 @@ func TestParser_PrimitiveAndCollectionAliases_Good(t *testing.T) { }) } } + +func TestParser_RFCLabelCompatibility_Good(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + + types := ExtractTypes(spec) + cases := []string{ + "CreateIssueOption", + "CreatePullRequestOption", + "EditPullRequestOption", + } + + for _, typeName := range cases { + t.Run(typeName, func(t *testing.T) { + gt, ok := types[typeName] + if !ok { + t.Fatalf("type %q not found", typeName) + } + for _, field := range gt.Fields { + if field.JSONName == "labels" { + if field.GoType != "any" { + t.Fatalf("labels field: got %q, want any", field.GoType) + } + return + } + } + t.Fatalf("labels field not found on %s", typeName) + }) + } +} diff --git a/commits.go b/commits.go index 8735b46..a7f964e 100644 --- a/commits.go +++ b/commits.go @@ -3,9 +3,8 @@ package forge import ( "context" "iter" - "strconv" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // CommitService handles commit-related operations such as commit statuses @@ -60,13 +59,13 @@ func (o CommitListOptions) queryParams() map[string]string { query["path"] = o.Path } if o.Stat != nil { - query["stat"] = strconv.FormatBool(*o.Stat) + query["stat"] = boolString(*o.Stat) } if o.Verification != nil { - query["verification"] = strconv.FormatBool(*o.Verification) + query["verification"] = boolString(*o.Verification) } if o.Files != nil { - query["files"] = strconv.FormatBool(*o.Files) + query["files"] = boolString(*o.Files) } if o.Not != "" { query["not"] = o.Not @@ -110,6 +109,55 @@ func (s *CommitService) Get(ctx context.Context, params Params) (*types.Commit, return &out, nil } +// ListCommitsPage returns a single page of commits for a repository. +func (s *CommitService) ListCommitsPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...any) (*PagedResult[types.Commit], error) { + params := pathParams("owner", owner, "repo", repo) + path := ResolvePath(commitCollectionPath, params) + return ListPage[types.Commit](ctx, s.client, path, commitCompatListQuery(filters...), opts) +} + +// ListCommits returns commits for a repository using RFC-compatible filters. +func (s *CommitService) ListCommits(ctx context.Context, owner, repo string, filters ...any) ([]types.Commit, error) { + params := pathParams("owner", owner, "repo", repo) + path := ResolvePath(commitCollectionPath, params) + query := commitCompatListQuery(filters...) + if pageOpts, ok := commitCompatPageOptions(filters...); ok { + page, err := ListPage[types.Commit](ctx, s.client, path, query, pageOpts) + if err != nil { + return nil, err + } + return page.Items, nil + } + return ListAll[types.Commit](ctx, s.client, path, query) +} + +// IterCommits returns an iterator over commits for a repository using RFC-compatible filters. +func (s *CommitService) IterCommits(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.Commit, error] { + params := pathParams("owner", owner, "repo", repo) + path := ResolvePath(commitCollectionPath, params) + query := commitCompatListQuery(filters...) + if pageOpts, ok := commitCompatPageOptions(filters...); ok { + return func(yield func(types.Commit, error) bool) { + page, err := ListPage[types.Commit](ctx, s.client, path, query, pageOpts) + if err != nil { + yield(*new(types.Commit), err) + return + } + for _, item := range page.Items { + if !yield(item, nil) { + return + } + } + } + } + return ListIter[types.Commit](ctx, s.client, path, query) +} + +// GetCommit returns a single commit by SHA or ref. +func (s *CommitService) GetCommit(ctx context.Context, owner, repo, sha string) (*types.Commit, error) { + return s.Get(ctx, pathParams("owner", owner, "repo", repo, "sha", sha)) +} + // GetDiffOrPatch returns a commit diff or patch as raw bytes. func (s *CommitService) GetDiffOrPatch(ctx context.Context, owner, repo, sha, diffType string) ([]byte, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/commits/{sha}.{diffType}", pathParams("owner", owner, "repo", repo, "sha", sha, "diffType", diffType)) @@ -225,3 +273,76 @@ func commitListQuery(filters ...CommitListOptions) map[string]string { } return query } + +func commitCompatListQuery(filters ...any) map[string]string { + query := make(map[string]string, len(filters)) + for _, filter := range filters { + switch v := filter.(type) { + case CommitListOptions: + for key, value := range v.queryParams() { + query[key] = value + } + case *CommitListOptions: + if v != nil { + for key, value := range v.queryParams() { + query[key] = value + } + } + case types.ListCommitsOption: + for key, value := range commitListQueryFromCompat(v) { + query[key] = value + } + case *types.ListCommitsOption: + if v != nil { + for key, value := range commitListQueryFromCompat(*v) { + query[key] = value + } + } + } + } + if len(query) == 0 { + return nil + } + return query +} + +func commitListQueryFromCompat(filter types.ListCommitsOption) map[string]string { + query := make(map[string]string, 6) + if filter.Sha != "" { + query["sha"] = filter.Sha + } + if filter.Path != "" { + query["path"] = filter.Path + } + if filter.Stat != nil { + query["stat"] = boolString(*filter.Stat) + } + if filter.Verification != nil { + query["verification"] = boolString(*filter.Verification) + } + if filter.Files != nil { + query["files"] = boolString(*filter.Files) + } + if filter.Not != "" { + query["not"] = filter.Not + } + return query +} + +func commitCompatPageOptions(filters ...any) (ListOptions, bool) { + for _, filter := range filters { + switch v := filter.(type) { + case types.ListCommitsOption: + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + case *types.ListCommitsOption: + if v != nil { + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + } + } + } + return ListOptions{}, false +} diff --git a/commits_extra_test.go b/commits_extra_test.go index 379124b..801e263 100644 --- a/commits_extra_test.go +++ b/commits_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestCommitService_GetCombinedStatusByRef_Good(t *testing.T) { @@ -34,3 +34,61 @@ func TestCommitService_GetCombinedStatusByRef_Good(t *testing.T) { t.Fatalf("got %#v", status) } } + +func TestCommitService_ListCommits_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/commits" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("sha"); got != "main" { + t.Errorf("got sha=%q, want %q", got, "main") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Errorf("got limit=%q, want %q", got, "25") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Commit{{SHA: "abc123"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + commits, err := f.Commits.ListCommits(context.Background(), "core", "go-forge", &types.ListCommitsOption{ + Sha: "main", + Page: 2, + PageSize: 25, + }) + if err != nil { + t.Fatal(err) + } + if len(commits) != 1 || commits[0].SHA != "abc123" { + t.Fatalf("got %#v", commits) + } +} + +func TestCommitService_GetCommit_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/git/commits/abc123" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Commit{SHA: "abc123"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + commit, err := f.Commits.GetCommit(context.Background(), "core", "go-forge", "abc123") + if err != nil { + t.Fatal(err) + } + if commit.SHA != "abc123" { + t.Fatalf("got %#v", commit) + } +} diff --git a/commits_test.go b/commits_test.go index 0de4c6a..49e1481 100644 --- a/commits_test.go +++ b/commits_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestCommitService_List_Good(t *testing.T) { diff --git a/compat_helpers.go b/compat_helpers.go new file mode 100644 index 0000000..f2cc9bb --- /dev/null +++ b/compat_helpers.go @@ -0,0 +1,17 @@ +package forge + +func compatListOptions(page, pageSize, limit int) (ListOptions, bool) { + if page == 0 && pageSize == 0 && limit == 0 { + return ListOptions{}, false + } + + opts := ListOptions{ + Page: page, + PageSize: pageSize, + Limit: limit, + } + if opts.Page < 1 { + opts.Page = 1 + } + return opts, true +} diff --git a/config.go b/config.go index 6da3f02..58db749 100644 --- a/config.go +++ b/config.go @@ -4,9 +4,10 @@ import ( "encoding/json" "os" "path/filepath" + "strings" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) const ( @@ -49,7 +50,7 @@ func readConfigFile() (url, token string, err error) { data, err := coreio.Local.Read(path) if err != nil { - if os.IsNotExist(err) { + if os.IsNotExist(err) || strings.Contains(err.Error(), "no such file or directory") { return "", "", nil } return "", "", core.E("ResolveConfig", "forge: read config file", err) diff --git a/config_test.go b/config_test.go index 5d35f47..2517085 100644 --- a/config_test.go +++ b/config_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) func TestResolveConfig_EnvOverrides_Good(t *testing.T) { diff --git a/contents.go b/contents.go index 78d5c21..dd01da2 100644 --- a/contents.go +++ b/contents.go @@ -3,10 +3,8 @@ package forge import ( "context" "iter" - "net/url" - core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // ContentService handles file read/write operations via the Forgejo API. @@ -29,14 +27,9 @@ func newContentService(c *Client) *ContentService { func (s *ContentService) ListContents(ctx context.Context, owner, repo, ref string) ([]types.ContentsResponse, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/contents", pathParams("owner", owner, "repo", repo)) if ref != "" { - u, err := url.Parse(path) - if err != nil { - return nil, core.E("ContentService.ListContents", "forge: parse path", err) - } - q := u.Query() - q.Set("ref", ref) - u.RawQuery = q.Encode() - path = u.String() + path = appendQuery(path, func(q *queryBuilder) { + q.Set("ref", ref) + }) } var out []types.ContentsResponse @@ -73,6 +66,11 @@ func (s *ContentService) GetFile(ctx context.Context, owner, repo, filepath stri return &out, nil } +// GetContents returns metadata and content for a file in a repository. +func (s *ContentService) GetContents(ctx context.Context, owner, repo, filepath string) (*types.ContentsResponse, error) { + return s.GetFile(ctx, owner, repo, filepath) +} + // CreateFile creates a new file in a repository. func (s *ContentService) CreateFile(ctx context.Context, owner, repo, filepath string, opts *types.CreateFileOptions) (*types.FileResponse, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/contents/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) diff --git a/contents_test.go b/contents_test.go index abc0b6d..6bee13c 100644 --- a/contents_test.go +++ b/contents_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestContentService_ListContents_Good(t *testing.T) { @@ -161,6 +161,40 @@ func TestContentService_CreateFile_Good(t *testing.T) { } } +func TestContentService_CreateFile_ContentCompat_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/contents/docs/rfc.md" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateFileOptions + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.ContentBase64 != "Y29tcGF0" { + t.Errorf("got content=%q, want %q", opts.ContentBase64, "Y29tcGF0") + } + json.NewEncoder(w).Encode(types.FileResponse{ + Content: &types.ContentsResponse{Name: "rfc.md", Path: "docs/rfc.md"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + resp, err := f.Contents.CreateFile(context.Background(), "core", "go-forge", "docs/rfc.md", &types.CreateFileOptions{ + Content: "Y29tcGF0", + Message: "create docs/rfc.md", + }) + if err != nil { + t.Fatal(err) + } + if resp.Content == nil || resp.Content.Path != "docs/rfc.md" { + t.Fatalf("got %#v", resp) + } +} + func TestContentService_UpdateFile_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { diff --git a/forge_test.go b/forge_test.go index 78e3792..57e6071 100644 --- a/forge_test.go +++ b/forge_test.go @@ -9,7 +9,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestForge_NewForge_Good(t *testing.T) { diff --git a/go.mod b/go.mod index b3a3c6b..8abe29c 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,9 @@ -module dappco.re/go/core/forge +module dappco.re/go/forge go 1.26.0 require ( - dappco.re/go/core v0.4.7 - dappco.re/go/core/io v0.2.0 + dappco.re/go/core v0.8.0-alpha.1 + dappco.re/go/io v0.8.0-alpha.1 github.com/goccy/go-json v0.10.6 ) - -require dappco.re/go/core/log v0.0.4 // indirect diff --git a/go.sum b/go.sum index 8169f53..761d3d1 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ -dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= -dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= -dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= -dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/io v0.4.1 h1:15dm7ldhFIAuZOrBiQG6XVZDpSvCxtZsUXApwTAB3wQ= +dappco.re/go/core/io v0.4.1/go.mod h1:w71dukyunczLb8frT9JOd5B78PjwWQD3YAXiCt3AcPA= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= diff --git a/helpers.go b/helpers.go index 4c15c90..167751a 100644 --- a/helpers.go +++ b/helpers.go @@ -2,6 +2,10 @@ package forge import ( "fmt" + // Note: AX-6 intrinsic — query escaping is centralised here because the pinned core module has no core.URLEncode helper. + "net/url" + "sort" + // Note: AX-6 intrinsic — the pinned core module has no core.Itoa/core.FormatInt/core.Atoi/core.FormatBool/core.Quote helpers. "strconv" "strings" "time" @@ -20,6 +24,19 @@ func int64String(v int64) string { return strconv.FormatInt(v, 10) } +func intString(v int) string { + return strconv.Itoa(v) +} + +func boolString(v bool) string { + return strconv.FormatBool(v) +} + +func parseInt(value string) int { + v, _ := strconv.Atoi(value) + return v +} + func pathParams(values ...string) Params { params := make(Params, len(values)/2) for i := 0; i+1 < len(values); i += 2 { @@ -119,3 +136,62 @@ func lastIndexByte(s string, b byte) int { } return -1 } + +type queryBuilder struct { + values map[string][]string +} + +func newQueryBuilder() *queryBuilder { + return &queryBuilder{values: make(map[string][]string)} +} + +func (q *queryBuilder) Set(key, value string) { + q.values[key] = []string{value} +} + +func (q *queryBuilder) Add(key, value string) { + q.values[key] = append(q.values[key], value) +} + +func (q *queryBuilder) Encode() string { + if q == nil || len(q.values) == 0 { + return "" + } + + keys := make([]string, 0, len(q.values)) + for key := range q.values { + keys = append(keys, key) + } + sort.Strings(keys) + + var b strings.Builder + for _, key := range keys { + escapedKey := url.QueryEscape(key) + for _, value := range q.values[key] { + if b.Len() > 0 { + b.WriteByte('&') + } + b.WriteString(escapedKey) + b.WriteByte('=') + b.WriteString(url.QueryEscape(value)) + } + } + return b.String() +} + +func encodeQuery(build func(*queryBuilder)) string { + query := newQueryBuilder() + build(query) + return query.Encode() +} + +func appendQuery(path string, build func(*queryBuilder)) string { + query := encodeQuery(build) + if query == "" { + return path + } + if strings.Contains(path, "?") { + return path + "&" + query + } + return path + "?" + query +} diff --git a/issues.go b/issues.go index b26c4a9..4437fde 100644 --- a/issues.go +++ b/issues.go @@ -3,12 +3,12 @@ package forge import ( "context" "iter" - "strconv" "time" + // Note: AX-6 intrinsic — upload APIs must expose the structural request body type; coreio Medium is used inside Client multipart handling. goio "io" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // IssueService handles issue operations within a repository. @@ -28,6 +28,7 @@ type IssueService struct { // opts := forge.IssueListOptions{State: "open", Labels: "bug"} type IssueListOptions struct { State string + Sort string Labels string Query string Type string @@ -43,6 +44,7 @@ type IssueListOptions struct { func (o IssueListOptions) String() string { return optionString("forge.IssueListOptions", "state", o.State, + "sort", o.Sort, "labels", o.Labels, "q", o.Query, "type", o.Type, @@ -63,6 +65,9 @@ func (o IssueListOptions) queryParams() map[string]string { if o.State != "" { query["state"] = o.State } + if o.Sort != "" { + query["sort"] = o.Sort + } if o.Labels != "" { query["labels"] = o.Labels } @@ -160,6 +165,21 @@ func newIssueService(c *Client) *IssueService { } } +// GetIssue returns a single issue by index. +func (s *IssueService) GetIssue(ctx context.Context, owner, repo string, index int64) (*types.Issue, error) { + return s.Get(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index))) +} + +// EditIssue updates an existing issue. +func (s *IssueService) EditIssue(ctx context.Context, owner, repo string, index int64, opts *types.EditIssueOption) (*types.Issue, error) { + return s.Update(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index)), opts) +} + +// DeleteIssue deletes an issue. +func (s *IssueService) DeleteIssue(ctx context.Context, owner, repo string, index int64) error { + return s.Delete(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index))) +} + // SearchIssuesOptions controls filtering for the global issue search endpoint. // // Usage: @@ -222,7 +242,7 @@ func (o SearchIssuesOptions) queryParams() map[string]string { query["q"] = o.Query } if o.PriorityRepoID != 0 { - query["priority_repo_id"] = strconv.FormatInt(o.PriorityRepoID, 10) + query["priority_repo_id"] = int64String(o.PriorityRepoID) } if o.Type != "" { query["type"] = o.Type @@ -234,19 +254,19 @@ func (o SearchIssuesOptions) queryParams() map[string]string { query["before"] = o.Before.Format(time.RFC3339) } if o.Assigned { - query["assigned"] = strconv.FormatBool(true) + query["assigned"] = "true" } if o.Created { - query["created"] = strconv.FormatBool(true) + query["created"] = "true" } if o.Mentioned { - query["mentioned"] = strconv.FormatBool(true) + query["mentioned"] = "true" } if o.ReviewRequested { - query["review_requested"] = strconv.FormatBool(true) + query["review_requested"] = "true" } if o.Reviewed { - query["reviewed"] = strconv.FormatBool(true) + query["reviewed"] = "true" } if o.Owner != "" { query["owner"] = o.Owner @@ -275,18 +295,60 @@ func (s *IssueService) IterSearchIssues(ctx context.Context, opts SearchIssuesOp return ListIter[types.Issue](ctx, s.client, "/api/v1/repos/issues/search", opts.queryParams()) } +// ListIssuesPage returns a single page of issues in a repository. +func (s *IssueService) ListIssuesPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...any) (*PagedResult[types.Issue], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) + return ListPage[types.Issue](ctx, s.client, path, issueListQuery(filters...), opts) +} + // ListIssues returns all issues in a repository. -func (s *IssueService) ListIssues(ctx context.Context, owner, repo string, filters ...IssueListOptions) ([]types.Issue, error) { +func (s *IssueService) ListIssues(ctx context.Context, owner, repo string, filters ...any) ([]types.Issue, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) + if pageOpts, ok := issueListPageOptions(filters...); ok { + page, err := ListPage[types.Issue](ctx, s.client, path, issueListQuery(filters...), pageOpts) + if err != nil { + return nil, err + } + return page.Items, nil + } return ListAll[types.Issue](ctx, s.client, path, issueListQuery(filters...)) } // IterIssues returns an iterator over all issues in a repository. -func (s *IssueService) IterIssues(ctx context.Context, owner, repo string, filters ...IssueListOptions) iter.Seq2[types.Issue, error] { +func (s *IssueService) IterIssues(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.Issue, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) + if pageOpts, ok := issueListPageOptions(filters...); ok { + return func(yield func(types.Issue, error) bool) { + page, err := ListPage[types.Issue](ctx, s.client, path, issueListQuery(filters...), pageOpts) + if err != nil { + yield(*new(types.Issue), err) + return + } + for _, item := range page.Items { + if !yield(item, nil) { + return + } + } + } + } return ListIter[types.Issue](ctx, s.client, path, issueListQuery(filters...)) } +// ListRepoIssues returns all issues in a repository. +func (s *IssueService) ListRepoIssues(ctx context.Context, owner, repo string, filters ...any) ([]types.Issue, error) { + return s.ListIssues(ctx, owner, repo, filters...) +} + +// ListRepoIssuesPage returns a single page of issues in a repository. +func (s *IssueService) ListRepoIssuesPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...any) (*PagedResult[types.Issue], error) { + return s.ListIssuesPage(ctx, owner, repo, opts, filters...) +} + +// IterRepoIssues returns an iterator over all issues in a repository. +func (s *IssueService) IterRepoIssues(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.Issue, error] { + return s.IterIssues(ctx, owner, repo, filters...) +} + // CreateIssue creates a new issue in a repository. func (s *IssueService) CreateIssue(ctx context.Context, owner, repo string, opts *types.CreateIssueOption) (*types.Issue, error) { var out types.Issue @@ -430,12 +492,42 @@ func (s *IssueService) ListComments(ctx context.Context, owner, repo string, ind return ListAll[types.Comment](ctx, s.client, path, nil) } +// ListIssueComments returns all comments on an issue. +func (s *IssueService) ListIssueComments(ctx context.Context, owner, repo string, index int64) ([]types.Comment, error) { + return s.ListComments(ctx, owner, repo, index) +} + // IterComments returns an iterator over all comments on an issue. func (s *IssueService) IterComments(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Comment, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return ListIter[types.Comment](ctx, s.client, path, nil) } +// IterIssueComments returns an iterator over all comments on an issue. +func (s *IssueService) IterIssueComments(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Comment, error] { + return s.IterComments(ctx, owner, repo, index) +} + +// GetIssueComment returns a single comment on an issue. +func (s *IssueService) GetIssueComment(ctx context.Context, owner, repo string, index, id int64) (*types.Comment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments/{id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(id))) + var out types.Comment + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditIssueComment updates an issue comment. +func (s *IssueService) EditIssueComment(ctx context.Context, owner, repo string, index, id int64, opts *types.EditIssueCommentOption) (*types.Comment, error) { + return s.EditComment(ctx, owner, repo, index, id, opts) +} + +// DeleteIssueComment deletes an issue comment. +func (s *IssueService) DeleteIssueComment(ctx context.Context, owner, repo string, index, id int64) error { + return s.DeleteComment(ctx, owner, repo, index, id) +} + // CreateComment creates a comment on an issue. func (s *IssueService) CreateComment(ctx context.Context, owner, repo string, index int64, body string) (*types.Comment, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index))) @@ -529,11 +621,30 @@ func (s *IssueService) DeleteCommentReaction(ctx context.Context, owner, repo st return s.client.DeleteWithBody(ctx, path, types.EditReactionOption{Reaction: reaction}) } -func issueListQuery(filters ...IssueListOptions) map[string]string { +func issueListQuery(filters ...any) map[string]string { query := make(map[string]string, len(filters)) for _, filter := range filters { - for key, value := range filter.queryParams() { - query[key] = value + switch v := filter.(type) { + case IssueListOptions: + for key, value := range issueListQueryFromOption(v) { + query[key] = value + } + case *IssueListOptions: + if v != nil { + for key, value := range issueListQueryFromOption(*v) { + query[key] = value + } + } + case types.ListIssueOption: + for key, value := range issueListQueryFromCompat(v) { + query[key] = value + } + case *types.ListIssueOption: + if v != nil { + for key, value := range issueListQueryFromCompat(*v) { + query[key] = value + } + } } } if len(query) == 0 { @@ -542,6 +653,100 @@ func issueListQuery(filters ...IssueListOptions) map[string]string { return query } +func issueListQueryFromOption(filter IssueListOptions) map[string]string { + query := make(map[string]string, 10) + if filter.State != "" { + query["state"] = filter.State + } + if filter.Sort != "" { + query["sort"] = filter.Sort + } + if filter.Labels != "" { + query["labels"] = filter.Labels + } + if filter.Query != "" { + query["q"] = filter.Query + } + if filter.Type != "" { + query["type"] = filter.Type + } + if filter.Milestones != "" { + query["milestones"] = filter.Milestones + } + if filter.Since != nil { + query["since"] = filter.Since.Format(time.RFC3339) + } + if filter.Before != nil { + query["before"] = filter.Before.Format(time.RFC3339) + } + if filter.CreatedBy != "" { + query["created_by"] = filter.CreatedBy + } + if filter.AssignedBy != "" { + query["assigned_by"] = filter.AssignedBy + } + if filter.MentionedBy != "" { + query["mentioned_by"] = filter.MentionedBy + } + return query +} + +func issueListQueryFromCompat(filter types.ListIssueOption) map[string]string { + query := make(map[string]string, 10) + if filter.State != "" { + query["state"] = filter.State + } + if filter.Sort != "" { + query["sort"] = filter.Sort + } + if filter.Labels != "" { + query["labels"] = filter.Labels + } + if filter.Query != "" { + query["q"] = filter.Query + } + if filter.Type != "" { + query["type"] = filter.Type + } + if filter.Milestones != "" { + query["milestones"] = filter.Milestones + } + if filter.Since != nil { + query["since"] = filter.Since.Format(time.RFC3339) + } + if filter.Before != nil { + query["before"] = filter.Before.Format(time.RFC3339) + } + if filter.CreatedBy != "" { + query["created_by"] = filter.CreatedBy + } + if filter.AssignedBy != "" { + query["assigned_by"] = filter.AssignedBy + } + if filter.MentionedBy != "" { + query["mentioned_by"] = filter.MentionedBy + } + return query +} + +func issueListPageOptions(filters ...any) (ListOptions, bool) { + for _, filter := range filters { + switch v := filter.(type) { + case types.ListIssueOption: + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + case *types.ListIssueOption: + if v != nil { + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + } + } + } + return ListOptions{}, false +} + func attachmentUploadQuery(opts *AttachmentUploadOptions) map[string]string { if opts == nil { return nil diff --git a/issues_extra_test.go b/issues_extra_test.go index 27d942d..fcaf38c 100644 --- a/issues_extra_test.go +++ b/issues_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestIssueService_ListIssues_Good(t *testing.T) { @@ -61,3 +61,75 @@ func TestIssueService_CreateIssue_Good(t *testing.T) { t.Fatalf("got title=%q", issue.Title) } } + +func TestIssueService_CreateIssue_LabelNamesCompat_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/issues" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateIssueOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + labels, ok := body.Labels.([]string) + if !ok { + t.Fatalf("expected []string labels, got %T", body.Labels) + } + if len(labels) != 1 || labels[0] != "enhancement" { + t.Fatalf("unexpected labels: %#v", labels) + } + json.NewEncoder(w).Encode(types.Issue{ID: 2, Index: 2, Title: "label issue"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issue, err := f.Issues.CreateIssue(context.Background(), "core", "go-forge", &types.CreateIssueOption{ + Title: "label issue", + Labels: []string{"enhancement"}, + }) + if err != nil { + t.Fatal(err) + } + if issue.Title != "label issue" { + t.Fatalf("got title=%q", issue.Title) + } +} + +func TestIssueService_ListRepoIssues_CompatPagination_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" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Errorf("got state=%q, want %q", got, "open") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Errorf("got limit=%q, want %q", got, "25") + } + w.Header().Set("X-Total-Count", "40") + json.NewEncoder(w).Encode([]types.Issue{{ID: 2, Title: "paged issue"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issues, err := f.Issues.ListRepoIssues(context.Background(), "core", "go-forge", &types.ListIssueOption{ + State: "open", + Page: 2, + PageSize: 25, + }) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 || issues[0].Title != "paged issue" { + t.Fatalf("got %#v", issues) + } +} diff --git a/issues_test.go b/issues_test.go index 38f8f21..2906562 100644 --- a/issues_test.go +++ b/issues_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func readMultipartAttachment(t *testing.T, r *http.Request) (string, string) { diff --git a/labels.go b/labels.go index 37a12bb..f55ef58 100644 --- a/labels.go +++ b/labels.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // LabelService handles repository labels, organisation labels, and issue labels. @@ -22,18 +22,39 @@ func newLabelService(c *Client) *LabelService { return &LabelService{client: c} } +// ListRepoLabelsPage returns a single page of labels for a repository. +func (s *LabelService) ListRepoLabelsPage(ctx context.Context, owner, repo string, opts ListOptions) (*PagedResult[types.Label], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels", pathParams("owner", owner, "repo", repo)) + return ListPage[types.Label](ctx, s.client, path, nil, opts) +} + // ListRepoLabels returns all labels for a repository. func (s *LabelService) ListRepoLabels(ctx context.Context, owner, repo string) ([]types.Label, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels", pathParams("owner", owner, "repo", repo)) return ListAll[types.Label](ctx, s.client, path, nil) } +// ListLabelsPage returns a single page of labels for a repository. +func (s *LabelService) ListLabelsPage(ctx context.Context, owner, repo string, opts ListOptions) (*PagedResult[types.Label], error) { + return s.ListRepoLabelsPage(ctx, owner, repo, opts) +} + +// ListLabels returns all labels for a repository. +func (s *LabelService) ListLabels(ctx context.Context, owner, repo string) ([]types.Label, error) { + return s.ListRepoLabels(ctx, owner, repo) +} + // IterRepoLabels returns an iterator over all labels for a repository. func (s *LabelService) IterRepoLabels(ctx context.Context, owner, repo string) iter.Seq2[types.Label, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels", pathParams("owner", owner, "repo", repo)) return ListIter[types.Label](ctx, s.client, path, nil) } +// IterLabels returns an iterator over all labels for a repository. +func (s *LabelService) IterLabels(ctx context.Context, owner, repo string) iter.Seq2[types.Label, error] { + return s.IterRepoLabels(ctx, owner, repo) +} + // GetRepoLabel returns a single label by ID. func (s *LabelService) GetRepoLabel(ctx context.Context, owner, repo string, id int64) (*types.Label, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) diff --git a/labels_test.go b/labels_test.go index 92f8b87..be709aa 100644 --- a/labels_test.go +++ b/labels_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestLabelService_ListRepoLabels_Good(t *testing.T) { diff --git a/milestones.go b/milestones.go index bdf1050..a32a278 100644 --- a/milestones.go +++ b/milestones.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // MilestoneListOptions controls filtering for repository milestone listings. @@ -53,6 +53,21 @@ func newMilestoneService(c *Client) *MilestoneService { return &MilestoneService{client: c} } +// ListMilestonesPage returns a single page of milestones for a repository. +func (s *MilestoneService) ListMilestonesPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...MilestoneListOptions) (*PagedResult[types.Milestone], error) { + return s.List(ctx, pathParams("owner", owner, "repo", repo), opts, filters...) +} + +// ListMilestones returns all milestones for a repository. +func (s *MilestoneService) ListMilestones(ctx context.Context, owner, repo string, filters ...MilestoneListOptions) ([]types.Milestone, error) { + return s.ListAll(ctx, pathParams("owner", owner, "repo", repo), filters...) +} + +// IterMilestones returns an iterator over all milestones for a repository. +func (s *MilestoneService) IterMilestones(ctx context.Context, owner, repo string, filters ...MilestoneListOptions) iter.Seq2[types.Milestone, error] { + return s.Iter(ctx, pathParams("owner", owner, "repo", repo), filters...) +} + // List returns a single page of milestones for a repository. func (s *MilestoneService) List(ctx context.Context, params Params, opts ListOptions, filters ...MilestoneListOptions) (*PagedResult[types.Milestone], error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", params) @@ -81,6 +96,11 @@ func (s *MilestoneService) Get(ctx context.Context, owner, repo string, id int64 return &out, nil } +// GetMilestone returns a single milestone by ID. +func (s *MilestoneService) GetMilestone(ctx context.Context, owner, repo string, id int64) (*types.Milestone, error) { + return s.Get(ctx, owner, repo, id) +} + // Create creates a new milestone. func (s *MilestoneService) Create(ctx context.Context, owner, repo string, opts *types.CreateMilestoneOption) (*types.Milestone, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", pathParams("owner", owner, "repo", repo)) @@ -91,6 +111,11 @@ func (s *MilestoneService) Create(ctx context.Context, owner, repo string, opts return &out, nil } +// CreateMilestone creates a new milestone. +func (s *MilestoneService) CreateMilestone(ctx context.Context, owner, repo string, opts *types.CreateMilestoneOption) (*types.Milestone, error) { + return s.Create(ctx, owner, repo, opts) +} + // Edit updates an existing milestone. func (s *MilestoneService) Edit(ctx context.Context, owner, repo string, id int64, opts *types.EditMilestoneOption) (*types.Milestone, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) @@ -101,12 +126,22 @@ func (s *MilestoneService) Edit(ctx context.Context, owner, repo string, id int6 return &out, nil } +// EditMilestone updates an existing milestone. +func (s *MilestoneService) EditMilestone(ctx context.Context, owner, repo string, id int64, opts *types.EditMilestoneOption) (*types.Milestone, error) { + return s.Edit(ctx, owner, repo, id, opts) +} + // Delete removes a milestone. func (s *MilestoneService) Delete(ctx context.Context, owner, repo string, id int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) return s.client.Delete(ctx, path) } +// DeleteMilestone deletes a milestone. +func (s *MilestoneService) DeleteMilestone(ctx context.Context, owner, repo string, id int64) error { + return s.Delete(ctx, owner, repo, id) +} + func milestoneQuery(filters ...MilestoneListOptions) map[string]string { if len(filters) == 0 { return nil diff --git a/milestones_test.go b/milestones_test.go index 8def945..855405e 100644 --- a/milestones_test.go +++ b/milestones_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestMilestoneService_List_Good(t *testing.T) { diff --git a/misc.go b/misc.go index d53d15f..3b8ff0e 100644 --- a/misc.go +++ b/misc.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // MiscService handles miscellaneous Forgejo API endpoints such as diff --git a/misc_test.go b/misc_test.go index 9685084..9039088 100644 --- a/misc_test.go +++ b/misc_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestMiscService_RenderMarkdown_Good(t *testing.T) { diff --git a/notifications.go b/notifications.go index cad311d..a4500dd 100644 --- a/notifications.go +++ b/notifications.go @@ -4,12 +4,9 @@ import ( "context" "iter" "net/http" - "net/url" - "strconv" "time" - core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // NotificationListOptions controls filtering for notification listings. @@ -39,7 +36,7 @@ func (o NotificationListOptions) String() string { // GoString returns a safe Go-syntax summary of the notification filters. func (o NotificationListOptions) GoString() string { return o.String() } -func (o NotificationListOptions) addQuery(values url.Values) { +func (o NotificationListOptions) addQuery(values *queryBuilder) { if o.All { values.Set("all", "true") } @@ -127,22 +124,22 @@ func newNotificationService(c *Client) *NotificationService { } func notificationMarkQueryString(all bool, statusTypes []string, toStatus string, lastReadAt *time.Time) string { - values := url.Values{} - if all { - values.Set("all", "true") - } - for _, status := range statusTypes { - if status != "" { - values.Add("status-types", status) + return encodeQuery(func(values *queryBuilder) { + if all { + values.Set("all", "true") } - } - if toStatus != "" { - values.Set("to-status", toStatus) - } - if lastReadAt != nil { - values.Set("last_read_at", lastReadAt.Format(time.RFC3339)) - } - return values.Encode() + for _, status := range statusTypes { + if status != "" { + values.Add("status-types", status) + } + } + if toStatus != "" { + values.Set("to-status", toStatus) + } + if lastReadAt != nil { + values.Set("last_read_at", lastReadAt.Format(time.RFC3339)) + } + }) } func (o NotificationRepoMarkOptions) queryString() string { @@ -284,26 +281,21 @@ func (s *NotificationService) listPage(ctx context.Context, path string, opts Li opts.Limit = defaultPageLimit } - u, err := url.Parse(path) - if err != nil { - return nil, core.E("NotificationService.listPage", "forge: parse path", err) - } - - values := u.Query() - values.Set("page", strconv.Itoa(opts.Page)) - values.Set("limit", strconv.Itoa(opts.Limit)) - for _, filter := range filters { - filter.addQuery(values) - } - u.RawQuery = values.Encode() + path = appendQuery(path, func(values *queryBuilder) { + values.Set("page", intString(opts.Page)) + values.Set("limit", intString(opts.Limit)) + for _, filter := range filters { + filter.addQuery(values) + } + }) var items []types.NotificationThread - resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &items) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, &items) if err != nil { return nil, err } - totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + totalCount := parseInt(resp.Header.Get("X-Total-Count")) return &PagedResult[types.NotificationThread]{ Items: items, TotalCount: totalCount, diff --git a/notifications_test.go b/notifications_test.go index 45635bd..951bbb6 100644 --- a/notifications_test.go +++ b/notifications_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestNotificationService_List_Good(t *testing.T) { diff --git a/orgs.go b/orgs.go index c76540f..bd896cb 100644 --- a/orgs.go +++ b/orgs.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // OrgService handles organisation operations. @@ -53,11 +53,49 @@ func newOrgService(c *Client) *OrgService { } } +// GetOrg returns an organisation by name. +func (s *OrgService) GetOrg(ctx context.Context, org string) (*types.Organization, error) { + return s.Get(ctx, pathParams("org", org)) +} + +// UpdateOrg updates an organisation. +func (s *OrgService) UpdateOrg(ctx context.Context, org string, opts *types.EditOrgOption) (*types.Organization, error) { + return s.Update(ctx, pathParams("org", org), opts) +} + +// DeleteOrg deletes an organisation. +func (s *OrgService) DeleteOrg(ctx context.Context, org string) error { + return s.Delete(ctx, pathParams("org", org)) +} + +// ListOrgsPage returns a single page of organisations. +func (s *OrgService) ListOrgsPage(ctx context.Context, opts ListOptions) (*PagedResult[types.Organization], error) { + return ListPage[types.Organization](ctx, s.client, "/api/v1/orgs", nil, opts) +} + // ListOrgs returns all organisations. func (s *OrgService) ListOrgs(ctx context.Context) ([]types.Organization, error) { return ListAll[types.Organization](ctx, s.client, "/api/v1/orgs", nil) } +// ListOrgTeamsPage returns a single page of teams in an organisation. +func (s *OrgService) ListOrgTeamsPage(ctx context.Context, org string, opts ListOptions) (*PagedResult[types.Team], error) { + path := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) + return ListPage[types.Team](ctx, s.client, path, nil, opts) +} + +// ListOrgTeams returns all teams in an organisation. +func (s *OrgService) ListOrgTeams(ctx context.Context, org string) ([]types.Team, error) { + path := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) + return ListAll[types.Team](ctx, s.client, path, nil) +} + +// IterOrgTeams returns an iterator over all teams in an organisation. +func (s *OrgService) IterOrgTeams(ctx context.Context, org string) iter.Seq2[types.Team, error] { + path := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) + return ListIter[types.Team](ctx, s.client, path, nil) +} + // IterOrgs returns an iterator over all organisations. func (s *OrgService) IterOrgs(ctx context.Context) iter.Seq2[types.Organization, error] { return ListIter[types.Organization](ctx, s.client, "/api/v1/orgs", nil) @@ -72,18 +110,39 @@ func (s *OrgService) CreateOrg(ctx context.Context, opts *types.CreateOrgOption) return &out, nil } +// ListMembersPage returns a single page of members of an organisation. +func (s *OrgService) ListMembersPage(ctx context.Context, org string, opts ListOptions) (*PagedResult[types.User], error) { + path := ResolvePath("/api/v1/orgs/{org}/members", pathParams("org", org)) + return ListPage[types.User](ctx, s.client, path, nil, opts) +} + // ListMembers returns all members of an organisation. func (s *OrgService) ListMembers(ctx context.Context, org string) ([]types.User, error) { path := ResolvePath("/api/v1/orgs/{org}/members", pathParams("org", org)) return ListAll[types.User](ctx, s.client, path, nil) } +// ListOrgMembersPage returns a single page of members of an organisation. +func (s *OrgService) ListOrgMembersPage(ctx context.Context, org string, opts ListOptions) (*PagedResult[types.User], error) { + return s.ListMembersPage(ctx, org, opts) +} + +// ListOrgMembers returns all members of an organisation. +func (s *OrgService) ListOrgMembers(ctx context.Context, org string) ([]types.User, error) { + return s.ListMembers(ctx, org) +} + // IterMembers returns an iterator over all members of an organisation. func (s *OrgService) IterMembers(ctx context.Context, org string) iter.Seq2[types.User, error] { path := ResolvePath("/api/v1/orgs/{org}/members", pathParams("org", org)) return ListIter[types.User](ctx, s.client, path, nil) } +// IterOrgMembers returns an iterator over all members of an organisation. +func (s *OrgService) IterOrgMembers(ctx context.Context, org string) iter.Seq2[types.User, error] { + return s.IterMembers(ctx, org) +} + // AddMember adds a user to an organisation. func (s *OrgService) AddMember(ctx context.Context, org, username string) error { path := ResolvePath("/api/v1/orgs/{org}/members/{username}", pathParams("org", org, "username", username)) diff --git a/orgs_extra_test.go b/orgs_extra_test.go index 348333b..ac53b87 100644 --- a/orgs_extra_test.go +++ b/orgs_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestOrgService_ListOrgs_Good(t *testing.T) { diff --git a/orgs_test.go b/orgs_test.go index 81ac6d6..0b3eac7 100644 --- a/orgs_test.go +++ b/orgs_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestOrgService_List_Good(t *testing.T) { diff --git a/packages.go b/packages.go index 43784a1..239d621 100644 --- a/packages.go +++ b/packages.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // PackageService handles package registry operations via the Forgejo API. diff --git a/packages_test.go b/packages_test.go index a457e82..dbc6332 100644 --- a/packages_test.go +++ b/packages_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestPackageService_List_Good(t *testing.T) { diff --git a/pagination.go b/pagination.go index 203b15a..b6f567e 100644 --- a/pagination.go +++ b/pagination.go @@ -10,7 +10,10 @@ import ( core "dappco.re/go/core" ) -const defaultPageLimit = 50 +const defaultPageSize = 50 + +// defaultPageLimit is retained for compatibility with existing call sites. +const defaultPageLimit = defaultPageSize // ListOptions controls pagination. // @@ -19,8 +22,10 @@ const defaultPageLimit = 50 // opts := forge.ListOptions{Page: 1, Limit: 50} // _ = opts type ListOptions struct { - Page int // 1-based page number - Limit int // items per page (default 50) + Page int // 1-based page number + PageSize int // items per page (default 50) + // Limit is a compatibility alias for PageSize. + Limit int } // String returns a safe summary of the pagination options. @@ -29,11 +34,15 @@ type ListOptions struct { // // _ = forge.DefaultList.String() func (o ListOptions) String() string { + pageSize := o.PageSize + if pageSize == 0 { + pageSize = o.Limit + } return core.Concat( "forge.ListOptions{page=", strconv.Itoa(o.Page), - ", limit=", - strconv.Itoa(o.Limit), + ", page_size=", + strconv.Itoa(pageSize), "}", ) } @@ -51,7 +60,7 @@ func (o ListOptions) GoString() string { return o.String() } // // page, err := forge.ListPage[types.Repository](ctx, client, path, nil, forge.DefaultList) // _ = page -var DefaultList = ListOptions{Page: 1, Limit: defaultPageLimit} +var DefaultList = ListOptions{Page: 1, PageSize: defaultPageSize} // PagedResult holds a single page of results with metadata. // @@ -108,8 +117,12 @@ func ListPage[T any](ctx context.Context, c *Client, path string, query map[stri if opts.Page < 1 { opts.Page = 1 } - if opts.Limit < 1 { - opts.Limit = defaultPageLimit + pageSize := opts.PageSize + if pageSize < 1 { + pageSize = opts.Limit + } + if pageSize < 1 { + pageSize = defaultPageSize } u, err := url.Parse(path) @@ -119,7 +132,7 @@ func ListPage[T any](ctx context.Context, c *Client, path string, query map[stri q := u.Query() q.Set("page", strconv.Itoa(opts.Page)) - q.Set("limit", strconv.Itoa(opts.Limit)) + q.Set("limit", strconv.Itoa(pageSize)) for k, v := range query { q.Set(k, v) } @@ -139,8 +152,8 @@ func ListPage[T any](ctx context.Context, c *Client, path string, query map[stri Page: opts.Page, // If totalCount is provided, use it to determine if there are more items. // Otherwise, assume there are more if we got a full page. - HasMore: (totalCount > 0 && (opts.Page-1)*opts.Limit+len(items) < totalCount) || - (totalCount == 0 && len(items) >= opts.Limit), + HasMore: (totalCount > 0 && (opts.Page-1)*pageSize+len(items) < totalCount) || + (totalCount == 0 && len(items) >= pageSize), }, nil } @@ -155,7 +168,7 @@ func ListAll[T any](ctx context.Context, c *Client, path string, query map[strin page := 1 for { - result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, Limit: defaultPageLimit}) + result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, PageSize: defaultPageSize}) if err != nil { return nil, err } @@ -180,7 +193,7 @@ func ListIter[T any](ctx context.Context, c *Client, path string, query map[stri return func(yield func(T, error) bool) { page := 1 for { - result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, Limit: defaultPageLimit}) + result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, PageSize: defaultPageSize}) if err != nil { yield(*new(T), err) return diff --git a/pagination_test.go b/pagination_test.go index 89e650c..1c1cc35 100644 --- a/pagination_test.go +++ b/pagination_test.go @@ -110,7 +110,7 @@ func TestListPage_QueryParams_Good(t *testing.T) { c := NewClient(srv.URL, "tok") _, err := ListPage[map[string]int](context.Background(), c, "/api/v1/repos", - map[string]string{"state": "open"}, ListOptions{Page: 2, Limit: 25}) + map[string]string{"state": "open"}, ListOptions{Page: 2, PageSize: 25}) if err != nil { t.Fatal(err) } diff --git a/pulls.go b/pulls.go index 56cd6eb..35a2acb 100644 --- a/pulls.go +++ b/pulls.go @@ -3,11 +3,8 @@ package forge import ( "context" "iter" - "net/url" - "strconv" - core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // PullService handles pull request operations within a repository. @@ -47,7 +44,7 @@ func (o PullListOptions) String() string { // GoString returns a safe Go-syntax summary of the pull request list filters. func (o PullListOptions) GoString() string { return o.String() } -func (o PullListOptions) addQuery(values url.Values) { +func (o PullListOptions) addQuery(values *queryBuilder) { if o.State != "" { values.Set("state", o.State) } @@ -55,11 +52,11 @@ func (o PullListOptions) addQuery(values url.Values) { values.Set("sort", o.Sort) } if o.Milestone != 0 { - values.Set("milestone", strconv.FormatInt(o.Milestone, 10)) + values.Set("milestone", int64String(o.Milestone)) } for _, label := range o.Labels { if label != 0 { - values.Add("labels", strconv.FormatInt(label, 10)) + values.Add("labels", int64String(label)) } } if o.Poster != "" { @@ -75,13 +72,39 @@ func newPullService(c *Client) *PullService { } } +// ListPullRequestsPage returns a single page of pull requests in a repository. +func (s *PullService) ListPullRequestsPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...any) (*PagedResult[types.PullRequest], error) { + return s.listPage(ctx, owner, repo, opts, filters...) +} + // ListPullRequests returns all pull requests in a repository. -func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string, filters ...PullListOptions) ([]types.PullRequest, error) { +func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string, filters ...any) ([]types.PullRequest, error) { + if pageOpts, ok := compatPullListPageOptions(filters...); ok { + page, err := s.listPage(ctx, owner, repo, pageOpts, filters...) + if err != nil { + return nil, err + } + return page.Items, nil + } return s.listAll(ctx, owner, repo, filters...) } // IterPullRequests returns an iterator over all pull requests in a repository. -func (s *PullService) IterPullRequests(ctx context.Context, owner, repo string, filters ...PullListOptions) iter.Seq2[types.PullRequest, error] { +func (s *PullService) IterPullRequests(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.PullRequest, error] { + if pageOpts, ok := compatPullListPageOptions(filters...); ok { + return func(yield func(types.PullRequest, error) bool) { + page, err := s.listPage(ctx, owner, repo, pageOpts, filters...) + if err != nil { + yield(*new(types.PullRequest), err) + return + } + for _, item := range page.Items { + if !yield(item, nil) { + return + } + } + } + } return s.listIter(ctx, owner, repo, filters...) } @@ -94,11 +117,37 @@ func (s *PullService) CreatePullRequest(ctx context.Context, owner, repo string, return &out, nil } +// GetPullRequest returns a single pull request by index. +func (s *PullService) GetPullRequest(ctx context.Context, owner, repo string, index int64) (*types.PullRequest, error) { + return s.Get(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index))) +} + +// EditPullRequest updates an existing pull request. +func (s *PullService) EditPullRequest(ctx context.Context, owner, repo string, index int64, opts *types.EditPullRequestOption) (*types.PullRequest, error) { + return s.Resource.Update(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index)), opts) +} + +// DeletePullRequest deletes a pull request. +func (s *PullService) DeletePullRequest(ctx context.Context, owner, repo string, index int64) error { + return s.Delete(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index))) +} + // Merge merges a pull request. Method is one of "merge", "rebase", "rebase-merge", "squash", "fast-forward-only", "manually-merged". func (s *PullService) Merge(ctx context.Context, owner, repo string, index int64, method string) error { + return s.MergePullRequest(ctx, owner, repo, index, &types.MergePullRequestOption{Do: method}) +} + +// MergePullRequest merges a pull request. +func (s *PullService) MergePullRequest(ctx context.Context, owner, repo string, index int64, opts *types.MergePullRequestOption) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/merge", pathParams("owner", owner, "repo", repo, "index", int64String(index))) - body := map[string]string{"Do": method} - return s.client.Post(ctx, path, body, nil) + if opts != nil { + body := *opts + if body.Do == "" { + body.Do = body.MergeStyle + } + opts = &body + } + return s.client.Post(ctx, path, opts, nil) } // CancelScheduledAutoMerge cancels the scheduled auto merge for a pull request. @@ -137,12 +186,22 @@ func (s *PullService) ListReviews(ctx context.Context, owner, repo string, index return ListAll[types.PullReview](ctx, s.client, path, nil) } +// ListPullReviews returns all reviews on a pull request. +func (s *PullService) ListPullReviews(ctx context.Context, owner, repo string, index int64) ([]types.PullReview, error) { + return s.ListReviews(ctx, owner, repo, index) +} + // IterReviews returns an iterator over all reviews on a pull request. func (s *PullService) IterReviews(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.PullReview, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return ListIter[types.PullReview](ctx, s.client, path, nil) } +// IterPullReviews returns an iterator over all reviews on a pull request. +func (s *PullService) IterPullReviews(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.PullReview, error] { + return s.IterReviews(ctx, owner, repo, index) +} + // ListFiles returns all changed files on a pull request. func (s *PullService) ListFiles(ctx context.Context, owner, repo string, index int64) ([]types.ChangedFile, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/files", pathParams("owner", owner, "repo", repo, "index", int64String(index))) @@ -218,56 +277,63 @@ func (s *PullService) GetReview(ctx context.Context, owner, repo string, index, return &out, nil } +// GetPullReview returns a single pull request review. +func (s *PullService) GetPullReview(ctx context.Context, owner, repo string, index, reviewID int64) (*types.PullReview, error) { + return s.GetReview(ctx, owner, repo, index, reviewID) +} + // DeleteReview deletes a pull request review. func (s *PullService) DeleteReview(ctx context.Context, owner, repo string, index, reviewID int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) return s.client.Delete(ctx, path) } -func (s *PullService) listPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...PullListOptions) (*PagedResult[types.PullRequest], error) { +// DeletePullReview deletes a pull request review. +func (s *PullService) DeletePullReview(ctx context.Context, owner, repo string, index, reviewID int64) error { + return s.DeleteReview(ctx, owner, repo, index, reviewID) +} + +func (s *PullService) listPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...any) (*PagedResult[types.PullRequest], error) { if opts.Page < 1 { opts.Page = 1 } - if opts.Limit < 1 { - opts.Limit = defaultPageLimit + pageSize := opts.PageSize + if pageSize < 1 { + pageSize = opts.Limit } - - path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) - u, err := url.Parse(path) - if err != nil { - return nil, core.E("PullService.listPage", "forge: parse path", err) + if pageSize < 1 { + pageSize = defaultPageLimit } - values := u.Query() - values.Set("page", strconv.Itoa(opts.Page)) - values.Set("limit", strconv.Itoa(opts.Limit)) - for _, filter := range filters { - filter.addQuery(values) - } - u.RawQuery = values.Encode() + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) + path = appendQuery(path, func(values *queryBuilder) { + values.Set("page", intString(opts.Page)) + values.Set("limit", intString(pageSize)) + addPullFilters(values, filters...) + }) var items []types.PullRequest - resp, err := s.client.doJSON(ctx, "GET", u.String(), nil, &items) + resp, err := s.client.doJSON(ctx, "GET", path, nil, &items) if err != nil { return nil, err } - totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + totalCount := parseInt(resp.Header.Get("X-Total-Count")) return &PagedResult[types.PullRequest]{ Items: items, TotalCount: totalCount, Page: opts.Page, - HasMore: (totalCount > 0 && (opts.Page-1)*opts.Limit+len(items) < totalCount) || - (totalCount == 0 && len(items) >= opts.Limit), + HasMore: (totalCount > 0 && (opts.Page-1)*pageSize+len(items) < totalCount) || + (totalCount == 0 && len(items) >= pageSize), }, nil } -func (s *PullService) listAll(ctx context.Context, owner, repo string, filters ...PullListOptions) ([]types.PullRequest, error) { +func (s *PullService) listAll(ctx context.Context, owner, repo string, filters ...any) ([]types.PullRequest, error) { var all []types.PullRequest page := 1 for { - result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) + result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, PageSize: defaultPageLimit}, filters...) if err != nil { return nil, err } @@ -281,11 +347,11 @@ func (s *PullService) listAll(ctx context.Context, owner, repo string, filters . return all, nil } -func (s *PullService) listIter(ctx context.Context, owner, repo string, filters ...PullListOptions) iter.Seq2[types.PullRequest, error] { +func (s *PullService) listIter(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.PullRequest, error] { return func(yield func(types.PullRequest, error) bool) { page := 1 for { - result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) + result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, PageSize: defaultPageLimit}, filters...) if err != nil { yield(*new(types.PullRequest), err) return @@ -303,6 +369,63 @@ func (s *PullService) listIter(ctx context.Context, owner, repo string, filters } } +func addPullFilters(values *queryBuilder, filters ...any) { + for _, filter := range filters { + switch v := filter.(type) { + case PullListOptions: + v.addQuery(values) + case *PullListOptions: + if v != nil { + v.addQuery(values) + } + case types.ListPullRequestsOption: + addCompatPullFilter(values, v) + case *types.ListPullRequestsOption: + if v != nil { + addCompatPullFilter(values, *v) + } + } + } +} + +func addCompatPullFilter(values *queryBuilder, filter types.ListPullRequestsOption) { + if filter.State != "" { + values.Set("state", filter.State) + } + if filter.Sort != "" { + values.Set("sort", filter.Sort) + } + if filter.Milestone != 0 { + values.Set("milestone", int64String(filter.Milestone)) + } + for _, label := range filter.Labels { + if label != 0 { + values.Add("labels", int64String(label)) + } + } + if filter.Poster != "" { + values.Set("poster", filter.Poster) + } +} + +func compatPullListPageOptions(filters ...any) (ListOptions, bool) { + for _, filter := range filters { + switch v := filter.(type) { + case types.ListPullRequestsOption: + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + case *types.ListPullRequestsOption: + if v != nil { + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + } + } + } + return ListOptions{}, false +} + // ListReviewComments returns all comments on a pull request review. func (s *PullService) ListReviewComments(ctx context.Context, owner, repo string, index, reviewID int64) ([]types.PullReviewComment, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) diff --git a/pulls_extra_test.go b/pulls_extra_test.go index 473b2f9..40f3fe9 100644 --- a/pulls_extra_test.go +++ b/pulls_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestPullService_ListPullRequests_Good(t *testing.T) { @@ -65,3 +65,77 @@ func TestPullService_CreatePullRequest_Good(t *testing.T) { t.Fatalf("got title=%q", pr.Title) } } + +func TestPullService_CreatePullRequest_LabelNamesCompat_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/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreatePullRequestOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + labels, ok := body.Labels.([]string) + if !ok { + t.Fatalf("expected []string labels, got %T", body.Labels) + } + if len(labels) != 1 || labels[0] != "feature" { + t.Fatalf("unexpected labels: %#v", labels) + } + json.NewEncoder(w).Encode(types.PullRequest{ID: 2, Title: "labelled pr", Index: 2}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + pr, err := f.Pulls.CreatePullRequest(context.Background(), "core", "go-forge", &types.CreatePullRequestOption{ + Title: "labelled pr", + Base: "main", + Head: "feature", + Labels: []string{"feature"}, + }) + if err != nil { + t.Fatal(err) + } + if pr.Title != "labelled pr" { + t.Fatalf("got title=%q", pr.Title) + } +} + +func TestPullService_ListPullRequests_CompatPagination_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/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Errorf("got state=%q, want %q", got, "open") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Errorf("got limit=%q, want %q", got, "25") + } + w.Header().Set("X-Total-Count", "40") + json.NewEncoder(w).Encode([]types.PullRequest{{ID: 2, Title: "paged pull"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + prs, err := f.Pulls.ListPullRequests(context.Background(), "core", "go-forge", &types.ListPullRequestsOption{ + State: "open", + Page: 2, + PageSize: 25, + }) + if err != nil { + t.Fatal(err) + } + if len(prs) != 1 || prs[0].Title != "paged pull" { + t.Fatalf("got %#v", prs) + } +} diff --git a/pulls_test.go b/pulls_test.go index 7569d3f..db84147 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestPullService_List_Good(t *testing.T) { @@ -407,6 +407,43 @@ func TestPullService_Merge_Good(t *testing.T) { } } +func TestPullService_MergePullRequest_CompatMergeStyle_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/pulls/7/merge" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if got := body["Do"]; got != "squash" { + t.Fatalf("got Do=%v, want squash", got) + } + if got := body["MergeMessageField"]; got != "PR: Add feature" { + t.Fatalf("got MergeMessageField=%v, want %q", got, "PR: Add feature") + } + if _, ok := body["MergeStyle"]; ok { + t.Fatalf("did not expect MergeStyle in request body: %#v", body) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Pulls.MergePullRequest(context.Background(), "core", "go-forge", 7, &types.MergePullRequestOption{ + MergeMessageField: "PR: Add feature", + MergeStyle: "squash", + }) + if err != nil { + t.Fatal(err) + } +} + func TestPullService_Merge_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) diff --git a/releases.go b/releases.go index 906d259..f1a744b 100644 --- a/releases.go +++ b/releases.go @@ -3,11 +3,11 @@ package forge import ( "context" "iter" - "strconv" + // Note: AX-6 intrinsic — upload APIs must expose the structural request body type; coreio Medium is used inside Client multipart handling. goio "io" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // ReleaseService handles release operations within a repository. @@ -46,10 +46,10 @@ func (o ReleaseListOptions) GoString() string { return o.String() } func (o ReleaseListOptions) queryParams() map[string]string { query := make(map[string]string, 3) if o.Draft { - query["draft"] = strconv.FormatBool(true) + query["draft"] = "true" } if o.PreRelease { - query["pre-release"] = strconv.FormatBool(true) + query["pre-release"] = "true" } if o.Query != "" { query["q"] = o.Query @@ -100,6 +100,12 @@ func newReleaseService(c *Client) *ReleaseService { } } +// ListReleasesPage returns a single page of releases in a repository. +func (s *ReleaseService) ListReleasesPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...ReleaseListOptions) (*PagedResult[types.Release], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases", pathParams("owner", owner, "repo", repo)) + return ListPage[types.Release](ctx, s.client, path, releaseListQuery(filters...), opts) +} + // ListReleases returns all releases in a repository. func (s *ReleaseService) ListReleases(ctx context.Context, owner, repo string, filters ...ReleaseListOptions) ([]types.Release, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases", pathParams("owner", owner, "repo", repo)) @@ -131,6 +137,11 @@ func (s *ReleaseService) GetByTag(ctx context.Context, owner, repo, tag string) return &out, nil } +// GetRelease returns a release by its tag name. +func (s *ReleaseService) GetRelease(ctx context.Context, owner, repo, tag string) (*types.Release, error) { + return s.GetByTag(ctx, owner, repo, tag) +} + // GetLatest returns the most recent non-prerelease, non-draft release. func (s *ReleaseService) GetLatest(ctx context.Context, owner, repo string) (*types.Release, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/latest", pathParams("owner", owner, "repo", repo)) diff --git a/releases_extra_test.go b/releases_extra_test.go index 9b58159..4f6a1ca 100644 --- a/releases_extra_test.go +++ b/releases_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestReleaseService_ListReleases_Good(t *testing.T) { diff --git a/releases_test.go b/releases_test.go index 8d5ca7d..abed98e 100644 --- a/releases_test.go +++ b/releases_test.go @@ -12,7 +12,7 @@ import ( "reflect" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func readMultipartReleaseAttachment(t *testing.T, r *http.Request) (map[string]string, string, string) { diff --git a/repos.go b/repos.go index 8b826a8..3d072b5 100644 --- a/repos.go +++ b/repos.go @@ -4,12 +4,9 @@ import ( "context" "iter" "net/http" - "net/url" - "strconv" "time" - core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // RepoService handles repository operations. @@ -43,7 +40,7 @@ func (o RepoKeyListOptions) GoString() string { return o.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) + query["key_id"] = int64String(o.KeyID) } if o.Fingerprint != "" { query["fingerprint"] = o.Fingerprint @@ -128,6 +125,21 @@ func newRepoService(c *Client) *RepoService { } } +// GetRepo returns a repository by owner and name. +func (s *RepoService) GetRepo(ctx context.Context, owner, repo string) (*types.Repository, error) { + return s.Get(ctx, pathParams("owner", owner, "repo", repo)) +} + +// UpdateRepo updates an existing repository. +func (s *RepoService) UpdateRepo(ctx context.Context, owner, repo string, opts *types.EditRepoOption) (*types.Repository, error) { + return s.Update(ctx, pathParams("owner", owner, "repo", repo), opts) +} + +// DeleteRepo deletes a repository. +func (s *RepoService) DeleteRepo(ctx context.Context, owner, repo string) error { + return s.Delete(ctx, pathParams("owner", owner, "repo", repo)) +} + // Migrate imports a remote git repository into Forgejo. func (s *RepoService) Migrate(ctx context.Context, opts *types.MigrateRepoOptions) (*types.Repository, error) { var out types.Repository @@ -166,6 +178,12 @@ func (s *RepoService) CreateOrgRepoDeprecated(ctx context.Context, org string, o return &out, nil } +// ListOrgReposPage returns a single page of repositories for an organisation. +func (s *RepoService) ListOrgReposPage(ctx context.Context, org string, opts ListOptions) (*PagedResult[types.Repository], error) { + path := ResolvePath("/api/v1/orgs/{org}/repos", pathParams("org", org)) + return ListPage[types.Repository](ctx, s.client, path, nil, opts) +} + // ListOrgRepos returns all repositories for an organisation. func (s *RepoService) ListOrgRepos(ctx context.Context, org string) ([]types.Repository, error) { path := ResolvePath("/api/v1/orgs/{org}/repos", pathParams("org", org)) @@ -178,13 +196,34 @@ func (s *RepoService) IterOrgRepos(ctx context.Context, org string) iter.Seq2[ty return ListIter[types.Repository](ctx, s.client, path, nil) } -// ListUserRepos returns all repositories for the authenticated user. -func (s *RepoService) ListUserRepos(ctx context.Context) ([]types.Repository, error) { +// ListCurrentUserReposPage returns a single page of repositories for the authenticated user. +func (s *RepoService) ListCurrentUserReposPage(ctx context.Context, opts ListOptions) (*PagedResult[types.Repository], error) { + return ListPage[types.Repository](ctx, s.client, "/api/v1/user/repos", nil, opts) +} + +// ListUserReposPage returns a single page of repositories for a user. +func (s *RepoService) ListUserReposPage(ctx context.Context, username string, opts ListOptions) (*PagedResult[types.Repository], error) { + path := ResolvePath("/api/v1/users/{username}/repos", pathParams("username", username)) + return ListPage[types.Repository](ctx, s.client, path, nil, opts) +} + +// ListUserRepos returns all repositories for a user. +// When username is omitted, it returns repositories for the authenticated user. +func (s *RepoService) ListUserRepos(ctx context.Context, username ...string) ([]types.Repository, error) { + if len(username) > 0 && username[0] != "" { + path := ResolvePath("/api/v1/users/{username}/repos", pathParams("username", username[0])) + return ListAll[types.Repository](ctx, s.client, path, nil) + } return ListAll[types.Repository](ctx, s.client, "/api/v1/user/repos", nil) } -// IterUserRepos returns an iterator over all repositories for the authenticated user. -func (s *RepoService) IterUserRepos(ctx context.Context) iter.Seq2[types.Repository, error] { +// IterUserRepos returns an iterator over repositories for a user. +// When username is omitted, it returns repositories for the authenticated user. +func (s *RepoService) IterUserRepos(ctx context.Context, username ...string) iter.Seq2[types.Repository, error] { + if len(username) > 0 && username[0] != "" { + path := ResolvePath("/api/v1/users/{username}/repos", pathParams("username", username[0])) + return ListIter[types.Repository](ctx, s.client, path, nil) + } return ListIter[types.Repository](ctx, s.client, "/api/v1/user/repos", nil) } @@ -465,14 +504,9 @@ func (s *RepoService) GetRawFile(ctx context.Context, owner, repo, filepath stri func (s *RepoService) GetRawFileOrLFS(ctx context.Context, owner, repo, filepath, ref string) ([]byte, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/media/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) if ref != "" { - u, err := url.Parse(path) - if err != nil { - return nil, core.E("RepoService.GetRawFileOrLFS", "forge: parse path", err) - } - q := u.Query() - q.Set("ref", ref) - u.RawQuery = q.Encode() - path = u.String() + path = appendQuery(path, func(q *queryBuilder) { + q.Set("ref", ref) + }) } return s.client.GetRaw(ctx, path) } @@ -481,14 +515,9 @@ func (s *RepoService) GetRawFileOrLFS(ctx context.Context, owner, repo, filepath func (s *RepoService) GetEditorConfig(ctx context.Context, owner, repo, filepath, ref string) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/editorconfig/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) if ref != "" { - u, err := url.Parse(path) - if err != nil { - return core.E("RepoService.GetEditorConfig", "forge: parse path", err) - } - q := u.Query() - q.Set("ref", ref) - u.RawQuery = q.Encode() - path = u.String() + path = appendQuery(path, func(q *queryBuilder) { + q.Set("ref", ref) + }) } return s.client.Get(ctx, path, nil) } @@ -650,24 +679,19 @@ func (s *RepoService) SearchRepositoriesPage(ctx context.Context, query string, pageOpts.Limit = 50 } - u, err := url.Parse("/api/v1/repos/search") - if err != nil { - return nil, core.E("RepoService.SearchRepositoriesPage", "forge: parse path", err) - } - - q := u.Query() - q.Set("q", query) - q.Set("page", strconv.Itoa(pageOpts.Page)) - q.Set("limit", strconv.Itoa(pageOpts.Limit)) - u.RawQuery = q.Encode() + path := appendQuery("/api/v1/repos/search", func(q *queryBuilder) { + q.Set("q", query) + q.Set("page", intString(pageOpts.Page)) + q.Set("limit", intString(pageOpts.Limit)) + }) var out types.SearchResults - resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &out) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, &out) if err != nil { return nil, err } - totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + totalCount := parseInt(resp.Header.Get("X-Total-Count")) items := make([]types.Repository, 0, len(out.Data)) for _, repo := range out.Data { if repo != nil { @@ -1045,7 +1069,7 @@ func repoKeyQuery(filters ...RepoKeyListOptions) map[string]string { query := make(map[string]string, 2) for _, filter := range filters { if filter.KeyID != 0 { - query["key_id"] = strconv.FormatInt(filter.KeyID, 10) + query["key_id"] = int64String(filter.KeyID) } if filter.Fingerprint != "" { query["fingerprint"] = filter.Fingerprint diff --git a/repos_test.go b/repos_test.go index 3c6c22b..44f7d9b 100644 --- a/repos_test.go +++ b/repos_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestRepoService_ListActivityFeeds_Good(t *testing.T) { diff --git a/service_pagination_extra_test.go b/service_pagination_extra_test.go new file mode 100644 index 0000000..06eaf1b --- /dev/null +++ b/service_pagination_extra_test.go @@ -0,0 +1,348 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/forge/types" +) + +func TestRepoService_ListOrgReposPage_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/orgs/core/repos" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Errorf("got limit=%q, want %q", got, "25") + } + w.Header().Set("X-Total-Count", "51") + json.NewEncoder(w).Encode([]types.Repository{{Name: "go-forge"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Repos.ListOrgReposPage(context.Background(), "core", ListOptions{Page: 2, PageSize: 25}) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.TotalCount != 51 || len(page.Items) != 1 || page.Items[0].Name != "go-forge" { + t.Fatalf("got %#v", page) + } +} + +func TestRepoService_ListUserReposPage_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/users/Virgil/repos" { + t.Errorf("wrong path: %s", r.URL.Path) + } + 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 != "10" { + t.Errorf("got limit=%q, want %q", got, "10") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Repository{{Name: "go-user"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Repos.ListUserReposPage(context.Background(), "Virgil", ListOptions{Page: 1, PageSize: 10}) + if err != nil { + t.Fatal(err) + } + if page.TotalCount != 1 || len(page.Items) != 1 || page.Items[0].Name != "go-user" { + t.Fatalf("got %#v", page) + } +} + +func TestIssueService_ListRepoIssuesPage_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" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Errorf("got state=%q, want %q", got, "open") + } + if got := r.URL.Query().Get("page"); got != "3" { + t.Errorf("got page=%q, want %q", got, "3") + } + if got := r.URL.Query().Get("limit"); got != "15" { + t.Errorf("got limit=%q, want %q", got, "15") + } + w.Header().Set("X-Total-Count", "42") + json.NewEncoder(w).Encode([]types.Issue{{ID: 7, Title: "bug"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Issues.ListRepoIssuesPage(context.Background(), "core", "go-forge", ListOptions{Page: 3, PageSize: 15}, &types.ListIssueOption{State: "open"}) + if err != nil { + t.Fatal(err) + } + if page.Page != 3 || page.TotalCount != 42 || len(page.Items) != 1 || page.Items[0].Title != "bug" { + t.Fatalf("got %#v", page) + } +} + +func TestPullService_ListPullRequestsPage_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/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Errorf("got state=%q, want %q", got, "open") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "20" { + t.Errorf("got limit=%q, want %q", got, "20") + } + w.Header().Set("X-Total-Count", "21") + json.NewEncoder(w).Encode([]types.PullRequest{{ID: 9, Title: "feature"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Pulls.ListPullRequestsPage(context.Background(), "core", "go-forge", ListOptions{Page: 2, PageSize: 20}, &types.ListPullRequestsOption{State: "open"}) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.TotalCount != 21 || len(page.Items) != 1 || page.Items[0].Title != "feature" { + t.Fatalf("got %#v", page) + } +} + +func TestCommitService_ListCommitsPage_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/commits" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("sha"); got != "main" { + t.Errorf("got sha=%q, want %q", got, "main") + } + if got := r.URL.Query().Get("page"); got != "4" { + t.Errorf("got page=%q, want %q", got, "4") + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Errorf("got limit=%q, want %q", got, "5") + } + w.Header().Set("X-Total-Count", "17") + json.NewEncoder(w).Encode([]types.Commit{{SHA: "abc123"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Commits.ListCommitsPage(context.Background(), "core", "go-forge", ListOptions{Page: 4, PageSize: 5}, &types.ListCommitsOption{Sha: "main"}) + if err != nil { + t.Fatal(err) + } + if page.Page != 4 || page.TotalCount != 17 || len(page.Items) != 1 || page.Items[0].SHA != "abc123" { + t.Fatalf("got %#v", page) + } +} + +func TestBranchService_ListBranchesPage_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/branches" { + t.Errorf("wrong path: %s", r.URL.Path) + } + 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 != "30" { + t.Errorf("got limit=%q, want %q", got, "30") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Branch{{Name: "main"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Branches.ListBranchesPage(context.Background(), "core", "go-forge", ListOptions{Page: 1, PageSize: 30}) + if err != nil { + t.Fatal(err) + } + if page.TotalCount != 1 || len(page.Items) != 1 || page.Items[0].Name != "main" { + t.Fatalf("got %#v", page) + } +} + +func TestOrgService_ListOrgTeamsPage_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/orgs/core/teams" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "10" { + t.Errorf("got limit=%q, want %q", got, "10") + } + w.Header().Set("X-Total-Count", "11") + json.NewEncoder(w).Encode([]types.Team{{ID: 1, Name: "maintainers"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Orgs.ListOrgTeamsPage(context.Background(), "core", ListOptions{Page: 2, PageSize: 10}) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.TotalCount != 11 || len(page.Items) != 1 || page.Items[0].Name != "maintainers" { + t.Fatalf("got %#v", page) + } +} + +func TestWebhookService_ListRepoHooksPage_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/hooks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + 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 != "5" { + t.Errorf("got limit=%q, want %q", got, "5") + } + w.Header().Set("X-Total-Count", "6") + json.NewEncoder(w).Encode([]types.Hook{{ID: 1, Type: "forgejo"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Webhooks.ListRepoHooksPage(context.Background(), "core", "go-forge", ListOptions{Page: 1, PageSize: 5}) + if err != nil { + t.Fatal(err) + } + if page.TotalCount != 6 || len(page.Items) != 1 || page.Items[0].Type != "forgejo" { + t.Fatalf("got %#v", page) + } +} + +func TestReleaseService_ListReleasesPage_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/releases" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("draft"); got != "true" { + t.Errorf("got draft=%q, want %q", got, "true") + } + if got := r.URL.Query().Get("q"); got != "v1" { + t.Errorf("got q=%q, want %q", got, "v1") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "10" { + t.Errorf("got limit=%q, want %q", got, "10") + } + w.Header().Set("X-Total-Count", "12") + json.NewEncoder(w).Encode([]types.Release{{ID: 1, TagName: "v1.0.0"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Releases.ListReleasesPage(context.Background(), "core", "go-forge", ListOptions{Page: 2, PageSize: 10}, ReleaseListOptions{Draft: true, Query: "v1"}) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.TotalCount != 12 || len(page.Items) != 1 || page.Items[0].TagName != "v1.0.0" { + t.Fatalf("got %#v", page) + } +} + +func TestMilestoneService_ListMilestonesPage_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/milestones" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Errorf("got state=%q, want %q", got, "open") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "10" { + t.Errorf("got limit=%q, want %q", got, "10") + } + w.Header().Set("X-Total-Count", "11") + json.NewEncoder(w).Encode([]types.Milestone{{ID: 2, Title: "Sprint"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Milestones.ListMilestonesPage(context.Background(), "core", "go-forge", ListOptions{Page: 2, PageSize: 10}, MilestoneListOptions{State: "open"}) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.TotalCount != 11 || len(page.Items) != 1 || page.Items[0].Title != "Sprint" { + t.Fatalf("got %#v", page) + } +} + +func TestLabelService_ListLabelsPage_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/labels" { + t.Errorf("wrong path: %s", r.URL.Path) + } + 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 != "8" { + t.Errorf("got limit=%q, want %q", got, "8") + } + w.Header().Set("X-Total-Count", "8") + json.NewEncoder(w).Encode([]types.Label{{ID: 1, Name: "bug"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Labels.ListLabelsPage(context.Background(), "core", "go-forge", ListOptions{Page: 1, PageSize: 8}) + if err != nil { + t.Fatal(err) + } + if page.TotalCount != 8 || len(page.Items) != 1 || page.Items[0].Name != "bug" { + t.Fatalf("got %#v", page) + } +} diff --git a/teams.go b/teams.go index 88e414c..0b0eb94 100644 --- a/teams.go +++ b/teams.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // TeamService handles team operations. diff --git a/teams_test.go b/teams_test.go index 0572411..567f288 100644 --- a/teams_test.go +++ b/teams_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestTeamService_Get_Good(t *testing.T) { diff --git a/tests/cli/forge/Taskfile.yaml b/tests/cli/forge/Taskfile.yaml new file mode 100644 index 0000000..43c9635 --- /dev/null +++ b/tests/cli/forge/Taskfile.yaml @@ -0,0 +1,25 @@ +version: "3" + +tasks: + default: + deps: [test] + + test: + desc: Validate the go-forge AX-10 CLI artifact driver. + dir: ../../.. + cmds: + - | + export GOCACHE="${GOCACHE:-/tmp/go-forge-gocache}" + export GOMODCACHE="${GOMODCACHE:-/tmp/go-forge-gomodcache}" + export GOWORK=off + mkdir -p "$GOCACHE" "$GOMODCACHE" + bin="$(mktemp -t core-forge.XXXXXX)" + trap 'rm -f "$bin"' EXIT + go build -o "$bin" ./tests/cli/forge + "$bin" + + driver: + desc: Run the go-forge AX-10 driver directly. + dir: ../../.. + cmds: + - GOWORK=off go run ./tests/cli/forge diff --git a/tests/cli/forge/main.go b/tests/cli/forge/main.go new file mode 100644 index 0000000..85f36c3 --- /dev/null +++ b/tests/cli/forge/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "fmt" + "os" + + forge "dappco.re/go/forge" +) + +func main() { + url := os.Getenv("FORGE_URL") + token := os.Getenv("FORGE_TOKEN") + if url == "" || token == "" { + fmt.Println("skip: FORGE_URL and FORGE_TOKEN are required") + return + } + + f := forge.NewForge(url, token) + repos, err := f.Repos.ListOrgRepos(context.Background(), "core") + if err != nil { + fmt.Fprintf(os.Stderr, "list core repos: %v\n", err) + os.Exit(1) + } + + fmt.Printf("core repos: %d\n", len(repos)) +} diff --git a/types/content.go b/types/content.go index 8693d8f..a1f3630 100644 --- a/types/content.go +++ b/types/content.go @@ -36,6 +36,7 @@ type CreateFileOptions struct { Author *Identity `json:"author,omitempty"` BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used Committer *Identity `json:"committer,omitempty"` + Content string `json:"-"` // RFC compatibility alias for ContentBase64 ContentBase64 string `json:"content"` // content must be base64 encoded Dates *CommitDateOptions `json:"dates,omitempty"` Message string `json:"message,omitempty"` // message (optional) for the commit of this file. if not supplied, a default message will be used @@ -127,6 +128,7 @@ type UpdateFileOptions struct { Author *Identity `json:"author,omitempty"` BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used Committer *Identity `json:"committer,omitempty"` + Content string `json:"-"` // RFC compatibility alias for ContentBase64 ContentBase64 string `json:"content"` // content must be base64 encoded Dates *CommitDateOptions `json:"dates,omitempty"` FromPath string `json:"from_path,omitempty"` // from_path (optional) is the path of the original file which will be moved/renamed to the path in the URL diff --git a/types/content_compat.go b/types/content_compat.go new file mode 100644 index 0000000..0742dba --- /dev/null +++ b/types/content_compat.go @@ -0,0 +1,69 @@ +package types + +import json "github.com/goccy/go-json" + +type createFileOptionsCompat CreateFileOptions + +func (o CreateFileOptions) MarshalJSON() ([]byte, error) { + aux := createFileOptionsCompat(o) + if aux.ContentBase64 == "" && o.Content != "" { + aux.ContentBase64 = o.Content + } + return json.Marshal(aux) +} + +func (o *CreateFileOptions) UnmarshalJSON(data []byte) error { + var aux createFileOptionsCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = CreateFileOptions(aux) + if o.Content == "" { + o.Content = o.ContentBase64 + } + return nil +} + +type updateFileOptionsCompat UpdateFileOptions + +func (o UpdateFileOptions) MarshalJSON() ([]byte, error) { + aux := updateFileOptionsCompat(o) + if aux.ContentBase64 == "" && o.Content != "" { + aux.ContentBase64 = o.Content + } + return json.Marshal(aux) +} + +func (o *UpdateFileOptions) UnmarshalJSON(data []byte) error { + var aux updateFileOptionsCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = UpdateFileOptions(aux) + if o.Content == "" { + o.Content = o.ContentBase64 + } + return nil +} + +type changeFileOperationCompat ChangeFileOperation + +func (o ChangeFileOperation) MarshalJSON() ([]byte, error) { + aux := changeFileOperationCompat(o) + if aux.ContentBase64 == "" && o.Content != "" { + aux.ContentBase64 = o.Content + } + return json.Marshal(aux) +} + +func (o *ChangeFileOperation) UnmarshalJSON(data []byte) error { + var aux changeFileOperationCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = ChangeFileOperation(aux) + if o.Content == "" { + o.Content = o.ContentBase64 + } + return nil +} diff --git a/types/hook.go b/types/hook.go index 27015a8..e8bb26a 100644 --- a/types/hook.go +++ b/types/hook.go @@ -10,12 +10,12 @@ import "time" // // opts := CreateHookOption{Type: "example"} type CreateHookOption struct { - Active bool `json:"active,omitempty"` - AuthorizationHeader string `json:"authorization_header,omitempty"` - BranchFilter string `json:"branch_filter,omitempty"` - Config *CreateHookOptionConfig `json:"config"` - Events []string `json:"events,omitempty"` - Type string `json:"type"` + Active bool `json:"active,omitempty"` + AuthorizationHeader string `json:"authorization_header,omitempty"` + BranchFilter string `json:"branch_filter,omitempty"` + Config any `json:"config"` + Events []string `json:"events,omitempty"` + Type string `json:"type"` } // CreateHookOptionConfig — CreateHookOptionConfig has all config options in it required are "content_type" and "url" Required diff --git a/types/issue.go b/types/issue.go index b04b9e4..f71e782 100644 --- a/types/issue.go +++ b/types/issue.go @@ -25,7 +25,7 @@ type CreateIssueOption struct { Body string `json:"body,omitempty"` Closed bool `json:"closed,omitempty"` Deadline time.Time `json:"due_date,omitempty"` - Labels []int64 `json:"labels,omitempty"` // list of label ids + Labels any `json:"labels,omitempty"` // list of label ids or names (RFC compatibility) Milestone int64 `json:"milestone,omitempty"` // milestone id Ref string `json:"ref,omitempty"` Title string `json:"title"` diff --git a/types/label_compat.go b/types/label_compat.go new file mode 100644 index 0000000..00bd2d0 --- /dev/null +++ b/types/label_compat.go @@ -0,0 +1,80 @@ +package types + +import json "github.com/goccy/go-json" + +type createIssueOptionCompat CreateIssueOption + +func (o *CreateIssueOption) UnmarshalJSON(data []byte) error { + var aux createIssueOptionCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = CreateIssueOption(aux) + o.Labels = normaliseLabelRefs(o.Labels) + return nil +} + +type createPullRequestOptionCompat CreatePullRequestOption + +func (o *CreatePullRequestOption) UnmarshalJSON(data []byte) error { + var aux createPullRequestOptionCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = CreatePullRequestOption(aux) + o.Labels = normaliseLabelRefs(o.Labels) + return nil +} + +type editPullRequestOptionCompat EditPullRequestOption + +func (o *EditPullRequestOption) UnmarshalJSON(data []byte) error { + var aux editPullRequestOptionCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = EditPullRequestOption(aux) + o.Labels = normaliseLabelRefs(o.Labels) + return nil +} + +func normaliseLabelRefs(v any) any { + items, ok := v.([]any) + if !ok { + return v + } + if len(items) == 0 { + return []string{} + } + + strs := make([]string, 0, len(items)) + for _, item := range items { + s, ok := item.(string) + if !ok { + strs = nil + break + } + strs = append(strs, s) + } + if strs != nil { + return strs + } + + ints := make([]int64, 0, len(items)) + for _, item := range items { + switch x := item.(type) { + case float64: + if float64(int64(x)) != x { + return v + } + ints = append(ints, int64(x)) + case int64: + ints = append(ints, x) + case int: + ints = append(ints, int64(x)) + default: + return v + } + } + return ints +} diff --git a/types/label_compat_test.go b/types/label_compat_test.go new file mode 100644 index 0000000..fe6c56f --- /dev/null +++ b/types/label_compat_test.go @@ -0,0 +1,36 @@ +package types + +import ( + json "github.com/goccy/go-json" + "testing" +) + +func TestCreateIssueOption_Unmarshal_LabelNamesCompat_Good(t *testing.T) { + var opts CreateIssueOption + if err := json.Unmarshal([]byte(`{"title":"issue","labels":["enhancement","bug"]}`), &opts); err != nil { + t.Fatal(err) + } + + labels, ok := opts.Labels.([]string) + if !ok { + t.Fatalf("expected []string labels, got %T", opts.Labels) + } + if len(labels) != 2 || labels[0] != "enhancement" || labels[1] != "bug" { + t.Fatalf("unexpected labels: %#v", labels) + } +} + +func TestCreatePullRequestOption_Unmarshal_LabelIDsCompat_Good(t *testing.T) { + var opts CreatePullRequestOption + if err := json.Unmarshal([]byte(`{"title":"pr","labels":[1,2]}`), &opts); err != nil { + t.Fatal(err) + } + + labels, ok := opts.Labels.([]int64) + if !ok { + t.Fatalf("expected []int64 labels, got %T", opts.Labels) + } + if len(labels) != 2 || labels[0] != 1 || labels[1] != 2 { + t.Fatalf("unexpected labels: %#v", labels) + } +} diff --git a/types/list_options_compat.go b/types/list_options_compat.go new file mode 100644 index 0000000..2ab0943 --- /dev/null +++ b/types/list_options_compat.go @@ -0,0 +1,60 @@ +// Compatibility types for RFC-style list options. + +package types + +import "time" + +// ListIssueOption is a compatibility alias for repository issue list filters. +// +// Usage: +// +// opts := ListIssueOption{State: "open", Sort: "created"} +type ListIssueOption struct { + AssignedBy string `json:"assigned_by,omitempty"` + Before *time.Time `json:"before,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Labels string `json:"labels,omitempty"` + Limit int `json:"limit,omitempty"` + MentionedBy string `json:"mentioned_by,omitempty"` + Milestones string `json:"milestones,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + Query string `json:"q,omitempty"` + Sort string `json:"sort,omitempty"` + State string `json:"state,omitempty"` + Since *time.Time `json:"since,omitempty"` + Type string `json:"type,omitempty"` +} + +// ListPullRequestsOption is a compatibility alias for repository pull request list filters. +// +// Usage: +// +// opts := ListPullRequestsOption{State: "open"} +type ListPullRequestsOption struct { + Labels []int64 `json:"labels,omitempty"` + Limit int `json:"limit,omitempty"` + Milestone int64 `json:"milestone,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + Poster string `json:"poster,omitempty"` + Sort string `json:"sort,omitempty"` + State string `json:"state,omitempty"` +} + +// ListCommitsOption is a compatibility alias for repository commit list filters. +// +// Usage: +// +// opts := ListCommitsOption{Sha: "main"} +type ListCommitsOption struct { + Files *bool `json:"files,omitempty"` + Limit int `json:"limit,omitempty"` + Not string `json:"not,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + Path string `json:"path,omitempty"` + Sha string `json:"sha,omitempty"` + Stat *bool `json:"stat,omitempty"` + Verification *bool `json:"verification,omitempty"` +} diff --git a/types/misc.go b/types/misc.go index 3da46b9..7297757 100644 --- a/types/misc.go +++ b/types/misc.go @@ -30,6 +30,7 @@ type AddTimeOption struct { // // opts := ChangeFileOperation{Operation: "example"} type ChangeFileOperation struct { + Content string `json:"-"` // RFC compatibility alias for ContentBase64 ContentBase64 string `json:"content,omitempty"` // new or updated file content, must be base64 encoded FromPath string `json:"from_path,omitempty"` // old path of the file to move Operation string `json:"operation"` // indicates what to do with the file @@ -203,6 +204,7 @@ type MergePullRequestOption struct { MergeMessageField string `json:"MergeMessageField,omitempty"` MergeTitleField string `json:"MergeTitleField,omitempty"` MergeWhenChecksSucceed bool `json:"merge_when_checks_succeed,omitempty"` + MergeStyle string `json:"-"` } // MigrateRepoOptions — MigrateRepoOptions options for migrating repository's this is used to interact with api v1 diff --git a/types/misc_compat.go b/types/misc_compat.go new file mode 100644 index 0000000..a6139ab --- /dev/null +++ b/types/misc_compat.go @@ -0,0 +1,25 @@ +package types + +import json "github.com/goccy/go-json" + +type mergePullRequestOptionCompat MergePullRequestOption + +func (o MergePullRequestOption) MarshalJSON() ([]byte, error) { + aux := mergePullRequestOptionCompat(o) + if aux.Do == "" && o.MergeStyle != "" { + aux.Do = o.MergeStyle + } + return json.Marshal(aux) +} + +func (o *MergePullRequestOption) UnmarshalJSON(data []byte) error { + var aux mergePullRequestOptionCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = MergePullRequestOption(aux) + if o.MergeStyle == "" { + o.MergeStyle = o.Do + } + return nil +} diff --git a/types/misc_compat_test.go b/types/misc_compat_test.go new file mode 100644 index 0000000..895d8cd --- /dev/null +++ b/types/misc_compat_test.go @@ -0,0 +1,46 @@ +package types + +import ( + json "github.com/goccy/go-json" + "testing" +) + +func TestMergePullRequestOption_MarshalJSON_CompatMergeStyle_Good(t *testing.T) { + data, err := json.Marshal(MergePullRequestOption{ + MergeMessageField: "PR: Add feature", + MergeStyle: "squash", + }) + if err != nil { + t.Fatal(err) + } + + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Fatal(err) + } + if got := body["Do"]; got != "squash" { + t.Fatalf("got Do=%v, want squash", got) + } + if _, ok := body["MergeStyle"]; ok { + t.Fatalf("did not expect MergeStyle in request body: %#v", body) + } + if got := body["MergeMessageField"]; got != "PR: Add feature" { + t.Fatalf("got MergeMessageField=%v, want %q", got, "PR: Add feature") + } +} + +func TestMergePullRequestOption_UnmarshalJSON_CompatMergeStyle_Good(t *testing.T) { + var opts MergePullRequestOption + if err := json.Unmarshal([]byte(`{"Do":"rebase","MergeMessageField":"ready"}`), &opts); err != nil { + t.Fatal(err) + } + if opts.Do != "rebase" { + t.Fatalf("got Do=%q, want %q", opts.Do, "rebase") + } + if opts.MergeStyle != "rebase" { + t.Fatalf("got MergeStyle=%q, want %q", opts.MergeStyle, "rebase") + } + if opts.MergeMessageField != "ready" { + t.Fatalf("got MergeMessageField=%q, want %q", opts.MergeMessageField, "ready") + } +} diff --git a/types/pr.go b/types/pr.go index 7204103..8a395a5 100644 --- a/types/pr.go +++ b/types/pr.go @@ -16,7 +16,7 @@ type CreatePullRequestOption struct { Body string `json:"body,omitempty"` Deadline time.Time `json:"due_date,omitempty"` Head string `json:"head,omitempty"` - Labels []int64 `json:"labels,omitempty"` + Labels any `json:"labels,omitempty"` // list of label ids or names (RFC compatibility) Milestone int64 `json:"milestone,omitempty"` Title string `json:"title,omitempty"` } @@ -64,7 +64,7 @@ type EditPullRequestOption struct { Base string `json:"base,omitempty"` Body string `json:"body,omitempty"` Deadline time.Time `json:"due_date,omitempty"` - Labels []int64 `json:"labels,omitempty"` + Labels any `json:"labels,omitempty"` // list of label ids or names (RFC compatibility) Milestone int64 `json:"milestone,omitempty"` RemoveDeadline bool `json:"unset_due_date,omitempty"` State string `json:"state,omitempty"` diff --git a/users.go b/users.go index d0254a8..c1b417f 100644 --- a/users.go +++ b/users.go @@ -4,11 +4,8 @@ import ( "context" "iter" "net/http" - "net/url" - "strconv" - core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // UserService handles user operations. @@ -43,7 +40,7 @@ func (o UserSearchOptions) queryParams() map[string]string { return nil } return map[string]string{ - "uid": strconv.FormatInt(o.UID, 10), + "uid": int64String(o.UID), } } @@ -86,6 +83,38 @@ func newUserService(c *Client) *UserService { } } +// GetUserByID returns a user by numeric ID. +func (s *UserService) GetUserByID(ctx context.Context, id int64) (*types.User, error) { + if id < 1 { + return nil, &APIError{ + StatusCode: http.StatusNotFound, + Message: "user not found", + URL: "/api/v1/users/search", + } + } + + result, err := s.SearchUsersPage(ctx, "", ListOptions{Page: 1, PageSize: 1}, UserSearchOptions{UID: id}) + if err != nil { + return nil, err + } + for i := range result.Items { + if result.Items[i].ID == id { + return &result.Items[i], nil + } + } + + return nil, &APIError{ + StatusCode: http.StatusNotFound, + Message: "user not found", + URL: "/api/v1/users/search", + } +} + +// GetUserByUsername returns a user by username. +func (s *UserService) GetUserByUsername(ctx context.Context, username string) (*types.User, error) { + return s.Get(ctx, pathParams("username", username)) +} + // GetCurrent returns the authenticated user. func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error) { var out types.User @@ -127,33 +156,32 @@ func (s *UserService) SearchUsersPage(ctx context.Context, query string, pageOpt if pageOpts.Page < 1 { pageOpts.Page = 1 } - if pageOpts.Limit < 1 { - pageOpts.Limit = 50 + pageSize := pageOpts.PageSize + if pageSize < 1 { + pageSize = pageOpts.Limit } - - u, err := url.Parse("/api/v1/users/search") - if err != nil { - return nil, core.E("UserService.SearchUsersPage", "forge: parse path", err) + if pageSize < 1 { + pageSize = 50 } - q := u.Query() - q.Set("q", query) - for _, filter := range filters { - for key, value := range filter.queryParams() { - q.Set(key, value) + path := appendQuery("/api/v1/users/search", func(q *queryBuilder) { + q.Set("q", query) + for _, filter := range filters { + for key, value := range filter.queryParams() { + q.Set(key, value) + } } - } - q.Set("page", strconv.Itoa(pageOpts.Page)) - q.Set("limit", strconv.Itoa(pageOpts.Limit)) - u.RawQuery = q.Encode() + q.Set("page", intString(pageOpts.Page)) + q.Set("limit", intString(pageSize)) + }) var out userSearchResults - resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &out) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, &out) if err != nil { return nil, err } - totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + totalCount := parseInt(resp.Header.Get("X-Total-Count")) items := make([]types.User, 0, len(out.Data)) for _, user := range out.Data { if user != nil { @@ -165,8 +193,8 @@ func (s *UserService) SearchUsersPage(ctx context.Context, query string, pageOpt Items: items, TotalCount: totalCount, Page: pageOpts.Page, - HasMore: (totalCount > 0 && (pageOpts.Page-1)*pageOpts.Limit+len(items) < totalCount) || - (totalCount == 0 && len(items) >= pageOpts.Limit), + HasMore: (totalCount > 0 && (pageOpts.Page-1)*pageSize+len(items) < totalCount) || + (totalCount == 0 && len(items) >= pageSize), }, nil } @@ -176,7 +204,7 @@ func (s *UserService) SearchUsers(ctx context.Context, query string, filters ... page := 1 for { - result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, Limit: 50}, filters...) + result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, PageSize: 50}, filters...) if err != nil { return nil, err } @@ -195,7 +223,7 @@ func (s *UserService) IterSearchUsers(ctx context.Context, query string, filters return func(yield func(types.User, error) bool) { page := 1 for { - result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, Limit: 50}, filters...) + result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, PageSize: 50}, filters...) if err != nil { yield(*new(types.User), err) return diff --git a/users_extra_test.go b/users_extra_test.go index bcd80a3..18d08db 100644 --- a/users_extra_test.go +++ b/users_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestUserService_ListMyFollowing_Good(t *testing.T) { diff --git a/users_test.go b/users_test.go index 9fe78e2..8b9df26 100644 --- a/users_test.go +++ b/users_test.go @@ -8,7 +8,7 @@ import ( "strconv" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestUserService_Get_Good(t *testing.T) { @@ -55,6 +55,73 @@ func TestUserService_GetCurrent_Good(t *testing.T) { } } +func TestUserService_GetUserByID_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/users/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("uid"); got != "42" { + t.Errorf("wrong uid: %s", got) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("wrong page: %s", got) + } + if got := r.URL.Query().Get("limit"); got != "1" { + t.Errorf("wrong limit: %s", got) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode(map[string]any{ + "data": []*types.User{ + {ID: 42, UserName: "alice"}, + }, + "ok": true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + user, err := f.Users.GetUserByID(context.Background(), 42) + if err != nil { + t.Fatal(err) + } + if user.ID != 42 || user.UserName != "alice" { + t.Fatalf("got %#v", user) + } +} + +func TestUserService_GetUserByID_NotFound(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/users/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("uid"); got != "42" { + t.Errorf("wrong uid: %s", got) + } + w.Header().Set("X-Total-Count", "0") + json.NewEncoder(w).Encode(map[string]any{ + "data": []*types.User{}, + "ok": true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + _, err := f.Users.GetUserByID(context.Background(), 42) + if !IsNotFound(err) { + t.Fatalf("expected not found error, got %v", err) + } +} + func TestUserService_GetSettings_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/webhooks.go b/webhooks.go index 0824aba..5478f69 100644 --- a/webhooks.go +++ b/webhooks.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // WebhookService handles webhook (hook) operations within a repository. @@ -26,18 +26,39 @@ func newWebhookService(c *Client) *WebhookService { } } +// ListHooksPage returns a single page of webhooks for a repository. +func (s *WebhookService) ListHooksPage(ctx context.Context, owner, repo string, opts ListOptions) (*PagedResult[types.Hook], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks", pathParams("owner", owner, "repo", repo)) + return ListPage[types.Hook](ctx, s.client, path, nil, opts) +} + // ListHooks returns all webhooks for a repository. func (s *WebhookService) ListHooks(ctx context.Context, owner, repo string) ([]types.Hook, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks", pathParams("owner", owner, "repo", repo)) return ListAll[types.Hook](ctx, s.client, path, nil) } +// ListRepoHooksPage returns a single page of webhooks for a repository. +func (s *WebhookService) ListRepoHooksPage(ctx context.Context, owner, repo string, opts ListOptions) (*PagedResult[types.Hook], error) { + return s.ListHooksPage(ctx, owner, repo, opts) +} + +// ListRepoHooks returns all webhooks for a repository. +func (s *WebhookService) ListRepoHooks(ctx context.Context, owner, repo string) ([]types.Hook, error) { + return s.ListHooks(ctx, owner, repo) +} + // IterHooks returns an iterator over all webhooks for a repository. func (s *WebhookService) IterHooks(ctx context.Context, owner, repo string) iter.Seq2[types.Hook, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks", pathParams("owner", owner, "repo", repo)) return ListIter[types.Hook](ctx, s.client, path, nil) } +// IterRepoHooks returns an iterator over all webhooks for a repository. +func (s *WebhookService) IterRepoHooks(ctx context.Context, owner, repo string) iter.Seq2[types.Hook, error] { + return s.IterHooks(ctx, owner, repo) +} + // CreateHook creates a webhook for a repository. func (s *WebhookService) CreateHook(ctx context.Context, owner, repo string, opts *types.CreateHookOption) (*types.Hook, error) { var out types.Hook @@ -47,6 +68,26 @@ func (s *WebhookService) CreateHook(ctx context.Context, owner, repo string, opt return &out, nil } +// CreateRepoHook creates a webhook for a repository. +func (s *WebhookService) CreateRepoHook(ctx context.Context, owner, repo string, opts *types.CreateHookOption) (*types.Hook, error) { + return s.CreateHook(ctx, owner, repo, opts) +} + +// GetRepoHook returns a single webhook for a repository. +func (s *WebhookService) GetRepoHook(ctx context.Context, owner, repo string, id int64) (*types.Hook, error) { + return s.Get(ctx, pathParams("owner", owner, "repo", repo, "id", int64String(id))) +} + +// EditRepoHook updates an existing webhook in a repository. +func (s *WebhookService) EditRepoHook(ctx context.Context, owner, repo string, id int64, opts *types.EditHookOption) (*types.Hook, error) { + return s.Update(ctx, pathParams("owner", owner, "repo", repo, "id", int64String(id)), opts) +} + +// DeleteRepoHook deletes a webhook from a repository. +func (s *WebhookService) DeleteRepoHook(ctx context.Context, owner, repo string, id int64) error { + return s.Delete(ctx, pathParams("owner", owner, "repo", repo, "id", int64String(id))) +} + // TestHook triggers a test delivery for a webhook. func (s *WebhookService) TestHook(ctx context.Context, owner, repo string, id int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks/{id}/tests", pathParams("owner", owner, "repo", repo, "id", int64String(id))) @@ -96,6 +137,11 @@ func (s *WebhookService) ListUserHooks(ctx context.Context) ([]types.Hook, error return ListAll[types.Hook](ctx, s.client, "/api/v1/user/hooks", nil) } +// ListUserHooksPage returns a single page of webhooks for the authenticated user. +func (s *WebhookService) ListUserHooksPage(ctx context.Context, opts ListOptions) (*PagedResult[types.Hook], error) { + return ListPage[types.Hook](ctx, s.client, "/api/v1/user/hooks", nil, opts) +} + // IterUserHooks returns an iterator over all webhooks for the authenticated user. func (s *WebhookService) IterUserHooks(ctx context.Context) iter.Seq2[types.Hook, error] { return ListIter[types.Hook](ctx, s.client, "/api/v1/user/hooks", nil) @@ -142,6 +188,12 @@ func (s *WebhookService) ListOrgHooks(ctx context.Context, org string) ([]types. return ListAll[types.Hook](ctx, s.client, path, nil) } +// ListOrgHooksPage returns a single page of webhooks for an organisation. +func (s *WebhookService) ListOrgHooksPage(ctx context.Context, org string, opts ListOptions) (*PagedResult[types.Hook], error) { + path := ResolvePath("/api/v1/orgs/{org}/hooks", pathParams("org", org)) + return ListPage[types.Hook](ctx, s.client, path, nil, opts) +} + // IterOrgHooks returns an iterator over all webhooks for an organisation. func (s *WebhookService) IterOrgHooks(ctx context.Context, org string) iter.Seq2[types.Hook, error] { path := ResolvePath("/api/v1/orgs/{org}/hooks", pathParams("org", org)) diff --git a/webhooks_extra_test.go b/webhooks_extra_test.go index b250582..7b0cdb2 100644 --- a/webhooks_extra_test.go +++ b/webhooks_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestWebhookService_ListHooks_Good(t *testing.T) { diff --git a/webhooks_test.go b/webhooks_test.go index 68d1ff9..d9f3814 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestWebhookService_List_Good(t *testing.T) { diff --git a/wiki.go b/wiki.go index 2243b3d..e67895f 100644 --- a/wiki.go +++ b/wiki.go @@ -5,7 +5,7 @@ import ( "iter" "strconv" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // WikiService handles wiki page operations for a repository. diff --git a/wiki_test.go b/wiki_test.go index ca2ab13..cb3613e 100644 --- a/wiki_test.go +++ b/wiki_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestWikiService_ListPages_Good(t *testing.T) {