From 9cdab89c6c1d3083db7b99d751244f09afbc86d6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 06:47:10 +0000 Subject: [PATCH] Fix service path segment escaping Co-Authored-By: Virgil --- actions.go | 27 +++-- branches.go | 13 ++- commits.go | 9 +- contents.go | 11 +- issues.go | 26 ++--- labels.go | 20 ++-- misc.go | 5 +- notifications.go | 10 +- orgs.go | 13 ++- packages.go | 13 ++- path_escape.go | 22 ++++ path_escape_test.go | 257 ++++++++++++++++++++++++++++++++++++++++++++ pulls.go | 34 ++++-- releases.go | 30 ++++-- repos.go | 14 +-- teams.go | 22 ++-- users.go | 21 ++-- webhooks.go | 8 +- wiki.go | 11 +- 19 files changed, 435 insertions(+), 131 deletions(-) create mode 100644 path_escape.go create mode 100644 path_escape_test.go diff --git a/actions.go b/actions.go index ba110b8..97e1065 100644 --- a/actions.go +++ b/actions.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" @@ -21,82 +20,82 @@ func newActionsService(c *Client) *ActionsService { // ListRepoSecrets returns all secrets for a repository. func (s *ActionsService) ListRepoSecrets(ctx context.Context, owner, repo string) ([]types.Secret, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "actions", "secrets") return ListAll[types.Secret](ctx, s.client, path, nil) } // IterRepoSecrets returns an iterator over all secrets for a repository. func (s *ActionsService) IterRepoSecrets(ctx context.Context, owner, repo string) iter.Seq2[types.Secret, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "actions", "secrets") return ListIter[types.Secret](ctx, s.client, path, nil) } // CreateRepoSecret creates or updates a secret in a repository. // Forgejo expects a PUT with {"data": "secret-value"} body. func (s *ActionsService) CreateRepoSecret(ctx context.Context, owner, repo, name string, data string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", owner, repo, name) + path := escapePathSegments("/api/v1/repos", owner, repo, "actions", "secrets", name) body := map[string]string{"data": data} return s.client.Put(ctx, path, body, nil) } // DeleteRepoSecret removes a secret from a repository. func (s *ActionsService) DeleteRepoSecret(ctx context.Context, owner, repo, name string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", owner, repo, name) + path := escapePathSegments("/api/v1/repos", owner, repo, "actions", "secrets", name) return s.client.Delete(ctx, path) } // ListRepoVariables returns all action variables for a repository. func (s *ActionsService) ListRepoVariables(ctx context.Context, owner, repo string) ([]types.ActionVariable, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "actions", "variables") return ListAll[types.ActionVariable](ctx, s.client, path, nil) } // IterRepoVariables returns an iterator over all action variables for a repository. func (s *ActionsService) IterRepoVariables(ctx context.Context, owner, repo string) iter.Seq2[types.ActionVariable, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "actions", "variables") return ListIter[types.ActionVariable](ctx, s.client, path, nil) } // CreateRepoVariable creates a new action variable in a repository. // Forgejo expects a POST with {"value": "var-value"} body. func (s *ActionsService) CreateRepoVariable(ctx context.Context, owner, repo, name, value string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", owner, repo, name) + path := escapePathSegments("/api/v1/repos", owner, repo, "actions", "variables", name) body := types.CreateVariableOption{Value: value} return s.client.Post(ctx, path, body, nil) } // DeleteRepoVariable removes an action variable from a repository. func (s *ActionsService) DeleteRepoVariable(ctx context.Context, owner, repo, name string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", owner, repo, name) + path := escapePathSegments("/api/v1/repos", owner, repo, "actions", "variables", name) return s.client.Delete(ctx, path) } // ListOrgSecrets returns all secrets for an organisation. func (s *ActionsService) ListOrgSecrets(ctx context.Context, org string) ([]types.Secret, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/actions/secrets", org) + path := escapePathSegments("/api/v1/orgs", org, "actions", "secrets") return ListAll[types.Secret](ctx, s.client, path, nil) } // IterOrgSecrets returns an iterator over all secrets for an organisation. func (s *ActionsService) IterOrgSecrets(ctx context.Context, org string) iter.Seq2[types.Secret, error] { - path := fmt.Sprintf("/api/v1/orgs/%s/actions/secrets", org) + path := escapePathSegments("/api/v1/orgs", org, "actions", "secrets") return ListIter[types.Secret](ctx, s.client, path, nil) } // ListOrgVariables returns all action variables for an organisation. func (s *ActionsService) ListOrgVariables(ctx context.Context, org string) ([]types.ActionVariable, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/actions/variables", org) + path := escapePathSegments("/api/v1/orgs", org, "actions", "variables") return ListAll[types.ActionVariable](ctx, s.client, path, nil) } // IterOrgVariables returns an iterator over all action variables for an organisation. func (s *ActionsService) IterOrgVariables(ctx context.Context, org string) iter.Seq2[types.ActionVariable, error] { - path := fmt.Sprintf("/api/v1/orgs/%s/actions/variables", org) + path := escapePathSegments("/api/v1/orgs", org, "actions", "variables") return ListIter[types.ActionVariable](ctx, s.client, path, nil) } // DispatchWorkflow triggers a workflow run. func (s *ActionsService) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, opts map[string]any) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow) + path := escapePathSegments("/api/v1/repos", owner, repo, "actions", "workflows", workflow, "dispatches") return s.client.Post(ctx, path, opts, nil) } diff --git a/branches.go b/branches.go index 66af9a4..d20a990 100644 --- a/branches.go +++ b/branches.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" @@ -23,19 +22,19 @@ func newBranchService(c *Client) *BranchService { // ListBranchProtections returns all branch protections for a repository. func (s *BranchService) ListBranchProtections(ctx context.Context, owner, repo string) ([]types.BranchProtection, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "branch_protections") return ListAll[types.BranchProtection](ctx, s.client, path, nil) } // IterBranchProtections returns an iterator over all branch protections for a repository. func (s *BranchService) IterBranchProtections(ctx context.Context, owner, repo string) iter.Seq2[types.BranchProtection, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "branch_protections") return ListIter[types.BranchProtection](ctx, s.client, path, nil) } // GetBranchProtection returns a single branch protection by name. func (s *BranchService) GetBranchProtection(ctx context.Context, owner, repo, name string) (*types.BranchProtection, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) + path := escapePathSegments("/api/v1/repos", owner, repo, "branch_protections", name) var out types.BranchProtection if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -45,7 +44,7 @@ func (s *BranchService) GetBranchProtection(ctx context.Context, owner, repo, na // CreateBranchProtection creates a new branch protection rule. func (s *BranchService) CreateBranchProtection(ctx context.Context, owner, repo string, opts *types.CreateBranchProtectionOption) (*types.BranchProtection, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "branch_protections") var out types.BranchProtection if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -55,7 +54,7 @@ func (s *BranchService) CreateBranchProtection(ctx context.Context, owner, repo // EditBranchProtection updates an existing branch protection rule. func (s *BranchService) EditBranchProtection(ctx context.Context, owner, repo, name string, opts *types.EditBranchProtectionOption) (*types.BranchProtection, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) + path := escapePathSegments("/api/v1/repos", owner, repo, "branch_protections", name) var out types.BranchProtection if err := s.client.Patch(ctx, path, opts, &out); err != nil { return nil, err @@ -65,6 +64,6 @@ func (s *BranchService) EditBranchProtection(ctx context.Context, owner, repo, n // DeleteBranchProtection deletes a branch protection rule. func (s *BranchService) DeleteBranchProtection(ctx context.Context, owner, repo, name string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) + path := escapePathSegments("/api/v1/repos", owner, repo, "branch_protections", name) return s.client.Delete(ctx, path) } diff --git a/commits.go b/commits.go index 27b501e..5f448a6 100644 --- a/commits.go +++ b/commits.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" @@ -51,7 +50,7 @@ func (s *CommitService) Get(ctx context.Context, params Params) (*types.Commit, // GetCombinedStatus returns the combined status for a given ref (branch, tag, or SHA). func (s *CommitService) GetCombinedStatus(ctx context.Context, owner, repo, ref string) (*types.CombinedStatus, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", owner, repo, ref) + path := escapePathSegments("/api/v1/repos", owner, repo, "statuses", ref) var out types.CombinedStatus if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -61,7 +60,7 @@ func (s *CommitService) GetCombinedStatus(ctx context.Context, owner, repo, ref // ListStatuses returns all commit statuses for a given ref. func (s *CommitService) ListStatuses(ctx context.Context, owner, repo, ref string) ([]types.CommitStatus, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/commits/%s/statuses", owner, repo, ref) + path := escapePathSegments("/api/v1/repos", owner, repo, "commits", ref, "statuses") var out []types.CommitStatus if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -71,7 +70,7 @@ func (s *CommitService) ListStatuses(ctx context.Context, owner, repo, ref strin // CreateStatus creates a new commit status for the given SHA. func (s *CommitService) CreateStatus(ctx context.Context, owner, repo, sha string, opts *types.CreateStatusOption) (*types.CommitStatus, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", owner, repo, sha) + path := escapePathSegments("/api/v1/repos", owner, repo, "statuses", sha) var out types.CommitStatus if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -81,7 +80,7 @@ func (s *CommitService) CreateStatus(ctx context.Context, owner, repo, sha strin // GetNote returns the git note for a given commit SHA. func (s *CommitService) GetNote(ctx context.Context, owner, repo, sha string) (*types.Note, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/git/notes/%s", owner, repo, sha) + path := escapePathSegments("/api/v1/repos", owner, repo, "git", "notes", sha) var out types.Note if err := s.client.Get(ctx, path, &out); err != nil { return nil, err diff --git a/contents.go b/contents.go index 8a6f48e..a9bbabc 100644 --- a/contents.go +++ b/contents.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "dappco.re/go/core/forge/types" ) @@ -19,7 +18,7 @@ func newContentService(c *Client) *ContentService { // GetFile returns metadata and content for a file in a repository. func (s *ContentService) GetFile(ctx context.Context, owner, repo, filepath string) (*types.ContentsResponse, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + path := escapePathSegments("/api/v1/repos", owner, repo, "contents", filepath) var out types.ContentsResponse if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -29,7 +28,7 @@ func (s *ContentService) GetFile(ctx context.Context, owner, repo, filepath stri // 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 := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + path := escapePathSegments("/api/v1/repos", owner, repo, "contents", filepath) var out types.FileResponse if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -39,7 +38,7 @@ func (s *ContentService) CreateFile(ctx context.Context, owner, repo, filepath s // UpdateFile updates an existing file in a repository. func (s *ContentService) UpdateFile(ctx context.Context, owner, repo, filepath string, opts *types.UpdateFileOptions) (*types.FileResponse, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + path := escapePathSegments("/api/v1/repos", owner, repo, "contents", filepath) var out types.FileResponse if err := s.client.Put(ctx, path, opts, &out); err != nil { return nil, err @@ -49,12 +48,12 @@ func (s *ContentService) UpdateFile(ctx context.Context, owner, repo, filepath s // DeleteFile deletes a file from a repository. Uses DELETE with a JSON body. func (s *ContentService) DeleteFile(ctx context.Context, owner, repo, filepath string, opts *types.DeleteFileOptions) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + path := escapePathSegments("/api/v1/repos", owner, repo, "contents", filepath) return s.client.DeleteWithBody(ctx, path, opts) } // GetRawFile returns the raw file content as bytes. func (s *ContentService) GetRawFile(ctx context.Context, owner, repo, filepath string) ([]byte, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/raw/%s", owner, repo, filepath) + path := escapePathSegments("/api/v1/repos", owner, repo, "raw", filepath) return s.client.GetRaw(ctx, path) } diff --git a/issues.go b/issues.go index a6ab01d..ac79c9b 100644 --- a/issues.go +++ b/issues.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" "iter" + "strconv" "dappco.re/go/core/forge/types" ) @@ -23,77 +23,77 @@ func newIssueService(c *Client) *IssueService { // Pin pins an issue. func (s *IssueService) Pin(ctx context.Context, owner, repo string, index int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "pin") return s.client.Post(ctx, path, nil, nil) } // Unpin unpins an issue. func (s *IssueService) Unpin(ctx context.Context, owner, repo string, index int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "pin") return s.client.Delete(ctx, path) } // SetDeadline sets or updates the deadline on an issue. func (s *IssueService) SetDeadline(ctx context.Context, owner, repo string, index int64, deadline string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/deadline", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "deadline") body := map[string]string{"due_date": deadline} return s.client.Post(ctx, path, body, nil) } // AddReaction adds a reaction to an issue. func (s *IssueService) AddReaction(ctx context.Context, owner, repo string, index int64, reaction string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "reactions") body := map[string]string{"content": reaction} return s.client.Post(ctx, path, body, nil) } // DeleteReaction removes a reaction from an issue. func (s *IssueService) DeleteReaction(ctx context.Context, owner, repo string, index int64, reaction string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "reactions") body := map[string]string{"content": reaction} return s.client.DeleteWithBody(ctx, path, body) } // StartStopwatch starts the stopwatch on an issue. func (s *IssueService) StartStopwatch(ctx context.Context, owner, repo string, index int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/stopwatch/start", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "stopwatch", "start") return s.client.Post(ctx, path, nil, nil) } // StopStopwatch stops the stopwatch on an issue. func (s *IssueService) StopStopwatch(ctx context.Context, owner, repo string, index int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/stopwatch/stop", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "stopwatch", "stop") return s.client.Post(ctx, path, nil, nil) } // AddLabels adds labels to an issue. func (s *IssueService) AddLabels(ctx context.Context, owner, repo string, index int64, labelIDs []int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "labels") body := types.IssueLabelsOption{Labels: toAnySlice(labelIDs)} return s.client.Post(ctx, path, body, nil) } // RemoveLabel removes a single label from an issue. func (s *IssueService) RemoveLabel(ctx context.Context, owner, repo string, index int64, labelID int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels/%d", owner, repo, index, labelID) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "labels", strconv.FormatInt(labelID, 10)) return s.client.Delete(ctx, path) } // ListComments returns all comments on an issue. func (s *IssueService) ListComments(ctx context.Context, owner, repo string, index int64) ([]types.Comment, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "comments") return ListAll[types.Comment](ctx, s.client, path, nil) } // 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 := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "comments") return ListIter[types.Comment](ctx, s.client, path, nil) } // 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 := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "issues", strconv.FormatInt(index, 10), "comments") opts := types.CreateIssueCommentOption{Body: body} var out types.Comment if err := s.client.Post(ctx, path, opts, &out); err != nil { diff --git a/labels.go b/labels.go index acb1146..1b5d34e 100644 --- a/labels.go +++ b/labels.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" "iter" + "strconv" "dappco.re/go/core/forge/types" ) @@ -20,19 +20,19 @@ func newLabelService(c *Client) *LabelService { // ListRepoLabels returns all labels for a repository. func (s *LabelService) ListRepoLabels(ctx context.Context, owner, repo string) ([]types.Label, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "labels") return ListAll[types.Label](ctx, s.client, path, nil) } // 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 := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "labels") return ListIter[types.Label](ctx, s.client, path, nil) } // GetRepoLabel returns a single label by ID. func (s *LabelService) GetRepoLabel(ctx context.Context, owner, repo string, id int64) (*types.Label, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) + path := escapePathSegments("/api/v1/repos", owner, repo, "labels", strconv.FormatInt(id, 10)) var out types.Label if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -42,7 +42,7 @@ func (s *LabelService) GetRepoLabel(ctx context.Context, owner, repo string, id // CreateRepoLabel creates a new label in a repository. func (s *LabelService) CreateRepoLabel(ctx context.Context, owner, repo string, opts *types.CreateLabelOption) (*types.Label, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "labels") var out types.Label if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -52,7 +52,7 @@ func (s *LabelService) CreateRepoLabel(ctx context.Context, owner, repo string, // EditRepoLabel updates an existing label in a repository. func (s *LabelService) EditRepoLabel(ctx context.Context, owner, repo string, id int64, opts *types.EditLabelOption) (*types.Label, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) + path := escapePathSegments("/api/v1/repos", owner, repo, "labels", strconv.FormatInt(id, 10)) var out types.Label if err := s.client.Patch(ctx, path, opts, &out); err != nil { return nil, err @@ -62,25 +62,25 @@ func (s *LabelService) EditRepoLabel(ctx context.Context, owner, repo string, id // DeleteRepoLabel deletes a label from a repository. func (s *LabelService) DeleteRepoLabel(ctx context.Context, owner, repo string, id int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) + path := escapePathSegments("/api/v1/repos", owner, repo, "labels", strconv.FormatInt(id, 10)) return s.client.Delete(ctx, path) } // ListOrgLabels returns all labels for an organisation. func (s *LabelService) ListOrgLabels(ctx context.Context, org string) ([]types.Label, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/labels", org) + path := escapePathSegments("/api/v1/orgs", org, "labels") return ListAll[types.Label](ctx, s.client, path, nil) } // IterOrgLabels returns an iterator over all labels for an organisation. func (s *LabelService) IterOrgLabels(ctx context.Context, org string) iter.Seq2[types.Label, error] { - path := fmt.Sprintf("/api/v1/orgs/%s/labels", org) + path := escapePathSegments("/api/v1/orgs", org, "labels") return ListIter[types.Label](ctx, s.client, path, nil) } // CreateOrgLabel creates a new label in an organisation. func (s *LabelService) CreateOrgLabel(ctx context.Context, org string, opts *types.CreateLabelOption) (*types.Label, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/labels", org) + path := escapePathSegments("/api/v1/orgs", org, "labels") var out types.Label if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err diff --git a/misc.go b/misc.go index b05526a..6ba4640 100644 --- a/misc.go +++ b/misc.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "dappco.re/go/core/forge/types" ) @@ -41,7 +40,7 @@ func (s *MiscService) ListLicenses(ctx context.Context) ([]types.LicensesTemplat // GetLicense returns a single licence template by name. func (s *MiscService) GetLicense(ctx context.Context, name string) (*types.LicenseTemplateInfo, error) { - path := fmt.Sprintf("/api/v1/licenses/%s", name) + path := escapePathSegments("/api/v1/licenses", name) var out types.LicenseTemplateInfo if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -60,7 +59,7 @@ func (s *MiscService) ListGitignoreTemplates(ctx context.Context) ([]string, err // GetGitignoreTemplate returns a single gitignore template by name. func (s *MiscService) GetGitignoreTemplate(ctx context.Context, name string) (*types.GitignoreTemplateInfo, error) { - path := fmt.Sprintf("/api/v1/gitignore/templates/%s", name) + path := escapePathSegments("/api/v1/gitignore/templates", name) var out types.GitignoreTemplateInfo if err := s.client.Get(ctx, path, &out); err != nil { return nil, err diff --git a/notifications.go b/notifications.go index e3b8af2..615cb6c 100644 --- a/notifications.go +++ b/notifications.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" "iter" + "strconv" "dappco.re/go/core/forge/types" ) @@ -30,13 +30,13 @@ func (s *NotificationService) Iter(ctx context.Context) iter.Seq2[types.Notifica // ListRepo returns all notifications for a specific repository. func (s *NotificationService) ListRepo(ctx context.Context, owner, repo string) ([]types.NotificationThread, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/notifications", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "notifications") return ListAll[types.NotificationThread](ctx, s.client, path, nil) } // IterRepo returns an iterator over all notifications for a specific repository. func (s *NotificationService) IterRepo(ctx context.Context, owner, repo string) iter.Seq2[types.NotificationThread, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/notifications", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "notifications") return ListIter[types.NotificationThread](ctx, s.client, path, nil) } @@ -47,7 +47,7 @@ func (s *NotificationService) MarkRead(ctx context.Context) error { // GetThread returns a single notification thread by ID. func (s *NotificationService) GetThread(ctx context.Context, id int64) (*types.NotificationThread, error) { - path := fmt.Sprintf("/api/v1/notifications/threads/%d", id) + path := escapePathSegments("/api/v1/notifications/threads", strconv.FormatInt(id, 10)) var out types.NotificationThread if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -57,6 +57,6 @@ func (s *NotificationService) GetThread(ctx context.Context, id int64) (*types.N // MarkThreadRead marks a single notification thread as read. func (s *NotificationService) MarkThreadRead(ctx context.Context, id int64) error { - path := fmt.Sprintf("/api/v1/notifications/threads/%d", id) + path := escapePathSegments("/api/v1/notifications/threads", strconv.FormatInt(id, 10)) return s.client.Patch(ctx, path, nil, nil) } diff --git a/orgs.go b/orgs.go index 36d4dc2..0b90144 100644 --- a/orgs.go +++ b/orgs.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" @@ -23,37 +22,37 @@ func newOrgService(c *Client) *OrgService { // ListMembers returns all members of an organisation. func (s *OrgService) ListMembers(ctx context.Context, org string) ([]types.User, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/members", org) + path := escapePathSegments("/api/v1/orgs", org, "members") return ListAll[types.User](ctx, s.client, path, nil) } // 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 := fmt.Sprintf("/api/v1/orgs/%s/members", org) + path := escapePathSegments("/api/v1/orgs", org, "members") return ListIter[types.User](ctx, s.client, path, nil) } // AddMember adds a user to an organisation. func (s *OrgService) AddMember(ctx context.Context, org, username string) error { - path := fmt.Sprintf("/api/v1/orgs/%s/members/%s", org, username) + path := escapePathSegments("/api/v1/orgs", org, "members", username) return s.client.Put(ctx, path, nil, nil) } // RemoveMember removes a user from an organisation. func (s *OrgService) RemoveMember(ctx context.Context, org, username string) error { - path := fmt.Sprintf("/api/v1/orgs/%s/members/%s", org, username) + path := escapePathSegments("/api/v1/orgs", org, "members", username) return s.client.Delete(ctx, path) } // ListUserOrgs returns all organisations for a user. func (s *OrgService) ListUserOrgs(ctx context.Context, username string) ([]types.Organization, error) { - path := fmt.Sprintf("/api/v1/users/%s/orgs", username) + path := escapePathSegments("/api/v1/users", username, "orgs") return ListAll[types.Organization](ctx, s.client, path, nil) } // IterUserOrgs returns an iterator over all organisations for a user. func (s *OrgService) IterUserOrgs(ctx context.Context, username string) iter.Seq2[types.Organization, error] { - path := fmt.Sprintf("/api/v1/users/%s/orgs", username) + path := escapePathSegments("/api/v1/users", username, "orgs") return ListIter[types.Organization](ctx, s.client, path, nil) } diff --git a/packages.go b/packages.go index ee724ea..646f4b1 100644 --- a/packages.go +++ b/packages.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" @@ -20,19 +19,19 @@ func newPackageService(c *Client) *PackageService { // List returns all packages for a given owner. func (s *PackageService) List(ctx context.Context, owner string) ([]types.Package, error) { - path := fmt.Sprintf("/api/v1/packages/%s", owner) + path := escapePathSegments("/api/v1/packages", owner) return ListAll[types.Package](ctx, s.client, path, nil) } // Iter returns an iterator over all packages for a given owner. func (s *PackageService) Iter(ctx context.Context, owner string) iter.Seq2[types.Package, error] { - path := fmt.Sprintf("/api/v1/packages/%s", owner) + path := escapePathSegments("/api/v1/packages", owner) return ListIter[types.Package](ctx, s.client, path, nil) } // Get returns a single package by owner, type, name, and version. func (s *PackageService) Get(ctx context.Context, owner, pkgType, name, version string) (*types.Package, error) { - path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s", owner, pkgType, name, version) + path := escapePathSegments("/api/v1/packages", owner, pkgType, name, version) var out types.Package if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -42,18 +41,18 @@ func (s *PackageService) Get(ctx context.Context, owner, pkgType, name, version // Delete removes a package by owner, type, name, and version. func (s *PackageService) Delete(ctx context.Context, owner, pkgType, name, version string) error { - path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s", owner, pkgType, name, version) + path := escapePathSegments("/api/v1/packages", owner, pkgType, name, version) return s.client.Delete(ctx, path) } // ListFiles returns all files for a specific package version. func (s *PackageService) ListFiles(ctx context.Context, owner, pkgType, name, version string) ([]types.PackageFile, error) { - path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s/files", owner, pkgType, name, version) + path := escapePathSegments("/api/v1/packages", owner, pkgType, name, version, "files") return ListAll[types.PackageFile](ctx, s.client, path, nil) } // IterFiles returns an iterator over all files for a specific package version. func (s *PackageService) IterFiles(ctx context.Context, owner, pkgType, name, version string) iter.Seq2[types.PackageFile, error] { - path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s/files", owner, pkgType, name, version) + path := escapePathSegments("/api/v1/packages", owner, pkgType, name, version, "files") return ListIter[types.PackageFile](ctx, s.client, path, nil) } diff --git a/path_escape.go b/path_escape.go new file mode 100644 index 0000000..f06ac0d --- /dev/null +++ b/path_escape.go @@ -0,0 +1,22 @@ +package forge + +import ( + "net/url" + "strings" +) + +// escapePathSegments joins path segments while escaping each segment. +func escapePathSegments(base string, segments ...string) string { + if len(segments) == 0 { + return base + } + + var b strings.Builder + b.Grow(len(base) + len(segments)*4) + b.WriteString(base) + for _, segment := range segments { + b.WriteByte('/') + b.WriteString(url.PathEscape(segment)) + } + return b.String() +} diff --git a/path_escape_test.go b/path_escape_test.go new file mode 100644 index 0000000..a8de45f --- /dev/null +++ b/path_escape_test.go @@ -0,0 +1,257 @@ +package forge + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestServicePathEscaping_Good(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + method string + wantPath string + status int + headers map[string]string + body string + call func(context.Context, *Forge) error + }{ + { + name: "contents raw file", + method: http.MethodGet, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/raw/docs%2Fread%20me.md", + body: "raw data", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Contents.GetRawFile(ctx, "core/team", "go forge", "docs/read me.md") + return err + }, + }, + { + name: "wiki delete page", + method: http.MethodDelete, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/wiki/page/Runbook%2FIntro", + call: func(ctx context.Context, f *Forge) error { + return f.Wiki.DeletePage(ctx, "core/team", "go forge", "Runbook/Intro") + }, + }, + { + name: "actions delete repo secret", + method: http.MethodDelete, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/actions/secrets/DEPLOY%2FKEY", + call: func(ctx context.Context, f *Forge) error { + return f.Actions.DeleteRepoSecret(ctx, "core/team", "go forge", "DEPLOY/KEY") + }, + }, + { + name: "actions list org secrets", + method: http.MethodGet, + wantPath: "/api/v1/orgs/ops%2Fsec/actions/secrets", + headers: map[string]string{"X-Total-Count": "0"}, + body: "[]", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Actions.ListOrgSecrets(ctx, "ops/sec") + return err + }, + }, + { + name: "actions dispatch workflow", + method: http.MethodPost, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/actions/workflows/build%2Frelease.yml/dispatches", + call: func(ctx context.Context, f *Forge) error { + return f.Actions.DispatchWorkflow(ctx, "core/team", "go forge", "build/release.yml", map[string]any{"ref": "main"}) + }, + }, + { + name: "branches delete protection", + method: http.MethodDelete, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/branch_protections/main%2Frelease", + call: func(ctx context.Context, f *Forge) error { + return f.Branches.DeleteBranchProtection(ctx, "core/team", "go forge", "main/release") + }, + }, + { + name: "commits list statuses", + method: http.MethodGet, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/commits/feature%2Fone/statuses", + body: "[]", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Commits.ListStatuses(ctx, "core/team", "go forge", "feature/one") + return err + }, + }, + { + name: "issues remove label", + method: http.MethodDelete, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/issues/42/labels/7", + call: func(ctx context.Context, f *Forge) error { + return f.Issues.RemoveLabel(ctx, "core/team", "go forge", 42, 7) + }, + }, + { + name: "labels list org labels", + method: http.MethodGet, + wantPath: "/api/v1/orgs/ops%2Fsec/labels", + headers: map[string]string{"X-Total-Count": "0"}, + body: "[]", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Labels.ListOrgLabels(ctx, "ops/sec") + return err + }, + }, + { + name: "misc get license", + method: http.MethodGet, + wantPath: "/api/v1/licenses/Apache%202.0%2Fcustom", + body: "{}", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Misc.GetLicense(ctx, "Apache 2.0/custom") + return err + }, + }, + { + name: "misc get gitignore template", + method: http.MethodGet, + wantPath: "/api/v1/gitignore/templates/Go%2FTooling", + body: "{}", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Misc.GetGitignoreTemplate(ctx, "Go/Tooling") + return err + }, + }, + { + name: "notifications list repo", + method: http.MethodGet, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/notifications", + headers: map[string]string{"X-Total-Count": "0"}, + body: "[]", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Notifications.ListRepo(ctx, "core/team", "go forge") + return err + }, + }, + { + name: "orgs add member", + method: http.MethodPut, + wantPath: "/api/v1/orgs/ops%2Fsec/members/alice%2Fbob", + call: func(ctx context.Context, f *Forge) error { + return f.Orgs.AddMember(ctx, "ops/sec", "alice/bob") + }, + }, + { + name: "orgs list user orgs", + method: http.MethodGet, + wantPath: "/api/v1/users/alice%2Fbob/orgs", + headers: map[string]string{"X-Total-Count": "0"}, + body: "[]", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Orgs.ListUserOrgs(ctx, "alice/bob") + return err + }, + }, + { + name: "packages get", + method: http.MethodGet, + wantPath: "/api/v1/packages/ops%2Fsec/container%2Fv2/app%2Fservice/v1.0%2Fbeta", + body: "{}", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Packages.Get(ctx, "ops/sec", "container/v2", "app/service", "v1.0/beta") + return err + }, + }, + { + name: "pulls undismiss review", + method: http.MethodPost, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/pulls/42/reviews/9/undismissals", + call: func(ctx context.Context, f *Forge) error { + return f.Pulls.UndismissReview(ctx, "core/team", "go forge", 42, 9) + }, + }, + { + name: "releases delete by tag", + method: http.MethodDelete, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/releases/tags/v1.0%2Frelease", + call: func(ctx context.Context, f *Forge) error { + return f.Releases.DeleteByTag(ctx, "core/team", "go forge", "v1.0/release") + }, + }, + { + name: "repos list org repos", + method: http.MethodGet, + wantPath: "/api/v1/orgs/ops%2Fsec/repos", + headers: map[string]string{"X-Total-Count": "0"}, + body: "[]", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Repos.ListOrgRepos(ctx, "ops/sec") + return err + }, + }, + { + name: "repos mirror sync", + method: http.MethodPost, + wantPath: "/api/v1/repos/core%2Fteam/go%20forge/mirror-sync", + call: func(ctx context.Context, f *Forge) error { + return f.Repos.MirrorSync(ctx, "core/team", "go forge") + }, + }, + { + name: "users star repo", + method: http.MethodPut, + wantPath: "/api/v1/user/starred/core%2Fteam/go%20forge", + call: func(ctx context.Context, f *Forge) error { + return f.Users.Star(ctx, "core/team", "go forge") + }, + }, + { + name: "teams add repo", + method: http.MethodPut, + wantPath: "/api/v1/teams/42/repos/ops%2Fsec/go%20forge", + call: func(ctx context.Context, f *Forge) error { + return f.Teams.AddRepo(ctx, 42, "ops/sec", "go forge") + }, + }, + { + name: "webhooks list org hooks", + method: http.MethodGet, + wantPath: "/api/v1/orgs/ops%2Fsec/hooks", + headers: map[string]string{"X-Total-Count": "0"}, + body: "[]", + call: func(ctx context.Context, f *Forge) error { + _, err := f.Webhooks.ListOrgHooks(ctx, "ops/sec") + return err + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != tc.method { + t.Errorf("expected %s, got %s", tc.method, r.Method) + } + if r.URL.EscapedPath() != tc.wantPath { + t.Errorf("wrong path: %s", r.URL.EscapedPath()) + } + for k, v := range tc.headers { + w.Header().Set(k, v) + } + if tc.status != 0 { + w.WriteHeader(tc.status) + } + if tc.body != "" { + if _, err := w.Write([]byte(tc.body)); err != nil { + t.Fatalf("write response: %v", err) + } + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := tc.call(ctx, f); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/pulls.go b/pulls.go index 408f438..32e9872 100644 --- a/pulls.go +++ b/pulls.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" "iter" + "strconv" "dappco.re/go/core/forge/types" ) @@ -23,32 +23,32 @@ func newPullService(c *Client) *PullService { // 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 { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "pulls", strconv.FormatInt(index, 10), "merge") body := map[string]string{"Do": method} return s.client.Post(ctx, path, body, nil) } // Update updates a pull request branch with the base branch. func (s *PullService) Update(ctx context.Context, owner, repo string, index int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/update", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "pulls", strconv.FormatInt(index, 10), "update") return s.client.Post(ctx, path, nil, nil) } // ListReviews returns all reviews on a pull request. func (s *PullService) ListReviews(ctx context.Context, owner, repo string, index int64) ([]types.PullReview, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "pulls", strconv.FormatInt(index, 10), "reviews") return ListAll[types.PullReview](ctx, s.client, path, nil) } // 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 := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "pulls", strconv.FormatInt(index, 10), "reviews") return ListIter[types.PullReview](ctx, s.client, path, nil) } // SubmitReview creates a new review on a pull request. func (s *PullService) SubmitReview(ctx context.Context, owner, repo string, index int64, review map[string]any) (*types.PullReview, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, index) + path := escapePathSegments("/api/v1/repos", owner, repo, "pulls", strconv.FormatInt(index, 10), "reviews") var out types.PullReview if err := s.client.Post(ctx, path, review, &out); err != nil { return nil, err @@ -58,13 +58,31 @@ func (s *PullService) SubmitReview(ctx context.Context, owner, repo string, inde // DismissReview dismisses a pull request review. func (s *PullService) DismissReview(ctx context.Context, owner, repo string, index, reviewID int64, msg string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/dismissals", owner, repo, index, reviewID) + path := escapePathSegments( + "/api/v1/repos", + owner, + repo, + "pulls", + strconv.FormatInt(index, 10), + "reviews", + strconv.FormatInt(reviewID, 10), + "dismissals", + ) body := map[string]string{"message": msg} return s.client.Post(ctx, path, body, nil) } // UndismissReview undismisses a pull request review. func (s *PullService) UndismissReview(ctx context.Context, owner, repo string, index, reviewID int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/undismissals", owner, repo, index, reviewID) + path := escapePathSegments( + "/api/v1/repos", + owner, + repo, + "pulls", + strconv.FormatInt(index, 10), + "reviews", + strconv.FormatInt(reviewID, 10), + "undismissals", + ) return s.client.Post(ctx, path, nil, nil) } diff --git a/releases.go b/releases.go index ae32d49..e418505 100644 --- a/releases.go +++ b/releases.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" "iter" + "strconv" "dappco.re/go/core/forge/types" ) @@ -23,7 +23,7 @@ func newReleaseService(c *Client) *ReleaseService { // GetByTag returns a release by its tag name. func (s *ReleaseService) GetByTag(ctx context.Context, owner, repo, tag string) (*types.Release, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner, repo, tag) + path := escapePathSegments("/api/v1/repos", owner, repo, "releases", "tags", tag) var out types.Release if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -33,25 +33,33 @@ func (s *ReleaseService) GetByTag(ctx context.Context, owner, repo, tag string) // DeleteByTag deletes a release by its tag name. func (s *ReleaseService) DeleteByTag(ctx context.Context, owner, repo, tag string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner, repo, tag) + path := escapePathSegments("/api/v1/repos", owner, repo, "releases", "tags", tag) return s.client.Delete(ctx, path) } // ListAssets returns all assets for a release. func (s *ReleaseService) ListAssets(ctx context.Context, owner, repo string, releaseID int64) ([]types.Attachment, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner, repo, releaseID) + path := escapePathSegments("/api/v1/repos", owner, repo, "releases", strconv.FormatInt(releaseID, 10), "assets") return ListAll[types.Attachment](ctx, s.client, path, nil) } // IterAssets returns an iterator over all assets for a release. func (s *ReleaseService) IterAssets(ctx context.Context, owner, repo string, releaseID int64) iter.Seq2[types.Attachment, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner, repo, releaseID) + path := escapePathSegments("/api/v1/repos", owner, repo, "releases", strconv.FormatInt(releaseID, 10), "assets") return ListIter[types.Attachment](ctx, s.client, path, nil) } // GetAsset returns a single asset for a release. func (s *ReleaseService) GetAsset(ctx context.Context, owner, repo string, releaseID, assetID int64) (*types.Attachment, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets/%d", owner, repo, releaseID, assetID) + path := escapePathSegments( + "/api/v1/repos", + owner, + repo, + "releases", + strconv.FormatInt(releaseID, 10), + "assets", + strconv.FormatInt(assetID, 10), + ) var out types.Attachment if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -61,6 +69,14 @@ func (s *ReleaseService) GetAsset(ctx context.Context, owner, repo string, relea // DeleteAsset deletes a single asset from a release. func (s *ReleaseService) DeleteAsset(ctx context.Context, owner, repo string, releaseID, assetID int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets/%d", owner, repo, releaseID, assetID) + path := escapePathSegments( + "/api/v1/repos", + owner, + repo, + "releases", + strconv.FormatInt(releaseID, 10), + "assets", + strconv.FormatInt(assetID, 10), + ) return s.client.Delete(ctx, path) } diff --git a/repos.go b/repos.go index 13a9f0b..ec66af5 100644 --- a/repos.go +++ b/repos.go @@ -22,12 +22,12 @@ func newRepoService(c *Client) *RepoService { // ListOrgRepos returns all repositories for an organisation. func (s *RepoService) ListOrgRepos(ctx context.Context, org string) ([]types.Repository, error) { - return ListAll[types.Repository](ctx, s.client, "/api/v1/orgs/"+org+"/repos", nil) + return ListAll[types.Repository](ctx, s.client, escapePathSegments("/api/v1/orgs", org, "repos"), nil) } // IterOrgRepos returns an iterator over all repositories for an organisation. func (s *RepoService) IterOrgRepos(ctx context.Context, org string) iter.Seq2[types.Repository, error] { - return ListIter[types.Repository](ctx, s.client, "/api/v1/orgs/"+org+"/repos", nil) + return ListIter[types.Repository](ctx, s.client, escapePathSegments("/api/v1/orgs", org, "repos"), nil) } // ListUserRepos returns all repositories for the authenticated user. @@ -47,7 +47,7 @@ func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types body["organization"] = org } var out types.Repository - err := s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/forks", body, &out) + err := s.client.Post(ctx, escapePathSegments("/api/v1/repos", owner, repo, "forks"), body, &out) if err != nil { return nil, err } @@ -56,20 +56,20 @@ func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types // Transfer initiates a repository transfer. func (s *RepoService) Transfer(ctx context.Context, owner, repo string, opts map[string]any) error { - return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/transfer", opts, nil) + return s.client.Post(ctx, escapePathSegments("/api/v1/repos", owner, repo, "transfer"), opts, nil) } // AcceptTransfer accepts a pending repository transfer. func (s *RepoService) AcceptTransfer(ctx context.Context, owner, repo string) error { - return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/transfer/accept", nil, nil) + return s.client.Post(ctx, escapePathSegments("/api/v1/repos", owner, repo, "transfer", "accept"), nil, nil) } // RejectTransfer rejects a pending repository transfer. func (s *RepoService) RejectTransfer(ctx context.Context, owner, repo string) error { - return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/transfer/reject", nil, nil) + return s.client.Post(ctx, escapePathSegments("/api/v1/repos", owner, repo, "transfer", "reject"), nil, nil) } // MirrorSync triggers a mirror sync. func (s *RepoService) MirrorSync(ctx context.Context, owner, repo string) error { - return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/mirror-sync", nil, nil) + return s.client.Post(ctx, escapePathSegments("/api/v1/repos", owner, repo, "mirror-sync"), nil, nil) } diff --git a/teams.go b/teams.go index 32470ec..c5ebbdd 100644 --- a/teams.go +++ b/teams.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" "iter" + "strconv" "dappco.re/go/core/forge/types" ) @@ -23,60 +23,60 @@ func newTeamService(c *Client) *TeamService { // ListMembers returns all members of a team. func (s *TeamService) ListMembers(ctx context.Context, teamID int64) ([]types.User, error) { - path := fmt.Sprintf("/api/v1/teams/%d/members", teamID) + path := escapePathSegments("/api/v1/teams", strconv.FormatInt(teamID, 10), "members") return ListAll[types.User](ctx, s.client, path, nil) } // IterMembers returns an iterator over all members of a team. func (s *TeamService) IterMembers(ctx context.Context, teamID int64) iter.Seq2[types.User, error] { - path := fmt.Sprintf("/api/v1/teams/%d/members", teamID) + path := escapePathSegments("/api/v1/teams", strconv.FormatInt(teamID, 10), "members") return ListIter[types.User](ctx, s.client, path, nil) } // AddMember adds a user to a team. func (s *TeamService) AddMember(ctx context.Context, teamID int64, username string) error { - path := fmt.Sprintf("/api/v1/teams/%d/members/%s", teamID, username) + path := escapePathSegments("/api/v1/teams", strconv.FormatInt(teamID, 10), "members", username) return s.client.Put(ctx, path, nil, nil) } // RemoveMember removes a user from a team. func (s *TeamService) RemoveMember(ctx context.Context, teamID int64, username string) error { - path := fmt.Sprintf("/api/v1/teams/%d/members/%s", teamID, username) + path := escapePathSegments("/api/v1/teams", strconv.FormatInt(teamID, 10), "members", username) return s.client.Delete(ctx, path) } // ListRepos returns all repositories managed by a team. func (s *TeamService) ListRepos(ctx context.Context, teamID int64) ([]types.Repository, error) { - path := fmt.Sprintf("/api/v1/teams/%d/repos", teamID) + path := escapePathSegments("/api/v1/teams", strconv.FormatInt(teamID, 10), "repos") return ListAll[types.Repository](ctx, s.client, path, nil) } // IterRepos returns an iterator over all repositories managed by a team. func (s *TeamService) IterRepos(ctx context.Context, teamID int64) iter.Seq2[types.Repository, error] { - path := fmt.Sprintf("/api/v1/teams/%d/repos", teamID) + path := escapePathSegments("/api/v1/teams", strconv.FormatInt(teamID, 10), "repos") return ListIter[types.Repository](ctx, s.client, path, nil) } // AddRepo adds a repository to a team. func (s *TeamService) AddRepo(ctx context.Context, teamID int64, org, repo string) error { - path := fmt.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, org, repo) + path := escapePathSegments("/api/v1/teams", strconv.FormatInt(teamID, 10), "repos", org, repo) return s.client.Put(ctx, path, nil, nil) } // RemoveRepo removes a repository from a team. func (s *TeamService) RemoveRepo(ctx context.Context, teamID int64, org, repo string) error { - path := fmt.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, org, repo) + path := escapePathSegments("/api/v1/teams", strconv.FormatInt(teamID, 10), "repos", org, repo) return s.client.Delete(ctx, path) } // ListOrgTeams returns all teams in an organisation. func (s *TeamService) ListOrgTeams(ctx context.Context, org string) ([]types.Team, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/teams", org) + path := escapePathSegments("/api/v1/orgs", org, "teams") return ListAll[types.Team](ctx, s.client, path, nil) } // IterOrgTeams returns an iterator over all teams in an organisation. func (s *TeamService) IterOrgTeams(ctx context.Context, org string) iter.Seq2[types.Team, error] { - path := fmt.Sprintf("/api/v1/orgs/%s/teams", org) + path := escapePathSegments("/api/v1/orgs", org, "teams") return ListIter[types.Team](ctx, s.client, path, nil) } diff --git a/users.go b/users.go index 2aba489..dc5214f 100644 --- a/users.go +++ b/users.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" @@ -32,60 +31,60 @@ func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error) { // ListFollowers returns all followers of a user. func (s *UserService) ListFollowers(ctx context.Context, username string) ([]types.User, error) { - path := fmt.Sprintf("/api/v1/users/%s/followers", username) + path := escapePathSegments("/api/v1/users", username, "followers") return ListAll[types.User](ctx, s.client, path, nil) } // IterFollowers returns an iterator over all followers of a user. func (s *UserService) IterFollowers(ctx context.Context, username string) iter.Seq2[types.User, error] { - path := fmt.Sprintf("/api/v1/users/%s/followers", username) + path := escapePathSegments("/api/v1/users", username, "followers") return ListIter[types.User](ctx, s.client, path, nil) } // ListFollowing returns all users that a user is following. func (s *UserService) ListFollowing(ctx context.Context, username string) ([]types.User, error) { - path := fmt.Sprintf("/api/v1/users/%s/following", username) + path := escapePathSegments("/api/v1/users", username, "following") return ListAll[types.User](ctx, s.client, path, nil) } // IterFollowing returns an iterator over all users that a user is following. func (s *UserService) IterFollowing(ctx context.Context, username string) iter.Seq2[types.User, error] { - path := fmt.Sprintf("/api/v1/users/%s/following", username) + path := escapePathSegments("/api/v1/users", username, "following") return ListIter[types.User](ctx, s.client, path, nil) } // Follow follows a user as the authenticated user. func (s *UserService) Follow(ctx context.Context, username string) error { - path := fmt.Sprintf("/api/v1/user/following/%s", username) + path := escapePathSegments("/api/v1/user/following", username) return s.client.Put(ctx, path, nil, nil) } // Unfollow unfollows a user as the authenticated user. func (s *UserService) Unfollow(ctx context.Context, username string) error { - path := fmt.Sprintf("/api/v1/user/following/%s", username) + path := escapePathSegments("/api/v1/user/following", username) return s.client.Delete(ctx, path) } // ListStarred returns all repositories starred by a user. func (s *UserService) ListStarred(ctx context.Context, username string) ([]types.Repository, error) { - path := fmt.Sprintf("/api/v1/users/%s/starred", username) + path := escapePathSegments("/api/v1/users", username, "starred") return ListAll[types.Repository](ctx, s.client, path, nil) } // IterStarred returns an iterator over all repositories starred by a user. func (s *UserService) IterStarred(ctx context.Context, username string) iter.Seq2[types.Repository, error] { - path := fmt.Sprintf("/api/v1/users/%s/starred", username) + path := escapePathSegments("/api/v1/users", username, "starred") return ListIter[types.Repository](ctx, s.client, path, nil) } // Star stars a repository as the authenticated user. func (s *UserService) Star(ctx context.Context, owner, repo string) error { - path := fmt.Sprintf("/api/v1/user/starred/%s/%s", owner, repo) + path := escapePathSegments("/api/v1/user/starred", owner, repo) return s.client.Put(ctx, path, nil, nil) } // Unstar unstars a repository as the authenticated user. func (s *UserService) Unstar(ctx context.Context, owner, repo string) error { - path := fmt.Sprintf("/api/v1/user/starred/%s/%s", owner, repo) + path := escapePathSegments("/api/v1/user/starred", owner, repo) return s.client.Delete(ctx, path) } diff --git a/webhooks.go b/webhooks.go index b814b28..22b14ac 100644 --- a/webhooks.go +++ b/webhooks.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" "iter" + "strconv" "dappco.re/go/core/forge/types" ) @@ -24,18 +24,18 @@ func newWebhookService(c *Client) *WebhookService { // TestHook triggers a test delivery for a webhook. func (s *WebhookService) TestHook(ctx context.Context, owner, repo string, id int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d/tests", owner, repo, id) + path := escapePathSegments("/api/v1/repos", owner, repo, "hooks", strconv.FormatInt(id, 10), "tests") return s.client.Post(ctx, path, nil, nil) } // ListOrgHooks returns all webhooks for an organisation. func (s *WebhookService) ListOrgHooks(ctx context.Context, org string) ([]types.Hook, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/hooks", org) + path := escapePathSegments("/api/v1/orgs", org, "hooks") return ListAll[types.Hook](ctx, s.client, path, nil) } // 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 := fmt.Sprintf("/api/v1/orgs/%s/hooks", org) + path := escapePathSegments("/api/v1/orgs", org, "hooks") return ListIter[types.Hook](ctx, s.client, path, nil) } diff --git a/wiki.go b/wiki.go index 898c5ca..120e83d 100644 --- a/wiki.go +++ b/wiki.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "dappco.re/go/core/forge/types" ) @@ -19,7 +18,7 @@ func newWikiService(c *Client) *WikiService { // ListPages returns all wiki page metadata for a repository. func (s *WikiService) ListPages(ctx context.Context, owner, repo string) ([]types.WikiPageMetaData, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "wiki", "pages") var out []types.WikiPageMetaData if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -29,7 +28,7 @@ func (s *WikiService) ListPages(ctx context.Context, owner, repo string) ([]type // GetPage returns a single wiki page by name. func (s *WikiService) GetPage(ctx context.Context, owner, repo, pageName string) (*types.WikiPage, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) + path := escapePathSegments("/api/v1/repos", owner, repo, "wiki", "page", pageName) var out types.WikiPage if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -39,7 +38,7 @@ func (s *WikiService) GetPage(ctx context.Context, owner, repo, pageName string) // CreatePage creates a new wiki page. func (s *WikiService) CreatePage(ctx context.Context, owner, repo string, opts *types.CreateWikiPageOptions) (*types.WikiPage, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/new", owner, repo) + path := escapePathSegments("/api/v1/repos", owner, repo, "wiki", "new") var out types.WikiPage if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -49,7 +48,7 @@ func (s *WikiService) CreatePage(ctx context.Context, owner, repo string, opts * // EditPage updates an existing wiki page. func (s *WikiService) EditPage(ctx context.Context, owner, repo, pageName string, opts *types.CreateWikiPageOptions) (*types.WikiPage, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) + path := escapePathSegments("/api/v1/repos", owner, repo, "wiki", "page", pageName) var out types.WikiPage if err := s.client.Patch(ctx, path, opts, &out); err != nil { return nil, err @@ -59,6 +58,6 @@ func (s *WikiService) EditPage(ctx context.Context, owner, repo, pageName string // DeletePage removes a wiki page. func (s *WikiService) DeletePage(ctx context.Context, owner, repo, pageName string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) + path := escapePathSegments("/api/v1/repos", owner, repo, "wiki", "page", pageName) return s.client.Delete(ctx, path) }