262ecdf270
Co-authored-by: Carlos Florêncio <carlosmflorencio@gmail.com> Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
437 lines
17 KiB
Go
437 lines
17 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
var (
|
|
// ErrInvalidMRURL marks malformed MR URLs and URLs outside the configured host.
|
|
ErrInvalidMRURL = errors.New("invalid GitLab merge request URL")
|
|
// ErrTaskMRNotFound covers absent and cross-workspace task-MR resources.
|
|
ErrTaskMRNotFound = errors.New("task merge request association not found")
|
|
// ErrTaskMRRepositoryRequired prevents ambiguous links on multi-repository tasks.
|
|
ErrTaskMRRepositoryRequired = errors.New("repository_id is required for multi-repository tasks")
|
|
// ErrTaskMRRepositoryMismatch prevents an MR from being linked to a repository
|
|
// whose durable provider origin and project identity do not match the MR.
|
|
ErrTaskMRRepositoryMismatch = errors.New("repository does not match GitLab merge request")
|
|
)
|
|
|
|
func parseMRURLForHost(rawURL, configuredHost string) (string, int, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil {
|
|
return "", 0, ErrInvalidMRURL
|
|
}
|
|
origin, err := normalizeHostOrigin(parsed.Scheme + "://" + parsed.Host)
|
|
if err != nil {
|
|
return "", 0, ErrInvalidMRURL
|
|
}
|
|
if !sameConfiguredOrigin(origin, configuredHost) {
|
|
return "", 0, fmt.Errorf("%w: host does not match workspace connection", ErrInvalidMRURL)
|
|
}
|
|
const marker = "/-/merge_requests/"
|
|
path := strings.TrimRight(parsed.Path, "/")
|
|
markerIndex := strings.LastIndex(path, marker)
|
|
if markerIndex <= 0 {
|
|
return "", 0, ErrInvalidMRURL
|
|
}
|
|
projectPath := strings.Trim(path[:markerIndex], "/")
|
|
iidText := path[markerIndex+len(marker):]
|
|
if projectPath == "" || iidText == "" || strings.Contains(iidText, "/") {
|
|
return "", 0, ErrInvalidMRURL
|
|
}
|
|
iid, err := strconv.Atoi(iidText)
|
|
if err != nil || iid <= 0 {
|
|
return "", 0, ErrInvalidMRURL
|
|
}
|
|
return projectPath, iid, nil
|
|
}
|
|
|
|
// parseGitLabRemoteURLIdentity extracts a normalized (host origin, project
|
|
// path) pair from a git remote URL, accepting HTTPS, ssh://, and scp-style
|
|
// (git@host:path) forms. SSH remotes are normalized to an HTTPS-style origin
|
|
// so they can be host-compared against a configured GitLab connection host,
|
|
// which is always stored as an HTTP(S) origin. Returns empty strings when the
|
|
// URL cannot be parsed as an owner/repo remote.
|
|
func parseGitLabRemoteURLIdentity(remoteURL string) (host, projectPath string) {
|
|
const sshScheme = "ssh"
|
|
remoteURL = strings.TrimSpace(remoteURL)
|
|
if remoteURL == "" {
|
|
return "", ""
|
|
}
|
|
scheme, hostname, path, ok := splitRemoteURLIdentity(remoteURL)
|
|
if !ok {
|
|
return "", ""
|
|
}
|
|
scheme = strings.ToLower(scheme)
|
|
if scheme != mentionHTTPScheme && scheme != mentionHTTPSScheme && scheme != sshScheme {
|
|
return "", ""
|
|
}
|
|
if scheme == sshScheme {
|
|
scheme = mentionHTTPSScheme
|
|
}
|
|
path = strings.TrimSuffix(strings.Trim(strings.TrimSpace(path), "/"), ".git")
|
|
if hostname == "" || path == "" || !strings.Contains(path, "/") {
|
|
return "", ""
|
|
}
|
|
return scheme + "://" + hostname, path
|
|
}
|
|
|
|
// splitRemoteURLIdentity resolves the (scheme, hostname, path) triple for
|
|
// either a scp-style shorthand (git@host:path) or a URL-form remote
|
|
// (https://, ssh://, etc). Extracted from parseGitLabRemoteURLIdentity to
|
|
// keep its cyclomatic complexity within the linter budget.
|
|
func splitRemoteURLIdentity(remoteURL string) (scheme, hostname, path string, ok bool) {
|
|
const sshScheme = "ssh"
|
|
if strings.Contains(remoteURL, "@") && !strings.Contains(remoteURL, "://") {
|
|
// scp-style shorthand: git@host:group/sub/project.git (host may be a
|
|
// bracketed IPv6 literal, e.g. git@[::1]:group/project.git, whose
|
|
// embedded colons must not be mistaken for the host/path separator).
|
|
_, rest, _ := strings.Cut(remoteURL, "@")
|
|
h, p, cutOK := cutSCPHostPath(rest)
|
|
if !cutOK {
|
|
return "", "", "", false
|
|
}
|
|
return sshScheme, h, p, true
|
|
}
|
|
parsed, err := url.Parse(remoteURL)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return "", "", "", false
|
|
}
|
|
hostname = remoteHost(parsed)
|
|
if strings.EqualFold(parsed.Scheme, sshScheme) {
|
|
// SSH transport ports (e.g. ssh://git@host:2222/...) are unrelated to
|
|
// the GitLab web origin's port, so compare hostname only. Bracket
|
|
// IPv6 literals so the synthesized origin stays a valid URL (an
|
|
// unbracketed "https://::1" is not).
|
|
hostname = bracketedHostname(parsed.Hostname())
|
|
}
|
|
return parsed.Scheme, hostname, parsed.Path, true
|
|
}
|
|
|
|
// cutSCPHostPath splits an scp-style remote's "host:path" segment (the part
|
|
// after "git@"), honoring bracketed IPv6 literals such as
|
|
// "[::1]:group/project.git" whose embedded colons would otherwise be
|
|
// mistaken for the host/path separator by a plain strings.Cut on ":".
|
|
func cutSCPHostPath(rest string) (host, path string, ok bool) {
|
|
if strings.HasPrefix(rest, "[") {
|
|
closeIdx := strings.Index(rest, "]")
|
|
if closeIdx < 0 || closeIdx+1 >= len(rest) || rest[closeIdx+1] != ':' {
|
|
return "", "", false
|
|
}
|
|
return rest[:closeIdx+1], rest[closeIdx+2:], true
|
|
}
|
|
return strings.Cut(rest, ":")
|
|
}
|
|
|
|
func remoteHost(parsed *url.URL) string {
|
|
hostname := parsed.Hostname()
|
|
if hostname == "" {
|
|
return ""
|
|
}
|
|
if port := parsed.Port(); port != "" {
|
|
return net.JoinHostPort(hostname, port)
|
|
}
|
|
return bracketedHostname(hostname)
|
|
}
|
|
|
|
// bracketedHostname wraps an IPv6 literal in brackets (as required for a
|
|
// valid URL host) and returns other hostnames unchanged.
|
|
func bracketedHostname(hostname string) string {
|
|
if hostname == "" || !strings.Contains(hostname, ":") {
|
|
return hostname
|
|
}
|
|
return "[" + hostname + "]"
|
|
}
|
|
|
|
// AssociateExistingMRByURL validates a workspace-owned task/repository pair,
|
|
// fetches the configured-host MR, and idempotently persists its association.
|
|
// `repositoryID` is repositories.ID (or empty to auto-resolve the task's
|
|
// single repository) — the same id space AssociatePRWithTask uses on the
|
|
// GitHub side. A row id from task_repositories instead fails closed here
|
|
// (ValidateTaskMRRepositoryIdentity / ResolveTaskMRRepository reject it)
|
|
// rather than silently duplicating the association.
|
|
func (s *Service) AssociateExistingMRByURL(
|
|
ctx context.Context,
|
|
workspaceID, taskID, repositoryID, mrURL string,
|
|
) (*TaskMR, error) {
|
|
store := s.requireStore()
|
|
if store == nil {
|
|
return nil, errors.New("gitlab store not configured")
|
|
}
|
|
client, err := s.ClientForWorkspace(ctx, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
projectPath, iid, err := parseMRURLForHost(mrURL, client.Host())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repositoryID, err = store.ResolveTaskMRRepository(ctx, workspaceID, taskID, repositoryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.ValidateTaskMRRepositoryIdentity(
|
|
ctx, workspaceID, taskID, repositoryID, client.Host(), projectPath,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
status, err := client.GetMRStatus(ctx, projectPath, iid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch merge request: %w", err)
|
|
}
|
|
if err := validateReturnedMRIdentity(status, client.Host(), projectPath, iid); err != nil {
|
|
return nil, ErrTaskMRNotFound
|
|
}
|
|
association := taskMRFromStatus(taskID, repositoryID, client.Host(), projectPath, status)
|
|
if err := store.UpsertTaskMR(ctx, association); err != nil {
|
|
return nil, fmt.Errorf("upsert task MR: %w", err)
|
|
}
|
|
s.reconcileComparisonTarget(ctx, taskID, client.Host(), status.MR)
|
|
s.publishTaskMRUpdated(ctx, workspaceID, association)
|
|
return association, nil
|
|
}
|
|
|
|
// AssociateExistingMRByURLForSession wraps AssociateExistingMRByURL for
|
|
// callers that have a concrete session to key a refresh watch with — the
|
|
// Create-MR action and manual URL linking triggered from a session's git
|
|
// activity. This mirrors GitHub's split between AssociatePRByURLForWorkspace
|
|
// (session-aware, creates a watch) and AssociateExistingPRByURLForWorkspace
|
|
// (the workspace-level HTTP endpoint, no session, no watch). Watch creation
|
|
// is best-effort: the association already succeeded, so a watch failure is
|
|
// logged rather than surfaced, matching ensureWatchForLinkedMR's convention.
|
|
func (s *Service) AssociateExistingMRByURLForSession(
|
|
ctx context.Context,
|
|
workspaceID, sessionID, taskID, repositoryID, mrURL string,
|
|
) (*TaskMR, error) {
|
|
association, err := s.AssociateExistingMRByURL(ctx, workspaceID, taskID, repositoryID, mrURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// A cancelable ctx (e.g. an HTTP request context that times out right
|
|
// after the association commits) must not skip this: the association
|
|
// already succeeded and is returned as a success below, so a canceled
|
|
// EnsureMRWatch would silently leave that MR with no refresh watch until
|
|
// another push recreates it. Detach from ctx's cancellation the same way
|
|
// DeleteReviewWatch does for its own post-commit side effect, but bound
|
|
// it with the same timeout so a stalled store call can't hang forever.
|
|
watchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), watchDeleteTimeout)
|
|
defer cancel()
|
|
if _, err := s.EnsureMRWatch(
|
|
watchCtx, sessionID, taskID, association.RepositoryID, association.ProjectPath,
|
|
association.MRIID, association.HeadBranch,
|
|
); err != nil {
|
|
s.logger.Warn("failed to ensure MR watch after URL association",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("task_id", taskID),
|
|
zap.Int("mr_iid", association.MRIID),
|
|
zap.Error(err))
|
|
}
|
|
return association, nil
|
|
}
|
|
|
|
func validateReturnedMRIdentity(status *MRStatus, host, projectPath string, iid int) error {
|
|
if status == nil || status.MR == nil {
|
|
return ErrTaskMRNotFound
|
|
}
|
|
returnedProjectPath, returnedIID, err := parseMRURLForHost(status.MR.WebURL, host)
|
|
if err != nil || status.MR.IID != iid || returnedIID != iid {
|
|
return ErrTaskMRNotFound
|
|
}
|
|
if !strings.EqualFold(returnedProjectPath, projectPath) {
|
|
return ErrTaskMRNotFound
|
|
}
|
|
if status.MR.ProjectPath != "" &&
|
|
!strings.EqualFold(strings.Trim(status.MR.ProjectPath, "/"), projectPath) {
|
|
return ErrTaskMRNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsConfiguredGitLabHost reports whether remoteURL's host matches the
|
|
// workspace's own configured GitLab connection, self-managed or gitlab.com.
|
|
// Used by push-detection provider routing to recognize self-managed GitLab
|
|
// repositories: unlike github.com/gitlab.com, they never get a durable
|
|
// "gitlab" provider tag at discovery time (see
|
|
// task/service.resolveRepositoryProviderIdentity), so remote_url is their
|
|
// only durable identity signal, and it must be compared against the actual
|
|
// configured host rather than a github.com/gitlab.com hostname allowlist.
|
|
//
|
|
// Best-effort: returns false on any lookup failure (unconfigured workspace,
|
|
// unparsable remote) rather than erroring, since callers use this only to
|
|
// pick a routing branch — ValidateTaskMRRepositoryIdentity is still the real
|
|
// security boundary and still runs before any GitLab API call.
|
|
func (s *Service) IsConfiguredGitLabHost(ctx context.Context, workspaceID, remoteURL string) bool {
|
|
host, _ := parseGitLabRemoteURLIdentity(remoteURL)
|
|
if host == "" {
|
|
return false
|
|
}
|
|
cfg, err := s.GetConfigForWorkspace(ctx, workspaceID)
|
|
if err != nil || cfg == nil || cfg.Host == "" {
|
|
return false
|
|
}
|
|
return sameGitLabHost(host, cfg.Host)
|
|
}
|
|
|
|
// UnlinkTaskMR removes one association and its matching refresh watch without
|
|
// mutating the task, other associations, or the upstream merge request.
|
|
func (s *Service) UnlinkTaskMR(ctx context.Context, workspaceID, associationID string) error {
|
|
store := s.requireStore()
|
|
if store == nil {
|
|
return errors.New("gitlab store not configured")
|
|
}
|
|
association, lookupErr := store.GetTaskMRByID(ctx, associationID)
|
|
if lookupErr != nil {
|
|
return lookupErr
|
|
}
|
|
if association != nil && s.comparisonTargetObserver != nil && association.RepositoryID != "" {
|
|
if err := s.comparisonTargetObserver.RemoveComparisonTargetForChange(
|
|
ctx,
|
|
association.TaskID,
|
|
association.RepositoryID,
|
|
taskmodels.ComparisonTargetProviderGitLab,
|
|
taskmodels.ComparisonTargetKindMergeRequest,
|
|
association.MRIID,
|
|
); err != nil {
|
|
if s.logger != nil {
|
|
s.logger.Warn("GitLab comparison target detach cleanup failed",
|
|
zap.String("task_id", association.TaskID), zap.Int("mr_iid", association.MRIID), zap.Error(err))
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
if err := store.DeleteTaskMRForWorkspace(ctx, workspaceID, associationID); err != nil {
|
|
return err
|
|
}
|
|
if s.eventBus != nil && association != nil {
|
|
event := bus.NewEvent(events.GitLabTaskMRDeleted, eventSource, &TaskMRDeletedEvent{
|
|
WorkspaceID: workspaceID, TaskID: association.TaskID, AssociationID: association.ID,
|
|
})
|
|
if err := s.eventBus.Publish(ctx, events.GitLabTaskMRDeleted, event); err != nil {
|
|
s.logger.Debug("failed to publish GitLab task MR deletion event", zap.Error(err))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func taskMRFromStatus(taskID, repositoryID, host, projectPath string, status *MRStatus) *TaskMR {
|
|
mr := status.MR
|
|
now := time.Now().UTC()
|
|
return &TaskMR{
|
|
TaskID: taskID, RepositoryID: repositoryID, Host: host,
|
|
ProjectPath: projectPath, MRIID: mr.IID, MRURL: mr.WebURL, MRTitle: mr.Title,
|
|
HeadBranch: mr.HeadBranch, HeadSHA: mr.HeadSHA, BaseBranch: mr.BaseBranch, BaseSHA: mr.BaseSHA, AuthorUsername: mr.AuthorUsername,
|
|
State: mr.State, ApprovalState: status.ApprovalState, PipelineState: status.PipelineState,
|
|
MergeStatus: status.MergeStatus, Draft: mr.Draft, ApprovalCount: status.ApprovalCount,
|
|
RequiredApprovals: status.RequiredApprovals, PipelineJobsTotal: status.PipelineJobsTotal,
|
|
PipelineJobsPass: status.PipelineJobsPassing, CreatedAt: mr.CreatedAt, MergedAt: mr.MergedAt,
|
|
ClosedAt: mr.ClosedAt, LastSyncedAt: &now,
|
|
DetailedMergeStatus: status.DetailedMergeStatus, ReviewerCount: status.ReviewerCount,
|
|
UnapprovedReviewers: status.UnapprovedReviewers,
|
|
// UnresolvedDiscussions is deliberately NOT taken from status (it is
|
|
// always 0 there — GetMRStatus skips discussions, see MRStatus's type
|
|
// doc). The auto-fix/auto-merge evaluation pass persists it separately
|
|
// via Store.UpdateTaskMRUnresolvedDiscussions for automation-subscribed
|
|
// MRs only; overwriting it here on every lifecycle sync would clobber
|
|
// that value back to 0 on the very next poll.
|
|
}
|
|
}
|
|
|
|
// TaskMRUpdatedEvent is the payload published on events.GitLabTaskMRUpdated.
|
|
// Exported (not just the embedded *TaskMR) so orchestrator-side consumers —
|
|
// notably the MR lifecycle automation pass — can type-assert event.Data
|
|
// without reaching into an unexported gitlab-package type.
|
|
type TaskMRUpdatedEvent struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Reviewers []MRReviewer `json:"reviewers"`
|
|
ReviewersValid bool `json:"reviewers_valid,omitempty"`
|
|
*TaskMR
|
|
}
|
|
|
|
// GetWorkspaceID implements the websocket broadcaster's workspace-routing
|
|
// interface (internal/gateway/websocket.extractWorkspaceID). Without it, the
|
|
// broadcaster's map-only field extractor cannot see WorkspaceID on this
|
|
// struct payload and always treats the event as workspace-unknown.
|
|
func (e *TaskMRUpdatedEvent) GetWorkspaceID() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return e.WorkspaceID
|
|
}
|
|
|
|
// TaskMRDeletedEvent identifies one task-MR association removed from active
|
|
// task surfaces. The upstream merge request remains unchanged.
|
|
type TaskMRDeletedEvent struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
TaskID string `json:"task_id"`
|
|
AssociationID string `json:"association_id"`
|
|
}
|
|
|
|
// GetWorkspaceID lets the websocket broadcaster route the deletion to the
|
|
// owning workspace.
|
|
func (e TaskMRDeletedEvent) GetWorkspaceID() string { return e.WorkspaceID }
|
|
|
|
// publishTaskMRLifecycleSyncEvent publishes a TaskMRUpdatedEvent after the
|
|
// poller's lifecycle sync pass refreshes a linked MR (AC22). Unlike
|
|
// publishTaskMRUpdated (an HTTP/link-flow caller that already has a trusted
|
|
// workspace ID), this path resolves the workspace itself — a lookup failure
|
|
// skips publishing rather than emitting an event the websocket layer could
|
|
// broadcast instance-wide; the next poll retries.
|
|
func (s *Service) publishTaskMRLifecycleSyncEvent(
|
|
ctx context.Context, mr *TaskMR, reviewers []MRReviewer, reviewersValid bool,
|
|
) {
|
|
if mr == nil {
|
|
return
|
|
}
|
|
s.mu.RLock()
|
|
eventBus := s.eventBus
|
|
store := s.store
|
|
s.mu.RUnlock()
|
|
if eventBus == nil || store == nil {
|
|
return
|
|
}
|
|
workspaceID, err := store.WorkspaceIDForTask(ctx, mr.TaskID)
|
|
if err != nil {
|
|
s.logger.Debug("gitlab: resolve workspace for MR lifecycle sync event",
|
|
zap.String("task_id", mr.TaskID), zap.Error(err))
|
|
return
|
|
}
|
|
event := bus.NewEvent(events.GitLabTaskMRUpdated, eventSource, &TaskMRUpdatedEvent{
|
|
WorkspaceID: workspaceID,
|
|
Reviewers: reviewers,
|
|
ReviewersValid: reviewersValid,
|
|
TaskMR: mr,
|
|
})
|
|
if err := eventBus.Publish(ctx, events.GitLabTaskMRUpdated, event); err != nil {
|
|
s.logger.Debug("publish GitLab task MR lifecycle sync event", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (s *Service) publishTaskMRUpdated(ctx context.Context, workspaceID string, association *TaskMR) {
|
|
s.mu.RLock()
|
|
eventBus := s.eventBus
|
|
s.mu.RUnlock()
|
|
if eventBus == nil {
|
|
return
|
|
}
|
|
event := bus.NewEvent(events.GitLabTaskMRUpdated, eventSource, &TaskMRUpdatedEvent{
|
|
WorkspaceID: workspaceID,
|
|
TaskMR: association,
|
|
})
|
|
if err := eventBus.Publish(ctx, events.GitLabTaskMRUpdated, event); err != nil {
|
|
s.logger.Debug("publish GitLab task MR update", zap.Error(err))
|
|
}
|
|
}
|