package api import ( "context" "net/http" "net/url" "strings" ) // seg escapes a single path segment. func seg(s string) string { return url.PathEscape(s) } // pathSegs escapes a possibly-multi-segment path, preserving slashes. func pathSegs(p string) string { p = strings.Trim(p, "/") if p == "" { return "" } parts := strings.Split(p, "/") for i, part := range parts { parts[i] = url.PathEscape(part) } return strings.Join(parts, "/") } // ---- identity ---- // GetUser returns the identity that owns the presented PAT. func (c *Client) GetUser(ctx context.Context) (*User, error) { var u User err := c.do(ctx, Request{Method: http.MethodGet, Path: "/user"}, &u) return &u, err } // ---- repositories ---- // CreateRepo creates a repository. func (c *Client) CreateRepo(ctx context.Context, in RepoCreate) (*Repo, error) { var r Repo err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos", Body: in}, &r) return &r, err } // GetRepo fetches a repository. func (c *Client) GetRepo(ctx context.Context, owner, repo string) (*Repo, error) { var r Repo err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo)}, &r) return &r, err } // UpdateRepo patches a repository. func (c *Client) UpdateRepo(ctx context.Context, owner, repo string, in RepoUpdate) (*Repo, error) { var r Repo err := c.do(ctx, Request{Method: http.MethodPatch, Path: "/repos/" + seg(owner) + "/" + seg(repo), Body: in}, &r) return &r, err } // DeleteRepo deletes a repository. func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error { return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo)}, nil) } // ListUserRepos lists a user's repos visible to the caller. func (c *Client) ListUserRepos(ctx context.Context, handle string, page, perPage int) (*RepoPage, error) { var p RepoPage err := c.do(ctx, Request{Method: http.MethodGet, Path: "/users/" + seg(handle) + "/repos", Query: pageQuery(page, perPage, nil)}, &p) return &p, err } // ListOrgRepos lists an org's repos visible to the caller. func (c *Client) ListOrgRepos(ctx context.Context, handle string, page, perPage int) (*RepoPage, error) { var p RepoPage err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle) + "/repos", Query: pageQuery(page, perPage, nil)}, &p) return &p, err } // SearchRepos searches repositories. func (c *Client) SearchRepos(ctx context.Context, q string, page, perPage int) (*RepoPage, error) { extra := url.Values{} if q != "" { extra.Set("q", q) } var p RepoPage err := c.do(ctx, Request{Method: http.MethodGet, Path: "/search/repos", Query: pageQuery(page, perPage, extra)}, &p) return &p, err } // ---- collaborators ---- // ListCollaborators lists repo collaborators (admin only). func (c *Client) ListCollaborators(ctx context.Context, owner, repo string) ([]Collaborator, error) { var cs []Collaborator err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/collaborators"}, &cs) return cs, err } // PutCollaborator adds or updates a collaborator. func (c *Client) PutCollaborator(ctx context.Context, owner, repo, user, permission string) (*Collaborator, error) { body := map[string]string{} if permission != "" { body["permission"] = permission } var col Collaborator err := c.do(ctx, Request{Method: http.MethodPut, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/collaborators/" + seg(user), Body: body}, &col) return &col, err } // DeleteCollaborator removes a collaborator. func (c *Client) DeleteCollaborator(ctx context.Context, owner, repo, user string) error { return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/collaborators/" + seg(user)}, nil) } // ---- merge requests ---- // ListMergeRequests lists merge requests. func (c *Client) ListMergeRequests(ctx context.Context, owner, repo, state string, page, perPage int) (*MergeRequestPage, error) { extra := url.Values{} if state != "" { extra.Set("state", state) } var p MergeRequestPage err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests", Query: pageQuery(page, perPage, extra)}, &p) return &p, err } // CreateMergeRequest opens a merge request. func (c *Client) CreateMergeRequest(ctx context.Context, owner, repo string, in MergeRequestCreate) (*MergeRequest, error) { var mr MergeRequest err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests", Body: in}, &mr) return &mr, err } // GetMergeRequest fetches a merge request with its detail. func (c *Client) GetMergeRequest(ctx context.Context, owner, repo string, number int) (*MergeRequestDetail, error) { var mr MergeRequestDetail err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number)}, &mr) return &mr, err } // MergeMergeRequest merges a merge request. func (c *Client) MergeMergeRequest(ctx context.Context, owner, repo string, number int, method string) (*MergeRequest, error) { body := map[string]string{} if method != "" { body["method"] = method } var mr MergeRequest err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/merge", Body: body}, &mr) return &mr, err } // CloseMergeRequest closes a merge request. func (c *Client) CloseMergeRequest(ctx context.Context, owner, repo string, number int) (*MergeRequest, error) { var mr MergeRequest err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/close"}, &mr) return &mr, err } // ReopenMergeRequest reopens a closed merge request. func (c *Client) ReopenMergeRequest(ctx context.Context, owner, repo string, number int) (*MergeRequest, error) { var mr MergeRequest err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/reopen"}, &mr) return &mr, err } // CommentMergeRequest adds a comment to a merge request. func (c *Client) CommentMergeRequest(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) { var cm Comment err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/comments", Body: map[string]string{"body": body}}, &cm) return &cm, err } // ReviewMergeRequest records a review verdict. func (c *Client) ReviewMergeRequest(ctx context.Context, owner, repo string, number int, verdict string) error { return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/reviews", Body: map[string]string{"verdict": verdict}}, nil) } // ---- actions ---- // ListRuns lists workflow runs. func (c *Client) ListRuns(ctx context.Context, owner, repo string, page, perPage int) (*RunPage, error) { var p RunPage err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs", Query: pageQuery(page, perPage, nil)}, &p) return &p, err } // GetRun fetches a run with jobs + step statuses. func (c *Client) GetRun(ctx context.Context, owner, repo string, number int) (*Run, error) { var r Run err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number)}, &r) return &r, err } // GetRunLogs fetches a run's accumulated logs (JSON form). func (c *Client) GetRunLogs(ctx context.Context, owner, repo string, number int) (*RunLogs, error) { var l RunLogs err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number) + "/logs"}, &l) return &l, err } // RerunRun re-runs a finished run. func (c *Client) RerunRun(ctx context.Context, owner, repo string, number int) (*Run, error) { var r Run err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number) + "/rerun"}, &r) return &r, err } // CancelRun cancels an in-flight run. func (c *Client) CancelRun(ctx context.Context, owner, repo string, number int) (*Run, error) { var r Run err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number) + "/cancel"}, &r) return &r, err } // Dispatch triggers workflow_dispatch workflows. func (c *Client) Dispatch(ctx context.Context, owner, repo, ref string) (*DispatchResult, error) { body := map[string]string{} if ref != "" { body["ref"] = ref } var d DispatchResult err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/dispatch", Body: body}, &d) return &d, err } // ---- device flow (pre-auth: the device code is the credential) ---- // StartDeviceLogin requests a device code the user approves in their browser // (the URL is in the response). Works on a tokenless client. func (c *Client) StartDeviceLogin(ctx context.Context, scope, clientName string) (*DeviceCodeStart, error) { body := map[string]string{} if clientName != "" { body["client_name"] = clientName } if scope != "" { body["scope"] = scope } var d DeviceCodeStart err := c.do(ctx, Request{Method: http.MethodPost, Path: "/device/code", Body: body}, &d) return &d, err } // PollDeviceToken exchanges an approved device code for a PAT. A pending // approval returns an *APIError with Code "authorization_pending" (keep // polling); "access_denied", "expired_token", and "invalid_grant" are terminal. func (c *Client) PollDeviceToken(ctx context.Context, deviceCode string) (*DeviceToken, error) { var d DeviceToken err := c.do(ctx, Request{Method: http.MethodPost, Path: "/device/token", Body: map[string]string{"device_code": deviceCode}}, &d) return &d, err } // ---- issues / labels / milestones ---- // ListIssues lists a repo's issues (state: open default, closed, all). func (c *Client) ListIssues(ctx context.Context, owner, repo, state string, page, perPage int) (*IssuePage, error) { extra := url.Values{} if state != "" { extra.Set("state", state) } var p IssuePage err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues", Query: pageQuery(page, perPage, extra)}, &p) return &p, err } // GetIssue fetches an issue with body, comments, labels, milestone, assignees. func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*IssueDetail, error) { var i IssueDetail err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number)}, &i) return &i, err } // CreateIssue opens an issue. func (c *Client) CreateIssue(ctx context.Context, owner, repo, title, body string) (*IssueDetail, error) { var i IssueDetail err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues", Body: map[string]string{"title": title, "body": body}}, &i) return &i, err } // SetIssueState closes ("closed") or reopens ("open") an issue. func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*IssueDetail, error) { var i IssueDetail err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/state", Body: map[string]string{"state": state}}, &i) return &i, err } // CommentIssue adds a comment. func (c *Client) CommentIssue(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) { var cm Comment err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/comments", Body: map[string]string{"body": body}}, &cm) return &cm, err } // SetIssueLabels replaces an issue's labels by name (empty clears all). func (c *Client) SetIssueLabels(ctx context.Context, owner, repo string, number int, labels []string) error { if labels == nil { labels = []string{} } return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/labels", Body: map[string]any{"labels": labels}}, nil) } // SetIssueMilestone assigns (id or title) or clears ("") an issue's milestone. func (c *Client) SetIssueMilestone(ctx context.Context, owner, repo string, number int, milestone string) error { return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/milestone", Body: map[string]string{"milestone": milestone}}, nil) } // SetIssueAssignee adds (op "add") or removes (op "remove") an assignee. func (c *Client) SetIssueAssignee(ctx context.Context, owner, repo string, number int, op, user string) error { return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/assignees", Body: map[string]string{"op": op, "user": user}}, nil) } // ListLabels lists a repo's labels. func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) { var l []Label err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/labels"}, &l) return l, err } // ListMilestones lists a repo's milestones (state: open default, closed, all). func (c *Client) ListMilestones(ctx context.Context, owner, repo, state string) ([]Milestone, error) { extra := url.Values{} if state != "" { extra.Set("state", state) } var m []Milestone err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones", Query: extra}, &m) return m, err } // CreateMilestone creates a milestone (dueOn is YYYY-MM-DD or empty). func (c *Client) CreateMilestone(ctx context.Context, owner, repo, title, description, dueOn string) (*Milestone, error) { var m Milestone err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones", Body: map[string]string{"title": title, "description": description, "due_on": dueOn}}, &m) return &m, err } // UpdateMilestone patches a milestone; nil fields keep their values. func (c *Client) UpdateMilestone(ctx context.Context, owner, repo, id string, in map[string]any) (*Milestone, error) { var m Milestone err := c.do(ctx, Request{Method: http.MethodPatch, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones/" + seg(id), Body: in}, &m) return &m, err } // DeleteMilestone removes a milestone. func (c *Client) DeleteMilestone(ctx context.Context, owner, repo, id string) error { return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones/" + seg(id)}, nil) } // ---- organizations ---- // GetOrg fetches basic org info. func (c *Client) GetOrg(ctx context.Context, handle string) (*Org, error) { var o Org err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle)}, &o) return &o, err } // ListOrgMembers lists org members. func (c *Client) ListOrgMembers(ctx context.Context, handle string) ([]OrgMember, error) { var m []OrgMember err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle) + "/members"}, &m) return m, err } // ListOrgTeams lists org teams. func (c *Client) ListOrgTeams(ctx context.Context, handle string) ([]Team, error) { var t []Team err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle) + "/teams"}, &t) return t, err } // ---- code browsing ---- // GetRefs returns branches, tags, and the default branch. func (c *Client) GetRefs(ctx context.Context, owner, repo string) (*Refs, error) { var r Refs err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/refs"}, &r) return &r, err } // GetContents returns a directory listing or file content. func (c *Client) GetContents(ctx context.Context, owner, repo, ref, path string) (*Contents, error) { p := "/repos/" + seg(owner) + "/" + seg(repo) + "/contents/" + seg(ref) if sp := pathSegs(path); sp != "" { p += "/" + sp } var ct Contents err := c.do(ctx, Request{Method: http.MethodGet, Path: p}, &ct) return &ct, err } // GetRaw returns raw file bytes. func (c *Client) GetRaw(ctx context.Context, owner, repo, ref, path string) ([]byte, error) { p := "/repos/" + seg(owner) + "/" + seg(repo) + "/raw/" + seg(ref) + "/" + pathSegs(path) return c.RawText(ctx, p, nil) } // GetCommits returns commit history reachable from a ref. func (c *Client) GetCommits(ctx context.Context, owner, repo, ref string, page, perPage int) (*CommitPage, error) { var p CommitPage err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/commits/" + seg(ref), Query: pageQuery(page, perPage, nil)}, &p) return &p, err } // GetCommit returns a single commit's detail. func (c *Client) GetCommit(ctx context.Context, owner, repo, sha string) (*CommitDetail, error) { var cd CommitDetail err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/commit/" + seg(sha)}, &cd) return &cd, err } // Compare returns a base...head comparison. func (c *Client) Compare(ctx context.Context, owner, repo, spec string) (*Comparison, error) { var cmp Comparison err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/compare/" + seg(spec)}, &cmp) return &cmp, err }