Files
kandev/apps/backend/internal/gitlab/service.go
T
João Salavisa dfaab11959 feat: full GitLab integration — parity with GitHub (#1120)
* 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>
2026-06-02 22:10:07 +01:00

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)
}