Files
kandev/apps/backend/internal/gitlab/pat_client.go
T
Henry ffec4aade9 feat(gitlab): filter issues by milestone (#3278)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-02 16:55:31 +00:00

813 lines
26 KiB
Go

package gitlab
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const (
apiPathPrefix = "/api/v4"
defaultPageSize = 50
maxPageSize = 100
requestTimeout = 30 * time.Second
defaultErrBytes = 4096
defaultPageStart = 1
)
// APIError represents an error response from the GitLab API.
type APIError struct {
StatusCode int
Endpoint string
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("GitLab API %s returned status %d", e.Endpoint, e.StatusCode)
}
// PATClient implements Client against the GitLab REST v4 API using a
// personal access token (or any other token type sent via PRIVATE-TOKEN).
type PATClient struct {
host string
token string
httpClient *http.Client
// username caches the authenticated user's login after the first
// /user lookup. Service.GetStatus can race other status callers,
// so the cache field is guarded by usernameMu.
usernameMu sync.RWMutex
username string
}
// NewPATClient creates a new PAT-based GitLab client.
// host is the GitLab base URL (e.g. "https://gitlab.com" or
// "https://gitlab.acme.corp"); a trailing slash is trimmed. An empty host
// falls back to the public DefaultHost.
func NewPATClient(host, token string) *PATClient {
if host == "" {
host = DefaultHost
}
host = strings.TrimRight(host, "/")
return &PATClient{
host: host,
token: token,
httpClient: &http.Client{
Timeout: requestTimeout,
// net/http only strips the standard sensitive headers
// (Authorization, Cookie, ...) when a redirect leaves the
// original host; a custom PRIVATE-TOKEN header would be forwarded
// to the redirect target unchanged. Reject any redirect that
// leaves the configured origin so the credential can never be
// delivered to an untrusted host.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if !strings.EqualFold(req.URL.Host, via[0].URL.Host) {
return fmt.Errorf("refusing cross-host redirect to %s", req.URL.Host)
}
return nil
},
},
}
}
func (c *PATClient) Host() string { return c.host }
func (c *PATClient) setHeaders(req *http.Request) {
if c.token != "" {
req.Header.Set("PRIVATE-TOKEN", c.token)
}
req.Header.Set("Accept", "application/json")
}
// IsAuthenticated probes /user *uncached* — calling GetAuthenticatedUser
// would return the previously-cached username even after the token was
// revoked upstream, leaving the integration showing "connected" forever.
// A 401/403 means the token is bad and is surfaced as (false, nil) so
// callers can render "not connected". Any other error (network, 5xx,
// parse failure) is returned so it isn't silently reported as a bad token.
func (c *PATClient) IsAuthenticated(ctx context.Context) (bool, error) {
var user struct {
Username string `json:"username"`
}
if err := c.get(ctx, "/user", &user); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) {
return false, nil
}
return false, err
}
// Opportunistically refresh the cache so the next GetAuthenticatedUser
// caller doesn't have to re-fetch.
c.usernameMu.Lock()
c.username = user.Username
c.usernameMu.Unlock()
return true, nil
}
func (c *PATClient) GetAuthenticatedUser(ctx context.Context) (string, error) {
c.usernameMu.RLock()
cached := c.username
c.usernameMu.RUnlock()
if cached != "" {
return cached, nil
}
var user struct {
Username string `json:"username"`
}
if err := c.get(ctx, "/user", &user); err != nil {
return "", err
}
c.usernameMu.Lock()
c.username = user.Username
c.usernameMu.Unlock()
return user.Username, nil
}
// projectRef returns the URL-encoded project path used as :id in API URLs.
// GitLab requires the slash between namespace and path to be %2F-encoded —
// url.PathEscape leaves "/" alone, so do the substitution manually before
// URL-escaping the rest.
func projectRef(projectPath string) string {
return encodeSegment(projectPath)
}
// encodeSegment percent-encodes a value so it survives as one URL path
// segment. GitLab's :id and :file_path both need this: an unencoded slash
// routes to a different resource and 404s.
func encodeSegment(value string) string {
return strings.ReplaceAll(url.PathEscape(value), "/", "%2F")
}
// groupRef returns the URL-encoded group path used as :id in /groups/:id/...
// API URLs. Same %2F treatment as projectRef — required for subgroup paths
// like "acme/team" where the slash separates the parent group from the
// child group and a bare PathEscape leaves it as "/", which GitLab routes
// to the parent only and returns 404 for the subgroup.
func groupRef(groupPath string) string {
return strings.ReplaceAll(url.PathEscape(groupPath), "/", "%2F")
}
func (c *PATClient) GetMR(ctx context.Context, projectPath string, iid int) (*MR, error) {
var raw rawMR
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d", projectRef(projectPath), iid)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("get MR !%d: %w", iid, err)
}
targetProjectID := raw.TargetProjectID
if targetProjectID == 0 {
targetProjectID = raw.ProjectID
}
if raw.SourceProjectID > 0 && (targetProjectID == 0 || raw.SourceProjectID != targetProjectID) {
sourceProject, err := c.getProjectByID(ctx, raw.SourceProjectID)
if err != nil {
return nil, fmt.Errorf("resolve MR source project %d: %w", raw.SourceProjectID, err)
}
raw.SourceProject = *sourceProject
}
return convertRawMR(&raw), nil
}
func (c *PATClient) getProjectByID(ctx context.Context, id int64) (*rawProject, error) {
if id <= 0 {
return nil, errors.New("source project ID is invalid")
}
var project rawProject
if err := c.get(ctx, fmt.Sprintf("/projects/%d", id), &project); err != nil {
return nil, err
}
if project.ID == 0 {
project.ID = id
}
if strings.TrimSpace(project.PathWithNamespace) == "" {
return nil, errors.New("source project path is missing")
}
return &project, nil
}
func (c *PATClient) FindMRByBranch(ctx context.Context, projectPath, branch string) (*MR, error) {
var raw []rawMR
endpoint := fmt.Sprintf(
"/projects/%s/merge_requests?source_branch=%s&state=opened&per_page=1",
projectRef(projectPath), url.QueryEscape(branch),
)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("find MR by branch: %w", err)
}
if len(raw) == 0 {
return nil, nil
}
return convertRawMR(&raw[0]), nil
}
func (c *PATClient) ListAuthoredMRs(ctx context.Context, projectPath string) ([]*MR, error) {
user, err := c.GetAuthenticatedUser(ctx)
if err != nil {
return nil, err
}
var raw []rawMR
endpoint := fmt.Sprintf(
"/projects/%s/merge_requests?state=opened&author_username=%s&per_page=100",
projectRef(projectPath), url.QueryEscape(user),
)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list MRs: %w", err)
}
return convertRawMRSlice(raw), nil
}
func (c *PATClient) ListReviewRequestedMRs(ctx context.Context, filter, customQuery string) ([]*MR, error) {
query := buildReviewMRQuery(filter, customQuery)
endpoint := "/merge_requests?" + query
var raw []rawMR
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list review-requested MRs: %w", err)
}
return convertRawMRSlice(raw), nil
}
func (c *PATClient) SearchMRs(ctx context.Context, filter, customQuery string) ([]*MR, error) {
page, err := c.SearchMRsPaged(ctx, filter, customQuery, defaultPageStart, defaultPageSize)
if err != nil {
return nil, err
}
return page.MRs, nil
}
func (c *PATClient) SearchMRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*MRSearchPage, error) {
page, perPage = clampSearchPage(page, perPage)
query := buildMRSearchQuery(filter, customQuery)
endpoint := fmt.Sprintf("/merge_requests?%s&page=%d&per_page=%d", query, page, perPage)
var raw []rawMR
total, err := c.getWithTotal(ctx, endpoint, &raw)
if err != nil {
return nil, fmt.Errorf("search MRs: %w", err)
}
return &MRSearchPage{
MRs: convertRawMRSlice(raw),
TotalCount: total,
Page: page,
PerPage: perPage,
}, nil
}
func (c *PATClient) ListIssues(ctx context.Context, filter, customQuery, milestone string) ([]*Issue, error) {
page, err := c.ListIssuesPaged(ctx, filter, customQuery, milestone, defaultPageStart, defaultPageSize)
if err != nil {
return nil, err
}
return page.Issues, nil
}
func (c *PATClient) ListIssuesPaged(ctx context.Context, filter, customQuery, milestone string, page, perPage int) (*IssueSearchPage, error) {
page, perPage = clampSearchPage(page, perPage)
query := buildIssueSearchQuery(filter, customQuery, milestone)
endpoint := fmt.Sprintf("/issues?%s&page=%d&per_page=%d", query, page, perPage)
var raw []rawIssue
total, err := c.getWithTotal(ctx, endpoint, &raw)
if err != nil {
return nil, fmt.Errorf("search issues: %w", err)
}
issues := make([]*Issue, len(raw))
for i := range raw {
issues[i] = convertRawIssue(&raw[i])
}
return &IssueSearchPage{
Issues: issues,
TotalCount: total,
Page: page,
PerPage: perPage,
}, nil
}
func (c *PATClient) GetIssueState(ctx context.Context, projectPath string, iid int) (string, error) {
var result struct {
State string `json:"state"`
}
endpoint := fmt.Sprintf("/projects/%s/issues/%d", projectRef(projectPath), iid)
if err := c.get(ctx, endpoint, &result); err != nil {
return "", fmt.Errorf("get issue state: %w", err)
}
return result.State, nil
}
func (c *PATClient) ListUserGroups(ctx context.Context) ([]Group, error) {
var raw []struct {
ID int64 `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
if err := c.get(ctx, "/groups?per_page=100&min_access_level=10", &raw); err != nil {
return nil, fmt.Errorf("list groups: %w", err)
}
groups := make([]Group, len(raw))
for i, g := range raw {
groups[i] = Group{ID: g.ID, Path: g.Path, Name: g.Name, AvatarURL: g.AvatarURL}
}
return groups, nil
}
func (c *PATClient) SearchGroupProjects(ctx context.Context, group, query string, limit int) ([]Project, error) {
if limit <= 0 {
limit = 20
}
endpoint := fmt.Sprintf("/groups/%s/projects?per_page=%d&simple=true&include_subgroups=true",
groupRef(group), limit)
if query != "" {
endpoint += "&search=" + url.QueryEscape(query)
}
var raw []rawProject
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list group projects: %w", err)
}
projects := make([]Project, len(raw))
for i := range raw {
projects[i] = convertRawProject(&raw[i])
}
return projects, nil
}
func (c *PATClient) ListMRApprovals(ctx context.Context, projectPath string, iid int) ([]MRApproval, error) {
approvals, _, err := c.fetchApprovals(ctx, projectPath, iid)
return approvals, err
}
// fetchApprovals issues a single GET against /approvals and returns both the
// list of approvers and the required-approvals count. Consolidating these so
// GetMRStatus doesn't have to hit the same endpoint twice per poll.
func (c *PATClient) fetchApprovals(ctx context.Context, projectPath string, iid int) ([]MRApproval, int, error) {
var raw struct {
ApprovedBy []struct {
User struct {
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
} `json:"user"`
} `json:"approved_by"`
ApprovalsRequired int `json:"approvals_required"`
UpdatedAt time.Time `json:"updated_at"`
}
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/approvals", projectRef(projectPath), iid)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, 0, fmt.Errorf("list approvals: %w", err)
}
approvals := make([]MRApproval, 0, len(raw.ApprovedBy))
for _, a := range raw.ApprovedBy {
approvals = append(approvals, MRApproval{
Username: a.User.Username,
Avatar: a.User.AvatarURL,
CreatedAt: raw.UpdatedAt,
})
}
return approvals, raw.ApprovalsRequired, nil
}
func (c *PATClient) ListMRDiscussions(ctx context.Context, projectPath string, iid int, since *time.Time) ([]MRDiscussion, error) {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/discussions?per_page=100",
projectRef(projectPath), iid)
var raw []rawDiscussion
for endpoint != "" {
var page []rawDiscussion
nextLink, err := c.getPaginated(ctx, endpoint, &page)
if err != nil {
return nil, fmt.Errorf("list discussions: %w", err)
}
raw = append(raw, page...)
endpoint = nextLink
}
discussions := make([]MRDiscussion, 0, len(raw))
for i := range raw {
d := convertRawDiscussion(&raw[i])
if since != nil && d.UpdatedAt.Before(*since) {
continue
}
discussions = append(discussions, d)
}
return discussions, nil
}
func (c *PATClient) CreateMRDiscussionNote(ctx context.Context, projectPath string, iid int, discussionID, body string) (*MRNote, error) {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/discussions/%s/notes",
projectRef(projectPath), iid, url.PathEscape(discussionID))
payload := map[string]string{"body": body}
var raw rawNote
if err := c.postJSON(ctx, endpoint, payload, &raw); err != nil {
return nil, fmt.Errorf("create discussion note: %w", err)
}
note := convertRawNote(&raw)
return &note, nil
}
func (c *PATClient) ResolveMRDiscussion(ctx context.Context, projectPath string, iid int, discussionID string) error {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/discussions/%s?resolved=true",
projectRef(projectPath), iid, url.PathEscape(discussionID))
return c.put(ctx, endpoint, nil)
}
func (c *PATClient) ListPipelines(ctx context.Context, projectPath, ref string) ([]Pipeline, error) {
endpoint := fmt.Sprintf("/projects/%s/pipelines?per_page=20", projectRef(projectPath))
if ref != "" {
endpoint += "&ref=" + url.QueryEscape(ref)
}
var raw []rawPipeline
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list pipelines: %w", err)
}
pipelines := make([]Pipeline, len(raw))
for i := range raw {
pipelines[i] = convertRawPipeline(&raw[i])
}
return pipelines, nil
}
func (c *PATClient) ListPipelineJobs(ctx context.Context, projectPath string, pipelineID int64) ([]PipelineJob, error) {
endpoint := fmt.Sprintf("/projects/%s/pipelines/%d/jobs?per_page=%d", projectRef(projectPath), pipelineID, maxPageSize)
var raw []rawPipelineJob
for endpoint != "" {
var page []rawPipelineJob
nextLink, err := c.getPaginated(ctx, endpoint, &page)
if err != nil {
return nil, fmt.Errorf("list pipeline jobs: %w", err)
}
raw = append(raw, page...)
endpoint = nextLink
}
jobs := make([]PipelineJob, len(raw))
for i := range raw {
jobs[i] = convertRawPipelineJob(&raw[i])
}
return jobs, nil
}
// populatePipelineJobCounts fetches the given pipeline's jobs and fills in
// its JobsTotal/JobsPassing from them. GitLab's pipeline list/get endpoints
// never carry job counts (unlike GitHub's check-runs summary), so this is
// the only way to populate them.
func (c *PATClient) populatePipelineJobCounts(ctx context.Context, projectPath string, pipeline *Pipeline) error {
jobs, err := c.ListPipelineJobs(ctx, projectPath, pipeline.ID)
if err != nil {
return err
}
total, passing, _ := summarizePipelineJobs(jobs)
pipeline.JobsTotal = total
pipeline.JobsPassing = passing
return nil
}
func (c *PATClient) GetMRFeedback(ctx context.Context, projectPath string, iid int) (*MRFeedback, error) {
mr, err := c.GetMR(ctx, projectPath, iid)
if err != nil {
return nil, err
}
approvals, err := c.ListMRApprovals(ctx, projectPath, iid)
if err != nil {
return nil, err
}
discussions, err := c.ListMRDiscussions(ctx, projectPath, iid, nil)
if err != nil {
return nil, err
}
var pipelines []Pipeline
if mr.HeadSHA != "" || mr.HeadBranch != "" {
pipelines, err = c.ListPipelines(ctx, projectPath, mr.HeadBranch)
if err != nil {
return nil, err
}
}
if len(pipelines) > 0 {
jobs, err := c.ListPipelineJobs(ctx, projectPath, pipelines[0].ID)
if err != nil {
return nil, err
}
total, passing, _ := summarizePipelineJobs(jobs)
pipelines[0].JobsTotal = total
pipelines[0].JobsPassing = passing
pipelines[0].Jobs = jobs
}
return &MRFeedback{
MR: mr,
Approvals: approvals,
Discussions: discussions,
Pipelines: pipelines,
HasIssues: hasOpenDiscussions(discussions) || pipelineFailing(pipelines),
}, nil
}
func (c *PATClient) GetMRStatus(ctx context.Context, projectPath string, iid int) (*MRStatus, error) {
mr, err := c.GetMR(ctx, projectPath, iid)
if err != nil {
return nil, err
}
approvals, required, err := c.fetchApprovals(ctx, projectPath, iid)
if err != nil {
return nil, err
}
var pipelines []Pipeline
if mr.HeadSHA != "" || mr.HeadBranch != "" {
pipelines, err = c.ListPipelines(ctx, projectPath, mr.HeadBranch)
if err != nil {
return nil, err
}
}
if len(pipelines) > 0 {
if err := c.populatePipelineJobCounts(ctx, projectPath, &pipelines[0]); err != nil {
return nil, err
}
}
pipelineState, jobsTotal, jobsPassing := summarizePipelines(pipelines)
approvalState := summarizeApprovals(len(approvals), required)
return &MRStatus{
MR: mr,
ApprovalState: approvalState,
PipelineState: pipelineState,
MergeStatus: mr.MergeStatus,
ApprovalCount: len(approvals),
RequiredApprovals: required,
PipelineJobsTotal: jobsTotal,
PipelineJobsPassing: jobsPassing,
DetailedMergeStatus: mr.DetailedMergeStatus,
ReviewerCount: len(mr.Reviewers),
UnapprovedReviewers: countUnapprovedReviewers(mr.Reviewers, approvals),
}, nil
}
func (c *PATClient) ListMRFiles(ctx context.Context, projectPath string, iid int) ([]MRFile, error) {
var raw struct {
Changes []struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
NewFile bool `json:"new_file"`
DeletedFile bool `json:"deleted_file"`
RenamedFile bool `json:"renamed_file"`
Diff string `json:"diff"`
} `json:"changes"`
}
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/changes",
projectRef(projectPath), iid)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list MR changes: %w", err)
}
files := make([]MRFile, len(raw.Changes))
for i, ch := range raw.Changes {
additions, deletions := countDiffLines(ch.Diff)
files[i] = MRFile{
Filename: ch.NewPath,
Status: diffStatus(ch.NewFile, ch.DeletedFile, ch.RenamedFile),
Additions: additions,
Deletions: deletions,
Patch: ch.Diff,
OldPath: ch.OldPath,
}
}
return files, nil
}
func (c *PATClient) ListMRCommits(ctx context.Context, projectPath string, iid int) ([]MRCommitInfo, error) {
// The MR-commits endpoint doesn't return per-commit stats — only the
// per-repo /repository/commits/:sha?stats=true does. We deliberately
// don't fetch stats here; see the MRCommitInfo doc-comment.
var raw []struct {
ID string `json:"id"`
Message string `json:"message"`
AuthorName string `json:"author_name"`
AuthoredDate string `json:"authored_date"`
}
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/commits?per_page=100",
projectRef(projectPath), iid)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list MR commits: %w", err)
}
commits := make([]MRCommitInfo, len(raw))
for i, r := range raw {
commits[i] = MRCommitInfo{
SHA: r.ID,
Message: r.Message,
AuthorName: r.AuthorName,
AuthorDate: r.AuthoredDate,
}
}
return commits, nil
}
func (c *PATClient) SubmitMRApproval(ctx context.Context, projectPath string, iid int) error {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/approve",
projectRef(projectPath), iid)
return c.post(ctx, endpoint, nil)
}
func (c *PATClient) SubmitMRUnapproval(ctx context.Context, projectPath string, iid int) error {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/unapprove",
projectRef(projectPath), iid)
return c.post(ctx, endpoint, nil)
}
func (c *PATClient) CreateMR(ctx context.Context, projectPath, sourceBranch, targetBranch, title, description string, draft bool) (*MR, error) {
finalTitle := title
if draft && !strings.HasPrefix(strings.ToLower(strings.TrimSpace(finalTitle)), "draft:") {
finalTitle = "Draft: " + finalTitle
}
payload := map[string]any{
"source_branch": sourceBranch,
"target_branch": targetBranch,
"title": finalTitle,
"description": description,
"remove_source_branch": true,
}
endpoint := fmt.Sprintf("/projects/%s/merge_requests", projectRef(projectPath))
var raw rawMR
if err := c.postJSON(ctx, endpoint, payload, &raw); err != nil {
return nil, fmt.Errorf("create MR: %w", err)
}
return convertRawMR(&raw), nil
}
func (c *PATClient) ListProjectBranches(ctx context.Context, projectPath string) ([]RepoBranch, error) {
endpoint := fmt.Sprintf("/projects/%s/repository/branches?per_page=100", projectRef(projectPath))
var branches []RepoBranch
for endpoint != "" {
var page []struct {
Name string `json:"name"`
}
nextLink, err := c.getPaginated(ctx, endpoint, &page)
if err != nil {
return nil, fmt.Errorf("list branches: %w", err)
}
for _, b := range page {
branches = append(branches, RepoBranch{Name: b.Name})
}
endpoint = nextLink
}
return branches, nil
}
// --- HTTP plumbing ---
func (c *PATClient) get(ctx context.Context, endpoint string, result any) error {
_, err := c.getWithTotal(ctx, endpoint, result)
return err
}
func (c *PATClient) getWithTotal(ctx context.Context, endpoint string, result any) (int, error) {
u := c.host + apiPathPrefix + endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, err
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("request %s: %w", endpoint, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, defaultErrBytes))
return 0, &APIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)}
}
total := 0
if t := resp.Header.Get("X-Total"); t != "" {
if n, convErr := strconv.Atoi(t); convErr == nil {
total = n
}
}
if result == nil {
// Drain so the underlying connection can be reused for keep-alive;
// HTTP/1.1 transports only pool when the body is fully consumed.
_, _ = io.Copy(io.Discard, resp.Body)
return total, nil
}
return total, json.NewDecoder(resp.Body).Decode(result)
}
func (c *PATClient) getPaginated(ctx context.Context, endpoint string, result any) (string, error) {
u := c.host + apiPathPrefix + endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("request %s: %w", endpoint, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, defaultErrBytes))
return "", &APIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)}
}
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return "", err
}
return parseNextLink(resp.Header.Get("Link"), c.host+apiPathPrefix), nil
}
func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) error {
return c.doWrite(ctx, http.MethodPost, endpoint, body, nil)
}
func (c *PATClient) put(ctx context.Context, endpoint string, body []byte) error {
return c.doWrite(ctx, http.MethodPut, endpoint, body, nil)
}
func (c *PATClient) postJSON(ctx context.Context, endpoint string, payload, result any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
return c.doWrite(ctx, http.MethodPost, endpoint, body, result)
}
func (c *PATClient) doWrite(ctx context.Context, method, endpoint string, body []byte, result any) error {
_, err := c.doWriteWithStatus(ctx, method, endpoint, body, result)
return err
}
func (c *PATClient) doWriteWithStatus(ctx context.Context, method, endpoint string, body []byte, result any) (int, error) {
u := c.host + apiPathPrefix + endpoint
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
if err != nil {
return 0, err
}
c.setHeaders(req)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("request %s %s: %w", method, endpoint, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, defaultErrBytes))
return resp.StatusCode, &APIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)}
}
if result == nil {
// Drain to allow HTTP/1.1 connection reuse on the next call.
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode, nil
}
return resp.StatusCode, json.NewDecoder(resp.Body).Decode(result)
}
// parseNextLink extracts the "next" URL path+query from a GitLab Link header
// and strips the API base prefix so the caller can pass the result back to
// get() (which re-prepends host + apiPathPrefix). Returns "" if the link
// targets a different host or doesn't share the apiBase prefix — passing a
// bare absolute URL back to get() would double-prefix it and break
// pagination, so it's safer to stop than to silently misroute the next page.
func parseNextLink(header, apiBase string) string {
if header == "" {
return ""
}
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if !strings.Contains(part, `rel="next"`) {
continue
}
start := strings.Index(part, "<")
end := strings.Index(part, ">")
if start < 0 || end < 0 || end <= start {
continue
}
link := part[start+1 : end]
if strings.HasPrefix(link, apiBase) {
return link[len(apiBase):]
}
return ""
}
return ""
}
func clampSearchPage(page, perPage int) (int, int) {
if page < 1 {
page = 1
}
if perPage <= 0 {
perPage = defaultPageSize
}
if perPage > maxPageSize {
perPage = maxPageSize
}
return page, perPage
}