460 lines
15 KiB
Go
460 lines
15 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
)
|
|
|
|
// secretNameToken is the canonical secret-store name for the GitLab PAT.
|
|
const secretNameToken = "GITLAB_TOKEN"
|
|
|
|
// ErrInvalidToken is returned by ConfigureToken when the supplied token is
|
|
// empty or fails the /user probe. The controller layer uses errors.Is to
|
|
// route this to HTTP 400 instead of 500 — a sentinel is more durable than
|
|
// string-matching against the error message, which would silently break
|
|
// the status mapping if the message wording changes.
|
|
var ErrInvalidToken = errors.New("invalid token")
|
|
|
|
// SecretManager handles secret create/update/delete for the token.
|
|
type SecretManager interface {
|
|
Create(ctx context.Context, name, value string) (id string, err error)
|
|
Update(ctx context.Context, id, value string) error
|
|
Delete(ctx context.Context, id string) error
|
|
}
|
|
|
|
// HostStore persists the configured GitLab host.
|
|
type HostStore interface {
|
|
GetHost(ctx context.Context) (string, error)
|
|
SetHost(ctx context.Context, host string) error
|
|
}
|
|
|
|
// TaskDeleter deletes tasks by ID. Used for cleaning up merged MR tasks.
|
|
type TaskDeleter interface {
|
|
DeleteTask(ctx context.Context, taskID string) error
|
|
}
|
|
|
|
// TaskDeleterWithReason is an optional extension of TaskDeleter that lets the
|
|
// cleanup path attach a machine-readable deletion reason (e.g.
|
|
// "pr_merged_or_closed") to the published task.deleted event. When the wired
|
|
// deleter does not implement this, cleanup falls back to plain DeleteTask.
|
|
type TaskDeleterWithReason interface {
|
|
DeleteTaskWithReason(ctx context.Context, taskID, reason string) error
|
|
}
|
|
|
|
// TaskSessionChecker checks whether the user genuinely engaged with a task.
|
|
type TaskSessionChecker interface {
|
|
HasUserAuthoredMessage(ctx context.Context, taskID string) (bool, error)
|
|
}
|
|
|
|
// Service coordinates GitLab integration operations.
|
|
type Service struct {
|
|
mu sync.RWMutex
|
|
host string
|
|
client Client
|
|
authMethod string
|
|
secrets SecretProvider
|
|
secretManager SecretManager
|
|
hostStore HostStore
|
|
store *Store
|
|
eventBus bus.EventBus
|
|
taskDeleter TaskDeleter
|
|
taskSessionChecker TaskSessionChecker
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// SetEventBus wires the event bus for publishing review/issue/feedback events.
|
|
func (s *Service) SetEventBus(b bus.EventBus) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.eventBus = b
|
|
}
|
|
|
|
// SetTaskDeleter wires the task-deletion dependency used by cleanup sweepers.
|
|
func (s *Service) SetTaskDeleter(d TaskDeleter) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.taskDeleter = d
|
|
}
|
|
|
|
// SetTaskSessionChecker wires the user-engagement check used by cleanup.
|
|
func (s *Service) SetTaskSessionChecker(c TaskSessionChecker) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.taskSessionChecker = c
|
|
}
|
|
|
|
// SetStore wires the task↔MR persistence layer. Optional — when nil the
|
|
// task-mr endpoints return empty results and SyncTaskMR is a no-op.
|
|
func (s *Service) SetStore(store *Store) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.store = store
|
|
}
|
|
|
|
// NewService builds a Service from an already-resolved client. Callers
|
|
// typically use Provide() instead of constructing this directly.
|
|
func NewService(host string, client Client, authMethod string, secrets SecretProvider, log *logger.Logger) *Service {
|
|
if host == "" {
|
|
host = DefaultHost
|
|
}
|
|
return &Service{
|
|
host: host,
|
|
client: client,
|
|
authMethod: authMethod,
|
|
secrets: secrets,
|
|
logger: log,
|
|
}
|
|
}
|
|
|
|
// SetSecretManager wires the secret-write dependency.
|
|
func (s *Service) SetSecretManager(m SecretManager) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.secretManager = m
|
|
}
|
|
|
|
// SetHostStore wires the host-persistence dependency.
|
|
func (s *Service) SetHostStore(h HostStore) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.hostStore = h
|
|
}
|
|
|
|
// Client returns the current underlying Client (may be a NoopClient).
|
|
func (s *Service) Client() Client {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.client
|
|
}
|
|
|
|
// Host returns the configured GitLab base URL.
|
|
func (s *Service) Host() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.host
|
|
}
|
|
|
|
// GetStatus returns the connection status surfaced to the frontend.
|
|
func (s *Service) GetStatus(ctx context.Context) (*Status, error) {
|
|
s.mu.RLock()
|
|
client := s.client
|
|
host := s.host
|
|
authMethod := s.authMethod
|
|
s.mu.RUnlock()
|
|
|
|
tokenConfigured, tokenSecretID, err := s.findTokenSecret(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("look up token secret: %w", err)
|
|
}
|
|
|
|
if client == nil {
|
|
// Defensive — every Provide/NewClient path returns at least a
|
|
// NoopClient, so in practice this branch is unreachable. Keep
|
|
// RequiredScopes as an empty slice so the JSON contract matches
|
|
// the always-an-array shape declared on the TypeScript side.
|
|
return &Status{
|
|
AuthMethod: AuthMethodNone,
|
|
Host: host,
|
|
TokenConfigured: tokenConfigured,
|
|
TokenSecretID: tokenSecretID,
|
|
RequiredScopes: []string{},
|
|
}, nil
|
|
}
|
|
|
|
authenticated, authErr := client.IsAuthenticated(ctx)
|
|
username := ""
|
|
if authenticated {
|
|
username, _ = client.GetAuthenticatedUser(ctx)
|
|
}
|
|
|
|
status := &Status{
|
|
Authenticated: authenticated,
|
|
Username: username,
|
|
AuthMethod: authMethod,
|
|
Host: host,
|
|
TokenConfigured: tokenConfigured,
|
|
TokenSecretID: tokenSecretID,
|
|
RequiredScopes: []string{"api", "read_user"},
|
|
}
|
|
if authErr != nil {
|
|
// IsAuthenticated returns (false, nil) for 401/403 — that's a
|
|
// known "bad token" signal. Anything reaching here is a transport
|
|
// failure (network, 5xx, parse error) the user needs to see as
|
|
// "GitLab unreachable" rather than "not connected", so they don't
|
|
// delete a valid token during a transient outage.
|
|
status.ConnectionError = authErr.Error()
|
|
}
|
|
if g, ok := client.(*GLabClient); ok {
|
|
status.GLabVersion = g.Version()
|
|
}
|
|
return status, nil
|
|
}
|
|
|
|
// ConfigureToken stores a new PAT in the secret manager and rebuilds the
|
|
// client. Validates the token by calling /user before persisting.
|
|
func (s *Service) ConfigureToken(ctx context.Context, token string) error {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return fmt.Errorf("%w: empty", ErrInvalidToken)
|
|
}
|
|
if s.secretManager == nil {
|
|
return errors.New("secret manager not configured")
|
|
}
|
|
|
|
s.mu.RLock()
|
|
host := s.host
|
|
s.mu.RUnlock()
|
|
|
|
probe := NewPATClient(host, token)
|
|
if _, probeErr := probe.GetAuthenticatedUser(ctx); probeErr != nil {
|
|
// Only 401 / 403 mean "the token is bad". Network failures, 5xx,
|
|
// DNS errors etc. propagate without the ErrInvalidToken wrap so the
|
|
// controller surfaces them as "GitLab unreachable" instead of
|
|
// "invalid token" — otherwise during a GitLab outage a user might
|
|
// delete their (valid) token assuming it had been rejected.
|
|
var apiErr *APIError
|
|
if errors.As(probeErr, &apiErr) && (apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) {
|
|
return fmt.Errorf("%w: %w", ErrInvalidToken, probeErr)
|
|
}
|
|
return fmt.Errorf("probe token: %w", probeErr)
|
|
}
|
|
|
|
configured, secretID, err := s.findTokenSecret(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("look up token secret: %w", err)
|
|
}
|
|
switch {
|
|
case configured && secretID != "":
|
|
if err := s.secretManager.Update(ctx, secretID, token); err != nil {
|
|
return fmt.Errorf("update token: %w", err)
|
|
}
|
|
default:
|
|
if _, err := s.secretManager.Create(ctx, secretNameToken, token); err != nil {
|
|
return fmt.Errorf("create token: %w", err)
|
|
}
|
|
}
|
|
|
|
// Build the installed client inside the write lock using the *current*
|
|
// s.host — if ConfigureHost ran between our snapshot above and now we'd
|
|
// otherwise install a client pointing at the previous host, leaving
|
|
// s.host and s.client desynced until the next reconfigure. The token
|
|
// was validated against `probe`; here we just construct a fresh client
|
|
// at the up-to-date host.
|
|
s.mu.Lock()
|
|
s.client = NewPATClient(s.host, token)
|
|
s.authMethod = AuthMethodPAT
|
|
installedHost := s.host
|
|
s.mu.Unlock()
|
|
// Log the host the installed client actually targets, not the
|
|
// pre-validation snapshot — a concurrent ConfigureHost between the
|
|
// two would otherwise leave the log entry referring to a stale value.
|
|
s.logger.Info("GitLab token configured", zap.String("host", installedHost))
|
|
return nil
|
|
}
|
|
|
|
// ClearToken removes the stored PAT and falls back to noop / glab.
|
|
func (s *Service) ClearToken(ctx context.Context) error {
|
|
if s.secretManager == nil {
|
|
return errors.New("secret manager not configured")
|
|
}
|
|
configured, secretID, err := s.findTokenSecret(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("look up token secret: %w", err)
|
|
}
|
|
if !configured || secretID == "" {
|
|
return nil
|
|
}
|
|
if err := s.secretManager.Delete(ctx, secretID); err != nil {
|
|
return fmt.Errorf("delete token: %w", err)
|
|
}
|
|
|
|
// Build the fallback client outside the write lock — NewClient may
|
|
// shell out to `glab auth status` (up to glabAuthTimeout = 10s), and
|
|
// holding the write lock for that long would freeze every concurrent
|
|
// GetStatus / GetMRFeedback / Client() call. Pre-build against a
|
|
// snapshot of s.host, then swap inside a short critical section.
|
|
// Guard the swap with a host-equality check so a concurrent
|
|
// ConfigureHost that already installed a newer client for a new
|
|
// host doesn't get clobbered by our stale-host build.
|
|
s.mu.RLock()
|
|
hostSnap := s.host
|
|
s.mu.RUnlock()
|
|
client, authMethod, _ := NewClient(ctx, hostSnap, s.secrets, s.logger)
|
|
s.mu.Lock()
|
|
if s.host == hostSnap {
|
|
s.client = client
|
|
s.authMethod = authMethod
|
|
}
|
|
// else: ConfigureHost moved s.host while we were probing — leave
|
|
// its (newer, correctly-targeted) client in place. The next
|
|
// reconfigure or auth-health poller will converge if needed.
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// ConfigureHost persists a new GitLab host URL and rebuilds the client.
|
|
// The host is normalized by stripping trailing slashes; an empty string
|
|
// resets to DefaultHost.
|
|
func (s *Service) ConfigureHost(ctx context.Context, host string) error {
|
|
host = strings.TrimRight(strings.TrimSpace(host), "/")
|
|
if host == "" {
|
|
host = DefaultHost
|
|
}
|
|
if !strings.HasPrefix(host, "https://") && !strings.HasPrefix(host, "http://") {
|
|
return errors.New("host must include scheme (https:// or http://)")
|
|
}
|
|
if err := CheckHost(ctx, host); err != nil {
|
|
return fmt.Errorf("host unreachable: %w", err)
|
|
}
|
|
|
|
// Persist before mutating the in-memory state so a failed write
|
|
// doesn't leave the running service pointing at a host that wasn't
|
|
// committed to disk.
|
|
if s.hostStore != nil {
|
|
if err := s.hostStore.SetHost(ctx, host); err != nil {
|
|
return fmt.Errorf("persist host: %w", err)
|
|
}
|
|
}
|
|
|
|
client, authMethod, _ := NewClient(ctx, host, s.secrets, s.logger)
|
|
s.mu.Lock()
|
|
s.host = host
|
|
s.client = client
|
|
s.authMethod = authMethod
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// GetMRFeedback proxies to the underlying client.
|
|
func (s *Service) GetMRFeedback(ctx context.Context, projectPath string, iid int) (*MRFeedback, error) {
|
|
return s.Client().GetMRFeedback(ctx, projectPath, iid)
|
|
}
|
|
|
|
// CreateMRDiscussionNote proxies to the underlying client.
|
|
func (s *Service) CreateMRDiscussionNote(ctx context.Context, projectPath string, iid int, discussionID, body string) (*MRNote, error) {
|
|
return s.Client().CreateMRDiscussionNote(ctx, projectPath, iid, discussionID, body)
|
|
}
|
|
|
|
// ResolveMRDiscussion proxies to the underlying client.
|
|
func (s *Service) ResolveMRDiscussion(ctx context.Context, projectPath string, iid int, discussionID string) error {
|
|
return s.Client().ResolveMRDiscussion(ctx, projectPath, iid, discussionID)
|
|
}
|
|
|
|
// findTokenSecret reports whether a GitLab token is stored in the secret
|
|
// store. Returns (configured, secretID, error). A nil secrets provider is
|
|
// treated as "not configured" without error; a List failure is returned so
|
|
// callers don't mistake a backend outage for "token absent" (which would
|
|
// hide a still-present secret from ClearToken / GetStatus).
|
|
func (s *Service) findTokenSecret(ctx context.Context) (bool, string, error) {
|
|
if s.secrets == nil {
|
|
return false, "", nil
|
|
}
|
|
items, err := s.secrets.List(ctx)
|
|
if err != nil {
|
|
return false, "", fmt.Errorf("list secrets: %w", err)
|
|
}
|
|
for _, item := range items {
|
|
if !item.HasValue {
|
|
continue
|
|
}
|
|
if item.Name == secretNameToken || item.Name == secretNameTokenLower {
|
|
return true, item.ID, nil
|
|
}
|
|
}
|
|
return false, "", nil
|
|
}
|
|
|
|
// --- Task ↔ MR association ---
|
|
|
|
// SyncTaskMR fetches the MR's current state from GitLab and upserts the
|
|
// task↔MR row. Called from the orchestrator when an agent creates an MR via
|
|
// the `pr` skill, and from the topbar's manual refresh. Returns the upserted
|
|
// row so the caller can broadcast it on the WS bus.
|
|
//
|
|
// repositoryID is the task's repository UUID (empty for single-repo tasks).
|
|
// projectPath is the GitLab namespace/path. iid is the MR's per-project id.
|
|
func (s *Service) SyncTaskMR(ctx context.Context, taskID, repositoryID, projectPath string, iid int) (*TaskMR, error) {
|
|
s.mu.RLock()
|
|
client := s.client
|
|
store := s.store
|
|
host := s.host
|
|
s.mu.RUnlock()
|
|
if store == nil {
|
|
return nil, errors.New("gitlab store not configured")
|
|
}
|
|
if client == nil {
|
|
return nil, ErrNoClient
|
|
}
|
|
status, err := client.GetMRStatus(ctx, projectPath, iid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch MR status: %w", err)
|
|
}
|
|
if status == nil || status.MR == nil {
|
|
return nil, fmt.Errorf("MR !%d not found in %s", iid, projectPath)
|
|
}
|
|
now := time.Now().UTC()
|
|
mr := status.MR
|
|
row := &TaskMR{
|
|
TaskID: taskID,
|
|
RepositoryID: repositoryID,
|
|
Host: host,
|
|
ProjectPath: projectPath,
|
|
MRIID: mr.IID,
|
|
MRURL: mr.WebURL,
|
|
MRTitle: mr.Title,
|
|
HeadBranch: mr.HeadBranch,
|
|
BaseBranch: mr.BaseBranch,
|
|
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,
|
|
}
|
|
if err := store.UpsertTaskMR(ctx, row); err != nil {
|
|
return nil, fmt.Errorf("upsert task MR: %w", err)
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// ListTaskMRsByWorkspace surfaces all MR associations under a workspace,
|
|
// grouped by task ID. Returns an empty map when no store is configured.
|
|
func (s *Service) ListTaskMRsByWorkspace(ctx context.Context, workspaceID string) (map[string][]*TaskMR, error) {
|
|
s.mu.RLock()
|
|
store := s.store
|
|
s.mu.RUnlock()
|
|
if store == nil {
|
|
return map[string][]*TaskMR{}, nil
|
|
}
|
|
return store.ListTaskMRsByWorkspaceID(ctx, workspaceID)
|
|
}
|
|
|
|
// ListTaskMRsByTask returns the MR(s) linked to a single task.
|
|
func (s *Service) ListTaskMRsByTask(ctx context.Context, taskID string) ([]*TaskMR, error) {
|
|
s.mu.RLock()
|
|
store := s.store
|
|
s.mu.RUnlock()
|
|
if store == nil {
|
|
return nil, nil
|
|
}
|
|
return store.ListTaskMRsByTask(ctx, taskID)
|
|
}
|