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>
289 lines
10 KiB
Go
289 lines
10 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
// Store backs the task↔MR association used by the topbar / review surface.
|
|
// Parallel to internal/github.Store's TaskPR plumbing. Kept intentionally
|
|
// minimal in v1 — watchers and review queues live behind their own tables
|
|
// when phase 3 lands.
|
|
type Store struct {
|
|
db *sqlx.DB
|
|
ro *sqlx.DB
|
|
}
|
|
|
|
// NewStore initialises the gitlab Store and creates its tables. db is the
|
|
// writer pool; ro is the read-only pool (same SQLite file, separate handle
|
|
// to avoid serialising reads behind writes). Both must be non-nil.
|
|
func NewStore(db, ro *sqlx.DB) (*Store, error) {
|
|
if db == nil || ro == nil {
|
|
return nil, errors.New("gitlab store: db and ro must be non-nil")
|
|
}
|
|
s := &Store{db: db, ro: ro}
|
|
if err := s.createTables(); err != nil {
|
|
return nil, fmt.Errorf("create gitlab tables: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
const createTablesSQL = `
|
|
CREATE TABLE IF NOT EXISTS gitlab_task_mrs (
|
|
id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
repository_id TEXT NOT NULL DEFAULT '',
|
|
host TEXT NOT NULL DEFAULT '',
|
|
project_path TEXT NOT NULL,
|
|
mr_iid INTEGER NOT NULL,
|
|
mr_url TEXT NOT NULL,
|
|
mr_title TEXT NOT NULL,
|
|
head_branch TEXT NOT NULL,
|
|
base_branch TEXT NOT NULL,
|
|
author_username TEXT NOT NULL DEFAULT '',
|
|
state TEXT NOT NULL DEFAULT 'open',
|
|
approval_state TEXT NOT NULL DEFAULT '',
|
|
pipeline_state TEXT NOT NULL DEFAULT '',
|
|
merge_status TEXT NOT NULL DEFAULT '',
|
|
draft INTEGER NOT NULL DEFAULT 0,
|
|
approval_count INTEGER DEFAULT 0,
|
|
required_approvals INTEGER DEFAULT 0,
|
|
pipeline_jobs_total INTEGER DEFAULT 0,
|
|
pipeline_jobs_pass INTEGER DEFAULT 0,
|
|
created_at DATETIME NOT NULL,
|
|
merged_at DATETIME,
|
|
closed_at DATETIME,
|
|
last_synced_at DATETIME,
|
|
updated_at DATETIME NOT NULL,
|
|
UNIQUE(task_id, repository_id, project_path, mr_iid)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_gitlab_task_mrs_task_id ON gitlab_task_mrs(task_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS gitlab_mr_watches (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
task_id TEXT NOT NULL,
|
|
repository_id TEXT NOT NULL DEFAULT '',
|
|
project_path TEXT NOT NULL,
|
|
mr_iid INTEGER NOT NULL,
|
|
branch TEXT NOT NULL,
|
|
last_checked_at DATETIME,
|
|
last_note_at DATETIME,
|
|
last_pipeline_state TEXT DEFAULT '',
|
|
last_approval_state TEXT DEFAULT '',
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NOT NULL,
|
|
UNIQUE(session_id, repository_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_gitlab_mr_watches_task_id ON gitlab_mr_watches(task_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS gitlab_review_watches (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
workflow_id TEXT NOT NULL,
|
|
workflow_step_id TEXT NOT NULL,
|
|
projects TEXT NOT NULL DEFAULT '[]',
|
|
agent_profile_id TEXT NOT NULL,
|
|
executor_profile_id TEXT NOT NULL,
|
|
prompt TEXT DEFAULT '',
|
|
review_scope TEXT NOT NULL DEFAULT 'user_and_teams',
|
|
custom_query TEXT NOT NULL DEFAULT '',
|
|
enabled BOOLEAN DEFAULT 1,
|
|
poll_interval_seconds INTEGER DEFAULT 300,
|
|
cleanup_policy TEXT NOT NULL DEFAULT 'auto',
|
|
last_polled_at DATETIME,
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_gitlab_review_watches_workspace_id ON gitlab_review_watches(workspace_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS gitlab_review_mr_tasks (
|
|
id TEXT PRIMARY KEY,
|
|
review_watch_id TEXT NOT NULL,
|
|
project_path TEXT NOT NULL DEFAULT '',
|
|
mr_iid INTEGER NOT NULL,
|
|
mr_url TEXT NOT NULL,
|
|
task_id TEXT NOT NULL,
|
|
created_at DATETIME NOT NULL,
|
|
UNIQUE(review_watch_id, project_path, mr_iid)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS gitlab_issue_watches (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
workflow_id TEXT NOT NULL,
|
|
workflow_step_id TEXT NOT NULL,
|
|
projects TEXT NOT NULL DEFAULT '[]',
|
|
agent_profile_id TEXT NOT NULL,
|
|
executor_profile_id TEXT NOT NULL,
|
|
prompt TEXT DEFAULT '',
|
|
labels TEXT NOT NULL DEFAULT '[]',
|
|
custom_query TEXT NOT NULL DEFAULT '',
|
|
enabled BOOLEAN DEFAULT 1,
|
|
poll_interval_seconds INTEGER DEFAULT 300,
|
|
cleanup_policy TEXT NOT NULL DEFAULT 'auto',
|
|
last_polled_at DATETIME,
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_gitlab_issue_watches_workspace_id ON gitlab_issue_watches(workspace_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS gitlab_issue_watch_tasks (
|
|
id TEXT PRIMARY KEY,
|
|
issue_watch_id TEXT NOT NULL,
|
|
project_path TEXT NOT NULL DEFAULT '',
|
|
issue_iid INTEGER NOT NULL,
|
|
issue_url TEXT NOT NULL,
|
|
task_id TEXT NOT NULL,
|
|
created_at DATETIME NOT NULL,
|
|
UNIQUE(issue_watch_id, project_path, issue_iid)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS gitlab_action_presets (
|
|
workspace_id TEXT PRIMARY KEY,
|
|
mr_presets TEXT NOT NULL DEFAULT '[]',
|
|
issue_presets TEXT NOT NULL DEFAULT '[]',
|
|
updated_at DATETIME NOT NULL
|
|
);
|
|
`
|
|
|
|
func (s *Store) createTables() error {
|
|
_, err := s.db.Exec(createTablesSQL)
|
|
return err
|
|
}
|
|
|
|
// taskMRSelectCols is the projection used by every SELECT so dev DBs that
|
|
// still have dropped columns (older revisions defined `unresolved_threads`
|
|
// and `discussion_count` here) don't break sqlx struct binding.
|
|
const taskMRSelectCols = `id, task_id, repository_id, host, project_path, mr_iid,
|
|
mr_url, mr_title, head_branch, base_branch, author_username, state,
|
|
approval_state, pipeline_state, merge_status, draft, approval_count,
|
|
required_approvals, pipeline_jobs_total, pipeline_jobs_pass,
|
|
created_at, merged_at, closed_at, last_synced_at, updated_at`
|
|
|
|
// taskMRSelectColsQualified is the same projection prefixed with `gtm.`,
|
|
// for the workspace JOIN where `tasks` shares column names (id, created_at,
|
|
// updated_at).
|
|
const taskMRSelectColsQualified = `gtm.id, gtm.task_id, gtm.repository_id,
|
|
gtm.host, gtm.project_path, gtm.mr_iid, gtm.mr_url, gtm.mr_title,
|
|
gtm.head_branch, gtm.base_branch, gtm.author_username, gtm.state,
|
|
gtm.approval_state, gtm.pipeline_state, gtm.merge_status, gtm.draft,
|
|
gtm.approval_count, gtm.required_approvals, gtm.pipeline_jobs_total,
|
|
gtm.pipeline_jobs_pass, gtm.created_at, gtm.merged_at, gtm.closed_at,
|
|
gtm.last_synced_at, gtm.updated_at`
|
|
|
|
// UpsertTaskMR creates or updates a task↔MR association keyed by
|
|
// (task_id, repository_id, project_path, mr_iid). Generates an ID on first
|
|
// insert; otherwise replaces the row's mutable fields and bumps updated_at.
|
|
func (s *Store) UpsertTaskMR(ctx context.Context, tm *TaskMR) error {
|
|
now := time.Now().UTC()
|
|
tm.UpdatedAt = now
|
|
existing, err := s.findTaskMR(ctx, tm.TaskID, tm.RepositoryID, tm.ProjectPath, tm.MRIID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing != nil {
|
|
tm.ID = existing.ID
|
|
tm.CreatedAt = existing.CreatedAt
|
|
return s.updateTaskMR(ctx, tm)
|
|
}
|
|
if tm.ID == "" {
|
|
tm.ID = uuid.New().String()
|
|
}
|
|
if tm.CreatedAt.IsZero() {
|
|
tm.CreatedAt = now
|
|
}
|
|
_, err = s.db.NamedExecContext(ctx, `
|
|
INSERT INTO gitlab_task_mrs (
|
|
id, task_id, repository_id, host, project_path, mr_iid, mr_url, mr_title,
|
|
head_branch, base_branch, author_username, state, approval_state, pipeline_state,
|
|
merge_status, draft, approval_count, required_approvals,
|
|
pipeline_jobs_total, pipeline_jobs_pass,
|
|
created_at, merged_at, closed_at, last_synced_at, updated_at
|
|
) VALUES (
|
|
:id, :task_id, :repository_id, :host, :project_path, :mr_iid, :mr_url, :mr_title,
|
|
:head_branch, :base_branch, :author_username, :state, :approval_state, :pipeline_state,
|
|
:merge_status, :draft, :approval_count, :required_approvals,
|
|
:pipeline_jobs_total, :pipeline_jobs_pass,
|
|
:created_at, :merged_at, :closed_at, :last_synced_at, :updated_at
|
|
)`, tm)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) updateTaskMR(ctx context.Context, tm *TaskMR) error {
|
|
_, err := s.db.NamedExecContext(ctx, `
|
|
UPDATE gitlab_task_mrs SET
|
|
host = :host, mr_url = :mr_url, mr_title = :mr_title,
|
|
head_branch = :head_branch, base_branch = :base_branch,
|
|
author_username = :author_username, state = :state,
|
|
approval_state = :approval_state, pipeline_state = :pipeline_state,
|
|
merge_status = :merge_status, draft = :draft,
|
|
approval_count = :approval_count, required_approvals = :required_approvals,
|
|
pipeline_jobs_total = :pipeline_jobs_total, pipeline_jobs_pass = :pipeline_jobs_pass,
|
|
merged_at = :merged_at, closed_at = :closed_at,
|
|
last_synced_at = :last_synced_at, updated_at = :updated_at
|
|
WHERE id = :id`, tm)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) findTaskMR(ctx context.Context, taskID, repositoryID, projectPath string, iid int) (*TaskMR, error) {
|
|
var tm TaskMR
|
|
err := s.ro.GetContext(ctx, &tm,
|
|
`SELECT `+taskMRSelectCols+` FROM gitlab_task_mrs
|
|
WHERE task_id = ? AND repository_id = ? AND project_path = ? AND mr_iid = ?
|
|
LIMIT 1`, taskID, repositoryID, projectPath, iid)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &tm, nil
|
|
}
|
|
|
|
// ListTaskMRsByTask returns every MR association for a task, oldest first.
|
|
func (s *Store) ListTaskMRsByTask(ctx context.Context, taskID string) ([]*TaskMR, error) {
|
|
var mrs []TaskMR
|
|
if err := s.ro.SelectContext(ctx, &mrs,
|
|
`SELECT `+taskMRSelectCols+` FROM gitlab_task_mrs
|
|
WHERE task_id = ? ORDER BY created_at ASC`, taskID); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]*TaskMR, 0, len(mrs))
|
|
for i := range mrs {
|
|
out = append(out, &mrs[i])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListTaskMRsByWorkspaceID returns every MR association for tasks inside the
|
|
// given workspace, grouped by task_id. Empty map when the workspace has no
|
|
// GitLab MRs.
|
|
func (s *Store) ListTaskMRsByWorkspaceID(ctx context.Context, workspaceID string) (map[string][]*TaskMR, error) {
|
|
var mrs []TaskMR
|
|
if err := s.ro.SelectContext(ctx, &mrs,
|
|
`SELECT `+taskMRSelectColsQualified+` FROM gitlab_task_mrs gtm
|
|
INNER JOIN tasks t ON gtm.task_id = t.id
|
|
WHERE t.workspace_id = ?
|
|
ORDER BY gtm.created_at ASC`, workspaceID); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string][]*TaskMR)
|
|
for i := range mrs {
|
|
out[mrs[i].TaskID] = append(out[mrs[i].TaskID], &mrs[i])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteTaskMR removes a single task↔MR row.
|
|
func (s *Store) DeleteTaskMR(ctx context.Context, id string) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_task_mrs WHERE id = ?`, id)
|
|
return err
|
|
}
|