413 lines
19 KiB
Go
413 lines
19 KiB
Go
// Package gitlab provides GitLab integration for Kandev: merge request
|
|
// monitoring, review queue management, pipeline status tracking, and
|
|
// discussion (review comment) interaction. Mirrors the surface of the
|
|
// internal/github package, adapted to GitLab nouns and the REST v4 API.
|
|
package gitlab
|
|
|
|
import "time"
|
|
|
|
// GitLabConfig is the workspace-owned GitLab connection metadata. Credentials
|
|
// are stored separately under SecretKeyForWorkspace and never serialized.
|
|
type GitLabConfig struct {
|
|
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
|
|
Host string `json:"host" db:"host"`
|
|
AuthMethod string `json:"auth_method" db:"auth_method"`
|
|
Username string `json:"username" db:"username"`
|
|
HasSecret bool `json:"has_secret" db:"-"`
|
|
LastOK bool `json:"last_ok" db:"last_ok"`
|
|
LastError string `json:"last_error,omitempty" db:"last_error"`
|
|
LastCheckedAt *time.Time `json:"last_checked_at,omitempty" db:"last_checked_at"`
|
|
Revision int64 `json:"-" db:"revision"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
// SetConfigRequest creates or updates a workspace GitLab connection. An empty
|
|
// token retains the existing PAT when AuthMethod is pat.
|
|
type SetConfigRequest struct {
|
|
Host string `json:"host"`
|
|
AuthMethod string `json:"auth_method"`
|
|
Token string `json:"token,omitempty"`
|
|
}
|
|
|
|
// TestConnectionResult is returned by the non-persisting connection probe.
|
|
type TestConnectionResult struct {
|
|
OK bool `json:"ok"`
|
|
Username string `json:"username,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// MR represents a GitLab Merge Request.
|
|
//
|
|
// IID is GitLab's per-project sequential ID (the number shown in the UI).
|
|
// ID is the global GitLab ID — required for some endpoints. The frontend
|
|
// keys on (ProjectPath, IID).
|
|
type MR struct {
|
|
ID int64 `json:"id"`
|
|
IID int `json:"iid"`
|
|
ProjectID int64 `json:"project_id"`
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
WebURL string `json:"web_url"`
|
|
State string `json:"state"` // open, closed, merged, locked
|
|
HeadBranch string `json:"head_branch"`
|
|
HeadSHA string `json:"head_sha"`
|
|
BaseBranch string `json:"base_branch"`
|
|
AuthorUsername string `json:"author_username"`
|
|
ProjectNamespace string `json:"project_namespace"`
|
|
ProjectPath string `json:"project_path"`
|
|
SourceProjectID int64 `json:"source_project_id,omitempty"`
|
|
SourceProjectPath string `json:"source_project_path,omitempty"`
|
|
SourceProjectRemoteURL string `json:"source_project_remote_url,omitempty"`
|
|
TargetProjectID int64 `json:"target_project_id,omitempty"`
|
|
TargetProjectPath string `json:"target_project_path,omitempty"`
|
|
TargetDefaultBranch string `json:"target_default_branch,omitempty"`
|
|
AllowCollaboration bool `json:"allow_collaboration"`
|
|
Body string `json:"body"`
|
|
Draft bool `json:"draft"`
|
|
MergeStatus string `json:"merge_status"` // can_be_merged, cannot_be_merged, unchecked, ...
|
|
HasConflicts bool `json:"has_conflicts"`
|
|
Additions int `json:"additions"`
|
|
Deletions int `json:"deletions"`
|
|
Reviewers []MRReviewer `json:"reviewers"`
|
|
Assignees []MRReviewer `json:"assignees"`
|
|
Labels []string `json:"labels"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
MergedAt *time.Time `json:"merged_at,omitempty"`
|
|
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
|
// DetailedMergeStatus is GitLab 15.6+'s server-side merge-readiness
|
|
// verdict (e.g. "mergeable", "not_approved", "discussions_not_resolved",
|
|
// "ci_still_running"). Empty on older/self-managed hosts that predate
|
|
// it — callers fall back to MergeStatus in that case.
|
|
DetailedMergeStatus string `json:"detailed_merge_status,omitempty"`
|
|
// BlockingDiscussionsResolved reflects only discussions the project
|
|
// marked as blocking; it is not the primary unresolved-discussion
|
|
// signal (see MRStatus.UnresolvedDiscussions).
|
|
BlockingDiscussionsResolved bool `json:"blocking_discussions_resolved"`
|
|
}
|
|
|
|
// MRReviewer represents a reviewer or assignee on an MR.
|
|
// GitLab does not have team-level review requests, so Type is always "user".
|
|
type MRReviewer struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
// ProjectMember is an active GitLab user eligible for project-level actions
|
|
// such as merge-request reviewer assignment.
|
|
type ProjectMember struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Name string `json:"name"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
}
|
|
|
|
// SubscriptionState is the live notification subscription state owned by
|
|
// GitLab for an issue or merge request.
|
|
type SubscriptionState struct {
|
|
Subscribed bool `json:"subscribed"`
|
|
}
|
|
|
|
// MRApproval represents a single approval on a merge request.
|
|
// GitLab approvals are simpler than GitHub reviews — there is no
|
|
// COMMENTED / DISMISSED state, only approved/not-approved.
|
|
type MRApproval struct {
|
|
Username string `json:"username"`
|
|
Avatar string `json:"avatar"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// MRDiscussion represents a discussion thread on a merge request.
|
|
// Discussions are GitLab's review-comment unit: a top-level note, optionally
|
|
// anchored to a file/line, and zero or more reply notes. The Resolved flag
|
|
// is per-discussion (not per-note).
|
|
type MRDiscussion struct {
|
|
ID string `json:"id"`
|
|
Resolvable bool `json:"resolvable"`
|
|
Resolved bool `json:"resolved"`
|
|
Notes []MRNote `json:"notes"`
|
|
Path string `json:"path,omitempty"`
|
|
Line int `json:"line,omitempty"`
|
|
OldLine int `json:"old_line,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// MRNote is a single message inside a discussion thread.
|
|
type MRNote struct {
|
|
ID int64 `json:"id"`
|
|
Author string `json:"author"`
|
|
AuthorAvatar string `json:"author_avatar"`
|
|
AuthorIsBot bool `json:"author_is_bot"`
|
|
Body string `json:"body"`
|
|
Type string `json:"type"` // DiffNote, DiscussionNote, or empty for regular notes
|
|
System bool `json:"system"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Pipeline represents a CI pipeline run associated with an MR.
|
|
// GitLab pipelines are richer than GitHub check runs — they have stages
|
|
// and jobs — but the UI surface only needs the rolled-up status.
|
|
type Pipeline struct {
|
|
ID int64 `json:"id"`
|
|
IID int `json:"iid"`
|
|
Status string `json:"status"` // running, pending, success, failed, canceled, skipped, manual, ...
|
|
Source string `json:"source"` // push, merge_request_event, schedule, ...
|
|
Ref string `json:"ref"`
|
|
SHA string `json:"sha"`
|
|
WebURL string `json:"web_url"`
|
|
JobsTotal int `json:"jobs_total"`
|
|
JobsPassing int `json:"jobs_passing"`
|
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
|
// Jobs is only populated for the latest pipeline in an MRFeedback
|
|
// response (GetMRFeedback) — fetching per-job detail for every historic
|
|
// pipeline in the list would be N+1 against the jobs API for no UI
|
|
// benefit. Empty for every other caller (e.g. GetMRStatus, which only
|
|
// needs JobsTotal/JobsPassing).
|
|
Jobs []PipelineJob `json:"jobs,omitempty"`
|
|
}
|
|
|
|
// MRFeedback aggregates all feedback for an MR (fetched live from GitLab).
|
|
type MRFeedback struct {
|
|
MR *MR `json:"mr"`
|
|
Approvals []MRApproval `json:"approvals"`
|
|
Discussions []MRDiscussion `json:"discussions"`
|
|
Pipelines []Pipeline `json:"pipelines"`
|
|
HasIssues bool `json:"has_issues"`
|
|
}
|
|
|
|
// MRStatus contains lightweight MR state used by the background poller.
|
|
// Unlike MRFeedback it skips discussions to reduce API calls — so
|
|
// UnresolvedDiscussions is always 0 here. Automation-subscribed MRs get
|
|
// their unresolved-discussion count from GetMRAutomationSnapshot instead,
|
|
// which fetches discussions only for the subset of MRs that need it.
|
|
type MRStatus struct {
|
|
MR *MR `json:"mr"`
|
|
ApprovalState string `json:"approval_state"` // "approved", "changes_requested", "pending", ""
|
|
PipelineState string `json:"pipeline_state"` // "success", "failure", "pending", ""
|
|
MergeStatus string `json:"merge_status"` // can_be_merged, cannot_be_merged, unchecked
|
|
ApprovalCount int `json:"approval_count"`
|
|
RequiredApprovals int `json:"required_approvals"`
|
|
PipelineJobsTotal int `json:"pipeline_jobs_total"`
|
|
PipelineJobsPassing int `json:"pipeline_jobs_passing"`
|
|
// DetailedMergeStatus mirrors MR.DetailedMergeStatus (free — already
|
|
// fetched as part of the MR object).
|
|
DetailedMergeStatus string `json:"detailed_merge_status,omitempty"`
|
|
// ReviewerCount is len(MR.Reviewers) (free).
|
|
ReviewerCount int `json:"reviewer_count"`
|
|
// UnapprovedReviewers counts assigned reviewers whose username is not
|
|
// present in the approvals list (free — reviewers and approvals are
|
|
// both already fetched by GetMRStatus).
|
|
UnapprovedReviewers int `json:"unapproved_reviewers"`
|
|
// UnresolvedDiscussions is always 0 from GetMRStatus (see type doc);
|
|
// populated separately for automation-subscribed MRs.
|
|
UnresolvedDiscussions int `json:"unresolved_discussions"`
|
|
}
|
|
|
|
// MRSearchPage is a paginated slice of MR search results, with the total
|
|
// count from the GitLab API X-Total header.
|
|
type MRSearchPage struct {
|
|
MRs []*MR `json:"mrs"`
|
|
TotalCount int `json:"total_count"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"per_page"`
|
|
}
|
|
|
|
// IssueSearchPage is a paginated slice of Issue search results.
|
|
type IssueSearchPage struct {
|
|
Issues []*Issue `json:"issues"`
|
|
TotalCount int `json:"total_count"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"per_page"`
|
|
}
|
|
|
|
// Project represents a GitLab project (lightweight, for autocomplete).
|
|
type Project struct {
|
|
ID int64 `json:"id"`
|
|
PathWithNamespace string `json:"path_with_namespace"`
|
|
Namespace string `json:"namespace"`
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Visibility string `json:"visibility"` // private, internal, public
|
|
WebURL string `json:"web_url"`
|
|
DefaultBranch string `json:"default_branch"`
|
|
}
|
|
|
|
// Group represents a GitLab group the authenticated user belongs to.
|
|
// Analogous to GitHubOrg.
|
|
type Group struct {
|
|
ID int64 `json:"id"`
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
}
|
|
|
|
// RepoBranch represents a branch in a GitLab project.
|
|
type RepoBranch struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// Tree entry types as returned by GitLab's repository/tree endpoint.
|
|
const (
|
|
TreeEntryTypeBlob = "blob"
|
|
TreeEntryTypeTree = "tree"
|
|
)
|
|
|
|
// RepoTreeEntry is one entry in a GitLab repository directory listing.
|
|
// Type is TreeEntryTypeBlob for files and TreeEntryTypeTree for directories
|
|
// (GitHub's equivalent endpoint calls these "file" and "dir").
|
|
type RepoTreeEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// Status represents GitLab connection status surfaced to the frontend.
|
|
type Status struct {
|
|
Authenticated bool `json:"authenticated"`
|
|
Username string `json:"username"`
|
|
AuthMethod string `json:"auth_method"` // "glab_cli", "pat", "none"
|
|
Host string `json:"host"`
|
|
TokenConfigured bool `json:"token_configured"`
|
|
TokenSecretID string `json:"token_secret_id,omitempty"`
|
|
GLabVersion string `json:"glab_version,omitempty"`
|
|
GLabOutdated bool `json:"glab_outdated,omitempty"`
|
|
RequiredScopes []string `json:"required_scopes"`
|
|
Diagnostics *AuthDiagnostics `json:"diagnostics,omitempty"`
|
|
// ConnectionError carries a transport-layer failure from the most
|
|
// recent IsAuthenticated probe (network down, 5xx, parse failure).
|
|
// 401/403 is NOT a connection error — that just means Authenticated
|
|
// is false. Empty when the probe succeeded or the host is unconfigured.
|
|
// Lets the frontend distinguish "not connected" from "GitLab is
|
|
// temporarily unreachable" so users don't delete a valid token during
|
|
// a transient outage.
|
|
ConnectionError string `json:"connection_error,omitempty"`
|
|
}
|
|
|
|
// ConfigureTokenRequest is the request body for configuring a GitLab token.
|
|
type ConfigureTokenRequest struct {
|
|
Token string `json:"token" binding:"required"`
|
|
}
|
|
|
|
// ConfigureHostRequest is the request body for setting the GitLab host URL.
|
|
type ConfigureHostRequest struct {
|
|
Host string `json:"host" binding:"required"`
|
|
}
|
|
|
|
// AuthDiagnostics captures the output of `glab auth status` (or REST probe)
|
|
// for troubleshooting.
|
|
type AuthDiagnostics struct {
|
|
Command string `json:"command"`
|
|
Output string `json:"output"`
|
|
ExitCode int `json:"exit_code"`
|
|
}
|
|
|
|
// MRFile represents a file changed in a merge request.
|
|
type MRFile struct {
|
|
Filename string `json:"filename"`
|
|
Status string `json:"status"` // added, deleted, modified, renamed
|
|
Additions int `json:"additions"`
|
|
Deletions int `json:"deletions"`
|
|
Patch string `json:"patch,omitempty"`
|
|
OldPath string `json:"old_path,omitempty"`
|
|
}
|
|
|
|
// MRCommitInfo represents a commit in a merge request.
|
|
//
|
|
// AuthorName is the commit's `author_name` (display name like "Jane Smith"),
|
|
// not a GitLab login handle — GitLab's MR-commits endpoint doesn't expose a
|
|
// login. Per-commit Additions/Deletions/FilesChanged are also not on the
|
|
// MR-commits endpoint; getting them would require a per-commit fetch
|
|
// against /projects/:id/repository/commits/:sha?stats=true, which is N+1
|
|
// and out of scope for v1. The fields were filled with 0 in earlier
|
|
// revisions and removed here to stop callers consuming bogus data.
|
|
type MRCommitInfo struct {
|
|
SHA string `json:"sha"`
|
|
Message string `json:"message"`
|
|
AuthorName string `json:"author_name"`
|
|
AuthorDate string `json:"author_date"`
|
|
}
|
|
|
|
// Issue represents a GitLab Issue.
|
|
type Issue struct {
|
|
ID int64 `json:"id"`
|
|
IID int `json:"iid"`
|
|
ProjectID int64 `json:"project_id"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
URL string `json:"url"`
|
|
WebURL string `json:"web_url"`
|
|
State string `json:"state"` // opened, closed
|
|
AuthorUsername string `json:"author_username"`
|
|
ProjectNamespace string `json:"project_namespace"`
|
|
ProjectPath string `json:"project_path"`
|
|
Labels []string `json:"labels"`
|
|
Assignees []string `json:"assignees"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
|
}
|
|
|
|
// TaskMR associates a kandev task with a GitLab merge request, parallel to
|
|
// github.TaskPR. RepositoryID may be empty for single-repo tasks; multi-repo
|
|
// task launches set it so each repo's MR is distinguishable on the same
|
|
// (task, iid) key. ProjectPath holds the GitLab path-with-namespace
|
|
// ("group/project") used as the API :id.
|
|
//
|
|
// GitLab-shaped fields differ from GitHub:
|
|
// - State is "open" | "closed" | "merged" | "locked" (normalised from
|
|
// GitLab's "opened" wording).
|
|
// - ApprovalState is "" | "approved" | "pending" — derived from approvals
|
|
// received vs RequiredApprovals.
|
|
// - PipelineState mirrors GitHub's ChecksState ("success", "failure",
|
|
// "pending", "") — GitLab pipelines are the analogue of GitHub checks.
|
|
// - MergeStatus carries GitLab's own merge_status string verbatim
|
|
// (can_be_merged, cannot_be_merged, unchecked, …) for debugging.
|
|
type TaskMR struct {
|
|
ID string `json:"id" db:"id"`
|
|
TaskID string `json:"task_id" db:"task_id"`
|
|
RepositoryID string `json:"repository_id,omitempty" db:"repository_id"`
|
|
Host string `json:"host" db:"host"` // gitlab base URL the MR lives on
|
|
ProjectPath string `json:"project_path" db:"project_path"` // namespace/path
|
|
MRIID int `json:"mr_iid" db:"mr_iid"` // GitLab per-project sequential id
|
|
MRURL string `json:"mr_url" db:"mr_url"`
|
|
MRTitle string `json:"mr_title" db:"mr_title"`
|
|
HeadBranch string `json:"head_branch" db:"head_branch"`
|
|
BaseBranch string `json:"base_branch" db:"base_branch"`
|
|
AuthorUsername string `json:"author_username" db:"author_username"`
|
|
State string `json:"state" db:"state"` // open, closed, merged, locked
|
|
ApprovalState string `json:"approval_state" db:"approval_state"` // approved, pending, ""
|
|
PipelineState string `json:"pipeline_state" db:"pipeline_state"` // success, failure, pending, ""
|
|
MergeStatus string `json:"merge_status" db:"merge_status"`
|
|
Draft bool `json:"draft" db:"draft"`
|
|
ApprovalCount int `json:"approval_count" db:"approval_count"`
|
|
RequiredApprovals int `json:"required_approvals" db:"required_approvals"`
|
|
PipelineJobsTotal int `json:"pipeline_jobs_total" db:"pipeline_jobs_total"`
|
|
PipelineJobsPass int `json:"pipeline_jobs_pass" db:"pipeline_jobs_pass"`
|
|
// DetailedMergeStatus, ReviewerCount and UnapprovedReviewers back the
|
|
// "awaiting review" summary label (Q2) and the auto-merge readiness
|
|
// gate (Q3). UnresolvedDiscussions is only ever set for MRs with
|
|
// automation enabled (Q4) — it stays 0 for lifecycle-only links.
|
|
DetailedMergeStatus string `json:"detailed_merge_status,omitempty" db:"detailed_merge_status"`
|
|
ReviewerCount int `json:"reviewer_count" db:"reviewer_count"`
|
|
UnapprovedReviewers int `json:"unapproved_reviewers" db:"unapproved_reviewers"`
|
|
UnresolvedDiscussions int `json:"unresolved_discussions" db:"unresolved_discussions"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
MergedAt *time.Time `json:"merged_at,omitempty" db:"merged_at"`
|
|
ClosedAt *time.Time `json:"closed_at,omitempty" db:"closed_at"`
|
|
LastSyncedAt *time.Time `json:"last_synced_at,omitempty" db:"last_synced_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
// TaskMRsResponse is the shape returned by GET /workspaces/:id/task-mrs.
|
|
// Keyed by task ID; each task may have one entry per linked repository.
|
|
type TaskMRsResponse struct {
|
|
TaskMRs map[string][]*TaskMR `json:"task_mrs"`
|
|
}
|