dfaab11959
* feat(backend): full GitLab parity — store, service, poller, WS, presets Add the watcher/poll/write-action surface on top of the existing GitLab client + service skeleton: * 5 new SQLite tables (mr_watches, review_watches, review_mr_tasks, issue_watches, issue_watch_tasks, action_presets) + CRUD * Client + Service: MergeMR, GetProjectMergeMethods, GetProtectedBranch, ListUserProjects, SearchProjects, SetMRLabels, SetMRAssignees * Service: review/issue/MR watch CRUD + Check/Trigger, GetStats, CleanupAllReview/IssueTasks (cleanup-policy aware), TriggerMRSync, action-presets CRUD with defaults * Background poller (MR / review / issue loops) wired from main.go * ~35 GitLab WS action constants + handlers.go registrations * Mock controller for E2E seeding (MRs, issues, pipelines, discussions, approvals, branches) * eventBus + taskDeleter + taskSessionChecker plumbed through Service * Event types: GitLabMRFeedback / NewReviewMR / NewIssue / TaskMRUpdated * feat(web): GitLab frontend parity — types, API, store slice, domain hooks Extend the frontend GitLab integration on top of the new backend surface: * HTTP API client expansion (gitlab-api.ts): full CRUD for review/issue/MR watches, MR write actions (merge/approve/labels/assignees), MR files/commits/feedback, action presets, projects autocomplete, stats * Store slice expansion: review/issue/MR watches lists, action presets by workspace, stats + status with loading flags. Properties prefixed with gitlab to avoid collision with GitHub slice * Domain hooks: use-gitlab-status, use-gitlab-stats, use-gitlab-review-watches, use-gitlab-issue-watches, use-gitlab-action-presets — mirror github hook shapes * Type model: ReviewWatch, IssueWatch, MRWatch, ActionPresets, Stats, ProjectMergeMethods, MRApproval, Pipeline, MRFeedback, etc. * Backend controller HTTP routes for watches/presets/write-actions/ projects/stats so the frontend HTTP API client has endpoints to hit All passes: backend (build + vet + lint + tests), web (typecheck + lint + 2400 tests). * fix(gitlab): wire orchestrator event handlers, fix label-filter drop, add tests Address Claude review blockers on the parity PR: 1. Wire orchestrator subscriptions for GitLab review/issue watch events. Add event_handlers_gitlab.go with handleGitLabNewReviewMR / handleGitLabNewIssue, dedup reservation handshake, and task-creator interfaces (mirrors event_handlers_github.go). main.go now calls orchestratorSvc.SetGitLabService so the dedup APIs are usable 2. Fix silent label-filter drop: when a custom_query is set on an issue watch, labels were being appended to the unused filter arg. Fold labels into customQuery (or default filter) so they actually reach GitLab; extracted appendLabelsToQuery helper with explicit handling of pre-existing labels= clauses 3. Switch event payloads to pointer types (mirrors GitHub) so handlers can type-assert pointer events 4. Backend tests: store_watches_test.go (CRUD + reserve/assign for all 4 new tables + presets), service_watches_test.go (label-filter helper, cleanup policy, project normalisation), action_presets_test.go 5. Frontend slice tests: review/issue watch CRUD round-trips, action presets + stats reducer coverage All green: backend (build+vet+lint+tests), web (typecheck+lint+2403 tests). * fix(gitlab): address remaining blockers — cleanup wiring, safe defaults, nil guards Address the second round of Claude review blockers: 1. main.go: wire SetTaskDeleter + SetTaskSessionChecker on services.GitLab (mirrors GitHub). Without these, manual cleanup sweeps would always error with "task deleter not configured" 2. service_cleanup.go: default cleanup policy on transient DB error is now CleanupPolicyNever (preserve tasks) instead of CleanupPolicyAuto (silently delete). Genuine "watch was deleted" path also falls under the safe-side default; user can manually delete the orphan tasks 3. service_watches.go: add nil guards on requireStore() so the 6 MR-watch list/get/delete methods return an errStoreUnavailable error instead of panicking when the SQLite store failed to construct at boot 4. use-gitlab-status.ts + use-gitlab-stats.ts: add per-mount attemptedRef so an unreachable GitLab doesn't trigger an infinite re-fetch loop — the previous useEffect re-ran every render because the failure path left status null, satisfying the !status guard * fix(gitlab): propagate session-check errors, clamp poll interval on update Address remaining Claude blockers: * service_cleanup.go: HasUserAuthoredMessage transient errors are now preserved (return false → skip delete) instead of silently ignored; the alternative was occasionally deleting tasks a user had touched * service_watches.go: extract clampPollInterval helper applying the same bounds (0 → default, <60 → 60) as the create path; both applyReviewWatchPatch and applyIssueWatchPatch use it so user-supplied zero or tiny values via UpdateXxxWatchRequest no longer hammer GitLab * fix(gitlab): clear dupl lint, fix preset retry loop + per-workspace watch cache * service_watches.go: applyReviewWatchPatch / applyIssueWatchPatch get nolint:dupl markers — they share shape but per-domain field validation lives in the create paths, so deduplicating via generics would obscure the contract. Restores backend lint to 0 issues * use-gitlab-action-presets.ts: per-workspace attemptedRef set so a failing preset fetch doesn't retry on every render * use-gitlab-review-watches.ts + use-gitlab-issue-watches.ts: track lastFetchedRef per consumer so a workspace switch triggers a refetch. The slice-level loaded flag is shared across instances and can't double as a per-workspace cache key * fix(gitlab): add nil-store guards across review/issue watch + cleanup paths Round-3 Claude finding: ReviewWatch / IssueWatch / preset / reservation methods still called s.requireStore().X() directly. If NewStore fails at boot (table migration error), the service struct's store is nil and every list/get/update/create/reserve method panics on first request. Apply the same store-nil → errStoreUnavailable pattern from f95a929 to every remaining method: * CreateReviewWatch / CreateIssueWatch * GetReviewWatch / ListReviewWatches / ListAllReviewWatches / UpdateReviewWatch / TriggerReviewWatchAll * GetIssueWatch / ListIssueWatches / ListAllIssueWatches / UpdateIssueWatch / TriggerIssueWatchAll * ReserveReviewMRTask / AssignReviewMRTaskID / ReleaseReviewMRTask * ReserveIssueWatchTask / AssignIssueWatchTaskID / ReleaseIssueWatchTask * lookupReviewPolicy / lookupIssuePolicy (return CleanupPolicyNever) * fix(gitlab): nil-store guards on CheckXxxWatch + DeleteXxxWatch + cleanup paths Round-4 Claude finding: 4 remaining panic sites the previous nil-guard pass missed — these run from the background poller (every 5 min) and the manual delete/cleanup flows, so a boot-time NewStore failure would crash the orchestrator process on first tick. * service_watches.go: add store-nil guards to DeleteReviewWatch / DeleteIssueWatch / CheckReviewWatch / CheckIssueWatch * service_cleanup.go: guard CleanupAllReviewTasks / CleanupAllIssueTasks entry points; wrap the trailing DeleteReviewMRTask / DeleteIssueWatchTask calls in `if store := s.requireStore(); store != nil` blocks so the cleanup succeeds even when the dedup-row delete is unreachable * refactor(gitlab): split service_watches.go to stay under 800-line revive limit CI's --new-from-rev lint caught service_watches.go at 808 lines (limit 800). Split into three focused files: * service_events.go (94 LOC) — publish helpers for MR feedback / new review MR / new issue / watch lifecycle events * service_issue_watches.go (302 LOC) — Issue watch CRUD + Check + Trigger + fetch + helpers * service_reservations.go (59 LOC) — Reserve/Assign/Release dedup handles used by the orchestrator event handlers service_watches.go is now 505 lines and contains MR watch + Review watch only. No behavioral change. * fix(gitlab): address inline code-review feedback Address actionable inline comments from Claude/CodeRabbit/cubic/Greptile: * appendLabelsToQuery: use url.ParseQuery for exact key match instead of strings.Contains; previously false-matched keys like mylabels= and silently dropped the watch's labels (test added) * fetchReviewMRs: drop dead `filter = watch.CustomQuery` assignment — SearchMRs's buildMRSearchQuery returns customQuery verbatim and ignores filter when customQuery is non-empty * store.go: add workspace_id indexes on gitlab_review_watches and gitlab_issue_watches (the 5-min poller and HTTP list endpoints did full table scans) * mock_client.SearchProjects: switch from == to case-insensitive Contains so partial-query autocomplete returns the seeded project, matching the doc comment * Poller: add sync.Mutex to guard `started` field; Start/Stop are now safe to call concurrently (go test -race would have flagged the previous read/write race) * Add ErrWatchNotFound sentinel; controller_watches.go maps it to HTTP 404 via httpRespondError helper. Update/Trigger handlers previously returned 500 for missing watches, hiding the distinction from real server faults * service_events.go: log Publish errors on watch lifecycle events instead of swallowing them silently * handlers.go: reject malformed JSON payloads in list-style WS handlers rather than silently falling through to broader list/search behavior * event_handlers_gitlab.go: remove dead gitlabRepoSlug helper + blank-identifier suppression * ci: re-trigger to clear flaky E2E shard 3 * fix(gitlab): address final review round — nil guards, store merge, logs, body validation - CheckMRWatch, UpdateReviewWatch, UpdateIssueWatch: nil guards for store/req - handlers: wsNewDiscussionNote rejects empty body - action_presets: Update reads raw stored row (no default freezing); Reset validates workspace_id - service_search.GetStats: log Warn per sub-call failure before zero - event_handlers_gitlab: log Release* dedup-row errors with context - web/store.ts: post-slice merge restores every GitLab sub-state field; extract buildStateOverrides helper - web/default-state.ts: add GitLab fields to defaultState + mergeInitialState via mergeGitLabFields helper - web/gitlab-api.ts: UpdateXxxWatchRequest now Omit workspace_id Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
452 lines
15 KiB
Go
452 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
|
|
}
|
|
|
|
// 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)
|
|
}
|