Files
multica/server/internal/integrations/vcs/gitlab.go
dixonl90 581d9527ba feat(vcs): self-hosted Git providers (Forgejo, Gitea, GitLab) alongside GitHub (MUL-3772) (#5006)
Adds self-hosted Git provider support (Forgejo, Gitea, GitLab) alongside GitHub:
per-workspace token connection, a provider-dispatched webhook, PR/MR and CI
mirroring, and the shared issue auto-link / auto-close machinery. Off until
MULTICA_VCS_SECRET_KEY is set, so existing deployments are unaffected.

Co-authored-by: Bohan <bohan@devv.ai>
2026-07-24 15:01:27 +08:00

249 lines
8.7 KiB
Go

package vcs
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// gitlabProvider implements Provider for GitLab, which differs from
// Forgejo/Gitea on every axis: /api/v4 with a PRIVATE-TOKEN header, webhooks
// authenticated by a plaintext X-Gitlab-Token compare (no HMAC), an
// X-Gitlab-Event header, "merge request" terminology, and pipeline events for
// CI. The normalized PullRequestEvent/CIStatusEvent hide all of that from the
// handler.
type gitlabProvider struct{}
func init() { register(gitlabProvider{}) }
func (gitlabProvider) Kind() Kind { return KindGitLab }
func (gitlabProvider) EventKind(h http.Header) EventKind {
switch h.Get("X-Gitlab-Event") {
case "Merge Request Hook":
return EventPullRequest
case "Pipeline Hook":
return EventCIStatus
default:
return EventOther
}
}
// VerifySignature compares the X-Gitlab-Token header to the stored secret in
// constant time. GitLab does not HMAC-sign webhook bodies; the shared token is
// the whole authentication, so an empty stored secret never validates.
func (gitlabProvider) VerifySignature(secret string, h http.Header, _ []byte) bool {
if secret == "" {
return false
}
got := h.Get("X-Gitlab-Token")
return subtle.ConstantTimeCompare([]byte(got), []byte(secret)) == 1
}
type glMergeRequestPayload struct {
ObjectKind string `json:"object_kind"`
User struct {
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
} `json:"user"`
Project struct {
PathWithNamespace string `json:"path_with_namespace"`
} `json:"project"`
ObjectAttributes struct {
IID int32 `json:"iid"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"` // opened|closed|merged|locked
Action string `json:"action"`
SourceBranch string `json:"source_branch"`
URL string `json:"url"`
Draft bool `json:"draft"`
WorkInProgress bool `json:"work_in_progress"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LastCommit struct {
ID string `json:"id"`
} `json:"last_commit"`
} `json:"object_attributes"`
}
func (gitlabProvider) ParsePullRequest(body []byte) (PullRequestEvent, error) {
var d glMergeRequestPayload
if err := json.Unmarshal(body, &d); err != nil {
return PullRequestEvent{}, err
}
owner, name := splitNamespace(d.Project.PathWithNamespace)
draft := d.ObjectAttributes.Draft || d.ObjectAttributes.WorkInProgress ||
strings.HasPrefix(strings.ToLower(d.ObjectAttributes.Title), "draft:")
return PullRequestEvent{
Action: d.ObjectAttributes.Action,
RepoOwner: owner,
RepoName: name,
Number: d.ObjectAttributes.IID,
Title: d.ObjectAttributes.Title,
Body: d.ObjectAttributes.Description,
State: normalizeGitLabMRState(d.ObjectAttributes.State, draft),
HTMLURL: d.ObjectAttributes.URL,
Branch: d.ObjectAttributes.SourceBranch,
HeadSHA: d.ObjectAttributes.LastCommit.ID,
AuthorLogin: d.User.Username,
AuthorAvatarURL: d.User.AvatarURL,
CreatedAt: normalizeGitLabTime(d.ObjectAttributes.CreatedAt),
UpdatedAt: normalizeGitLabTime(d.ObjectAttributes.UpdatedAt),
}, nil
}
// normalizeGitLabTime converts GitLab's webhook timestamp format
// ("2017-09-20 08:31:45 UTC") into the RFC3339 the rest of the pipeline expects
// (PullRequestEvent/CIStatusEvent document their time fields as "RFC3339 or
// empty", and the shared parser is RFC3339-only). Without this every GitLab
// event's timestamp failed to parse and was silently replaced with ingestion
// time, defeating the PR-upsert and commit-status monotonic guards for GitLab
// and skewing PR list ordering. Normalizing here keeps the fix inside the
// provider adapter, matching the package's "providers contribute only what
// differs" design rather than teaching the shared parser a GitLab dialect.
// Output uses RFC3339Nano to preserve any sub-second precision GitLab sends, so
// two events within the same wall-clock second still order correctly under the
// monotonic guards (the shared time.RFC3339 parser accepts the fractional part).
// Unrecognized input returns "" so the handler falls back to ingestion time.
func normalizeGitLabTime(s string) string {
if s == "" {
return ""
}
for _, layout := range []string{
time.RFC3339,
time.RFC3339Nano,
"2006-01-02 15:04:05 MST",
"2006-01-02 15:04:05 -0700",
"2006-01-02 15:04:05.999999 MST",
} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC().Format(time.RFC3339Nano)
}
}
return ""
}
// normalizeGitLabMRState maps GitLab MR states onto open/closed/merged/draft.
// "locked" is a transient open sub-state, so it reads as open.
func normalizeGitLabMRState(state string, draft bool) string {
switch state {
case "merged":
return "merged"
case "closed":
return "closed"
default: // opened, locked
if draft {
return "draft"
}
return "open"
}
}
type glPipelinePayload struct {
ObjectKind string `json:"object_kind"`
ObjectAttributes struct {
SHA string `json:"sha"`
Status string `json:"status"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
FinishedAt string `json:"finished_at"`
} `json:"object_attributes"`
}
func (gitlabProvider) ParseCIStatus(body []byte) (CIStatusEvent, error) {
var d glPipelinePayload
if err := json.Unmarshal(body, &d); err != nil {
return CIStatusEvent{}, err
}
// Prefer the pipeline's finished_at (the state transition we're recording);
// fall back to created_at. Normalized to RFC3339 so the commit-status
// monotonic guard has a real, comparable timestamp instead of ingestion time.
updatedAt := d.ObjectAttributes.FinishedAt
if updatedAt == "" {
updatedAt = d.ObjectAttributes.CreatedAt
}
return CIStatusEvent{
SHA: d.ObjectAttributes.SHA,
// GitLab pipelines are modelled as one status per commit, not per named
// check, so a stable synthetic context keys the single status row.
// Known limitations of this simplification (acceptable for the default
// branch-pipeline config; revisit if needed):
// - Merge-train / merged-results pipelines run on a synthetic merge
// commit whose SHA differs from the MR head (last_commit.id), so the
// head_sha join won't match and the card shows no checks.
// - Multiple pipelines on one commit (e.g. a scheduled pipeline plus an
// MR pipeline) collapse into this single context, so the last one to
// fire wins per commit.
Context: "gitlab/pipeline",
State: normalizeGitLabPipelineState(d.ObjectAttributes.Status),
TargetURL: d.ObjectAttributes.URL,
UpdatedAt: normalizeGitLabTime(updatedAt),
}, nil
}
// normalizeGitLabPipelineState maps pipeline statuses onto passed/failed/
// pending. skipped is a pass (nothing failed); canceled is a failure-class
// terminal, matching how GitHub treats cancelled.
func normalizeGitLabPipelineState(s string) string {
switch s {
case "success", "skipped":
return "passed"
case "failed", "canceled":
return "failed"
default: // created, waiting_for_resource, preparing, pending, running, manual, scheduled
return "pending"
}
}
func (gitlabProvider) ValidateToken(ctx context.Context, instanceURL, token string) (Account, error) {
endpoint := NormalizeInstanceURL(instanceURL) + "/api/v4/user"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return Account{}, fmt.Errorf("gitlab: build request: %w", err)
}
req.Header.Set("PRIVATE-TOKEN", token)
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return Account{}, fmt.Errorf("gitlab: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return Account{}, ErrUnauthorized
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return Account{}, fmt.Errorf("gitlab: GET /user: status %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var u struct {
Username string `json:"username"`
}
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return Account{}, fmt.Errorf("gitlab: decode user: %w", err)
}
if u.Username == "" {
return Account{}, errors.New("gitlab: user response missing username")
}
return Account{Login: u.Username}, nil
}
// splitNamespace splits a GitLab path_with_namespace ("group/subgroup/repo")
// into owner ("group/subgroup") and repo name ("repo"). Subgroups are kept in
// the owner so the identity stays unique.
func splitNamespace(path string) (owner, name string) {
path = strings.Trim(path, "/")
if i := strings.LastIndex(path, "/"); i >= 0 {
return path[:i], path[i+1:]
}
return "", path
}