Files
kandev/apps/backend/internal/gitlab/pat_client_actions.go
T
2026-07-21 23:24:22 +01:00

270 lines
9.6 KiB
Go

package gitlab
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
// --- Merge ---
// MergeMRRequest is the JSON body for accepting an MR.
type MergeMRRequest struct {
MergeCommitMessage string `json:"merge_commit_message,omitempty"`
SquashCommitMessage string `json:"squash_commit_message,omitempty"`
Squash bool `json:"squash,omitempty"`
ShouldRemoveSourceBranch bool `json:"should_remove_source_branch,omitempty"`
}
// MergeMR accepts an MR. GitLab supports three project-level merge methods
// (merge, rebase_merge, ff); the caller picks one and the project must allow
// it. Passing squash=true performs a squash merge regardless of method.
func (c *PATClient) MergeMR(ctx context.Context, projectPath string, iid int, squash bool, squashCommitMessage string) (*MR, error) {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/merge", projectRef(projectPath), iid)
body, err := json.Marshal(MergeMRRequest{
Squash: squash,
SquashCommitMessage: squashCommitMessage,
ShouldRemoveSourceBranch: false,
})
if err != nil {
return nil, fmt.Errorf("encode merge request: %w", err)
}
var raw rawMR
if err := c.doWrite(ctx, "PUT", endpoint, body, &raw); err != nil {
return nil, fmt.Errorf("merge MR !%d: %w", iid, err)
}
return convertRawMR(&raw), nil
}
// --- Project merge methods ---
// GetProjectMergeMethods reads a project's `merge_method` and `squash_option`
// settings so the merge button UI can offer the right options.
func (c *PATClient) GetProjectMergeMethods(ctx context.Context, projectPath string) (*ProjectMergeMethods, error) {
var raw struct {
MergeMethod string `json:"merge_method"` // merge, rebase_merge, ff
SquashOption string `json:"squash_option"` // never, default_off, default_on, always
}
endpoint := fmt.Sprintf("/projects/%s", projectRef(projectPath))
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("get project merge methods: %w", err)
}
out := &ProjectMergeMethods{}
switch raw.MergeMethod {
case "merge":
out.Merge = true
case "rebase_merge":
out.RebaseMerge = true
case "ff":
out.FastForward = true
default:
out.Merge = true
}
if raw.SquashOption != "" && raw.SquashOption != "never" {
out.AllowSquash = true
}
return out, nil
}
// --- Branch protection ---
// ProtectedBranch represents a GitLab protected branch rule.
type ProtectedBranch struct {
Name string `json:"name"`
PushAccessLevel int `json:"push_access_level"`
MergeAccessLevel int `json:"merge_access_level"`
CodeOwnerApprovalRequired bool `json:"code_owner_approval_required"`
}
// GetProtectedBranch returns the protected-branch settings for a branch (404 → nil).
func (c *PATClient) GetProtectedBranch(ctx context.Context, projectPath, branch string) (*ProtectedBranch, error) {
endpoint := fmt.Sprintf("/projects/%s/protected_branches/%s",
projectRef(projectPath), url.PathEscape(branch))
var raw struct {
Name string `json:"name"`
PushAccessLevels []struct {
AccessLevel int `json:"access_level"`
} `json:"push_access_levels"`
MergeAccessLevels []struct {
AccessLevel int `json:"access_level"`
} `json:"merge_access_levels"`
CodeOwnerApprovalRequired bool `json:"code_owner_approval_required"`
}
if err := c.get(ctx, endpoint, &raw); err != nil {
// 404 means the branch isn't protected — return nil without error.
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
return nil, nil
}
return nil, fmt.Errorf("get protected branch: %w", err)
}
out := &ProtectedBranch{
Name: raw.Name,
CodeOwnerApprovalRequired: raw.CodeOwnerApprovalRequired,
}
if len(raw.PushAccessLevels) > 0 {
out.PushAccessLevel = raw.PushAccessLevels[0].AccessLevel
}
if len(raw.MergeAccessLevels) > 0 {
out.MergeAccessLevel = raw.MergeAccessLevels[0].AccessLevel
}
return out, nil
}
// --- Projects (autocomplete + global search) ---
// ListUserProjects lists projects the authenticated user is a member of.
// GitLab equivalent of GitHub's ListUserOrgs + repo enumeration.
func (c *PATClient) ListUserProjects(ctx context.Context) ([]Project, error) {
var raw []rawProject
if err := c.get(ctx, "/projects?membership=true&simple=true&per_page=100&order_by=last_activity_at", &raw); err != nil {
return nil, fmt.Errorf("list user projects: %w", err)
}
out := make([]Project, len(raw))
for i := range raw {
out[i] = convertRawProject(&raw[i])
}
return out, nil
}
// SearchProjects searches all projects matching `query`. Used by the
// project-autocomplete and watch-creation flows.
func (c *PATClient) SearchProjects(ctx context.Context, query string, limit int) ([]Project, error) {
if limit <= 0 {
limit = 20
}
endpoint := fmt.Sprintf("/projects?membership=true&simple=true&per_page=%d", limit)
if strings.TrimSpace(query) != "" {
endpoint += "&search=" + url.QueryEscape(query)
}
var raw []rawProject
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("search projects: %w", err)
}
out := make([]Project, len(raw))
for i := range raw {
out[i] = convertRawProject(&raw[i])
}
return out, nil
}
// --- Labels ---
// SetMRLabels replaces an MR's labels with the given set.
func (c *PATClient) SetMRLabels(ctx context.Context, projectPath string, iid int, labels []string) error {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d", projectRef(projectPath), iid)
body, err := json.Marshal(map[string]string{"labels": strings.Join(labels, ",")})
if err != nil {
return fmt.Errorf("encode set-labels payload: %w", err)
}
if err := c.doWrite(ctx, "PUT", endpoint, body, nil); err != nil {
return fmt.Errorf("set MR labels: %w", err)
}
return nil
}
// SetMRAssignees replaces an MR's assignees with the given set of user IDs.
// GitLab takes numeric user IDs (not usernames) for this endpoint.
func (c *PATClient) SetMRAssignees(ctx context.Context, projectPath string, iid int, assigneeIDs []int) error {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d", projectRef(projectPath), iid)
body, err := json.Marshal(map[string][]int{"assignee_ids": assigneeIDs})
if err != nil {
return fmt.Errorf("encode set-assignees payload: %w", err)
}
if err := c.doWrite(ctx, "PUT", endpoint, body, nil); err != nil {
return fmt.Errorf("set MR assignees: %w", err)
}
return nil
}
func (c *PATClient) ListProjectMembers(ctx context.Context, projectPath, query string) ([]ProjectMember, error) {
endpoint := fmt.Sprintf("/projects/%s/members/all?per_page=100", projectRef(projectPath))
if strings.TrimSpace(query) != "" {
endpoint += "&query=" + url.QueryEscape(query)
}
var raw []struct {
ID int64 `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
State string `json:"state"`
}
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list project members: %w", err)
}
members := make([]ProjectMember, 0, len(raw))
for _, member := range raw {
if member.State != "" && member.State != "active" {
continue
}
members = append(members, ProjectMember{
ID: member.ID,
Username: member.Username,
Name: member.Name,
AvatarURL: member.AvatarURL,
})
}
return members, nil
}
func (c *PATClient) SetMRReviewers(ctx context.Context, projectPath string, iid int, reviewerIDs []int64) error {
if reviewerIDs == nil {
reviewerIDs = []int64{}
}
body, err := json.Marshal(map[string][]int64{"reviewer_ids": reviewerIDs})
if err != nil {
return fmt.Errorf("encode set-reviewers payload: %w", err)
}
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d", projectRef(projectPath), iid)
if err := c.doWrite(ctx, http.MethodPut, endpoint, body, nil); err != nil {
return fmt.Errorf("set MR reviewers: %w", err)
}
return nil
}
func (c *PATClient) GetMRSubscription(ctx context.Context, projectPath string, iid int) (*SubscriptionState, error) {
return c.getSubscription(ctx, projectPath, "merge_requests", iid)
}
func (c *PATClient) SetMRSubscription(ctx context.Context, projectPath string, iid int, subscribed bool) (*SubscriptionState, error) {
return c.setSubscription(ctx, projectPath, "merge_requests", iid, subscribed)
}
func (c *PATClient) GetIssueSubscription(ctx context.Context, projectPath string, iid int) (*SubscriptionState, error) {
return c.getSubscription(ctx, projectPath, "issues", iid)
}
func (c *PATClient) SetIssueSubscription(ctx context.Context, projectPath string, iid int, subscribed bool) (*SubscriptionState, error) {
return c.setSubscription(ctx, projectPath, "issues", iid, subscribed)
}
func (c *PATClient) getSubscription(ctx context.Context, projectPath, resource string, iid int) (*SubscriptionState, error) {
endpoint := fmt.Sprintf("/projects/%s/%s/%d", projectRef(projectPath), resource, iid)
var state SubscriptionState
if err := c.get(ctx, endpoint, &state); err != nil {
return nil, fmt.Errorf("get %s subscription: %w", resource, err)
}
return &state, nil
}
func (c *PATClient) setSubscription(ctx context.Context, projectPath, resource string, iid int, subscribed bool) (*SubscriptionState, error) {
action := "unsubscribe"
if subscribed {
action = "subscribe"
}
endpoint := fmt.Sprintf("/projects/%s/%s/%d/%s", projectRef(projectPath), resource, iid, action)
var state SubscriptionState
status, err := c.doWriteWithStatus(ctx, http.MethodPost, endpoint, nil, &state)
if status == http.StatusNotModified {
return &SubscriptionState{Subscribed: subscribed}, nil
}
if err != nil {
return nil, fmt.Errorf("%s to %s notifications: %w", action, resource, err)
}
return &state, nil
}