Skip to content

feat: full GitLab integration — parity with GitHub#1120

Open
jcfs wants to merge 12 commits into
mainfrom
feature/full-gitlab-integrat-5h2
Open

feat: full GitLab integration — parity with GitHub#1120
jcfs wants to merge 12 commits into
mainfrom
feature/full-gitlab-integrat-5h2

Conversation

@jcfs
Copy link
Copy Markdown
Contributor

@jcfs jcfs commented May 28, 2026

Summary

Bring internal/gitlab to full parity with internal/github: PR/MR + issue watchers with background polling, write actions (merge, approve, labels/assignees, discussion notes), CI/pipeline status, action presets, and full HTTP + WS surfaces. Frontend gets matching types, API client, store slice, and core domain hooks.

Backend (apps/backend)

  • 5 new SQLite tables + CRUD: gitlab_mr_watches, gitlab_review_watches, gitlab_review_mr_tasks, gitlab_issue_watches, gitlab_issue_watch_tasks, gitlab_action_presets
  • Client extensions: MergeMR, GetProjectMergeMethods, GetProtectedBranch, ListUserProjects, SearchProjects, SetMRLabels, SetMRAssignees
  • Service expansion (~700 LOC): review/issue/MR watch CRUD + Check/Trigger loops, merge with project-method validation, approve/unapprove, labels/assignees, search, GetStats, CleanupAllReview/IssueTasks (cleanup-policy aware), TriggerMRSync, action presets CRUD with built-in defaults
  • Background poller (3 loops: MR / review / issue) wired into main.go
  • ~35 WebSocket action constants + handlers (status, watches, write actions, presets, stats, cleanup, projects)
  • Mock controller for E2E seeding (MRs, issues, pipelines, discussions, approvals, branches)
  • Controller routes: full HTTP surface for watches/presets/write-actions/projects/stats so the frontend has parity endpoints
  • eventBus plumbed through Service; new event types: GitLabMRFeedback, GitLabNewReviewMR, GitLabNewIssue, GitLabTaskMRUpdated

Frontend (apps/web)

  • Types: ReviewWatch, IssueWatch, MRWatch, ActionPresets, Stats, ProjectMergeMethods, MRApproval, Pipeline, MRFeedback, etc.
  • API client (gitlab-api.ts): full HTTP wrappers for every new backend route
  • Store slice: gitlabReviewWatches, gitlabIssueWatches, gitlabMRWatches, gitlabActionPresets, gitlabStats, gitlabStatus (prefixed to avoid GitHub slice collision)
  • Domain hooks: useGitLabStatus, useGitLabStats, useGitLabReviewWatches, useGitLabIssueWatches, useGitLabActionPresets — mirror the GitHub hook shapes
  • AppState declaration extended with all new GitLab properties + actions

Decisions

  1. Single user-wide token + host (mirrors GitHub)
  2. Action presets backed by SQLite + WS CRUD (full parity)
  3. Inline task creation on watch hits (matches GitHub — no orchestrator watcher-dispatch coordinator)
  4. Reuse workspace.repository field for provider detection by host

Verification

  • Backend: go build ./..., go vet ./..., go test ./..., make lint — all green
  • Frontend: pnpm typecheck, pnpm lint, pnpm test (2400 passing) — all green

Test plan

  • Settings → GitLab → enter PAT → see connected banner
  • Create review watch for assigned_to_me MRs → trigger → confirm dedup row appears + event fires
  • Create issue watch on a label → trigger → confirm dedup row + event
  • Open task with linked MR → merge button respects project merge methods → MR transitions to merged
  • Manual cleanup sweep removes auto-created tasks for closed MRs/issues respecting policy
  • Self-managed GitLab host: configure host, verify status reflects new host
  • Action presets CRUD + reset round-trips through workspace SQLite row

Out of scope / follow-ups

  • UI page wiring (the /gitlab page shell and mr-list/issue-list components exist as scaffolding — the new hooks are ready to plug into them in a follow-up)
  • Playwright E2E specs for the new flows
  • GITLAB_TOKEN injection into the agent runtime (/pr skill detection)

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

Preview Environment

URL https://kandev-pr-1120-bwo7.sprites.app
Commit 49cd445
Agent Mock agent

Updates automatically on each push. Destroyed when the PR is closed.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a full GitLab integration: backend watch/task models and DB schema, store CRUD and reservation semantics, service APIs (watches, merge, search, sync, cleanup), background poller, HTTP + WebSocket routes (plus mock), orchestrator event wiring and startup, and frontend API, types, Zustand state, and hooks.

Changes

Backend GitLab Integration

Layer / File(s) Summary
Entrypoint & startup wiring
apps/backend/cmd/kandev/...
Main and helpers wire GitLab service into orchestrator, start the GitLab poller, and register routes with the WebSocket dispatcher.
Domain models, events, actions
internal/gitlab/watch_models.go, internal/events/types.go, pkg/websocket/actions.go
Adds watch/task domain models, cleanup policy helpers, action-preset models/defaults, event constants, and WebSocket action constants.
Client surface & implementations
internal/gitlab/client.go, pat_client_actions.go, mock_client.go, noop_client.go
Expands Client interface and implements PAT, mock, and noop methods for merge, project/branch, search, and MR label/assignee updates.
DB schema and store
internal/gitlab/store.go, internal/gitlab/store_watches.go
Creates tables for watches/tasks/presets and implements CRUD, reservation/assignment semantics, JSON helpers, and action-presets persistence.
Store tests
internal/gitlab/store_watches_test.go
Unit tests for MR/Review/Issue watch CRUD, reservation/assignment, and action-presets round-trips.
Service wiring & poller
internal/gitlab/service.go, internal/gitlab/poller.go, cmd/kandev/services.go
Service gains event bus, TaskDeleter, TaskSessionChecker setters; Poller runs MR/review/issue loops and publishes discovery events.
Watch orchestration
internal/gitlab/service_watches.go, service_watches_test.go
Implements MR/review/issue watches: CRUD, polling/discovery, dedup/reservations, triggers, and event publishing plus helper utilities.
MR actions, search, sync, cleanup
internal/gitlab/service_merge.go, service_search.go, service_sync.go, service_cleanup.go
MergeMR and MR operations, user-scoped searches and stats, TriggerMRSync, and cleanup sweep logic for auto-deleting terminal tasks.
HTTP controllers, WS handlers, and mock
internal/gitlab/controller.go, controller_watches.go, handlers.go, mock_controller.go
Registers HTTP watch endpoints, adds RegisterRoutesWithDispatcher to wire WS actions, implements WS handlers, and provides mock seeding endpoints.

Frontend State and Data Access

Layer / File(s) Summary
API client
apps/web/lib/api/domains/gitlab-api.ts
Client functions for watch CRUD/trigger, cleanup, project discovery, merge-methods, MR write actions, MR reads, action presets, and stats.
Type definitions
apps/web/lib/types/gitlab.ts
Adds types for review/issue/MR watches, projects, merge methods, action presets, MR feedback/files/commits, pipelines, and stats.
Zustand slice and store types
apps/web/lib/state/slices/gitlab/*, apps/web/lib/state/store.ts
Expands GitLab slice state and actions, refactors slice creation into composed action creators, and updates AppState typings.
Hooks and UI wiring
apps/web/hooks/domains/gitlab/*
Hooks for action presets, review/issue watches, stats, and status that load data conditionally and expose CRUD/trigger callbacks.
Frontend tests
apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts
Unit tests validating slice setters, CRUD updates, and stats/preset round-trips.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant WSDispatcher
  participant GitLabService
  participant Store
  participant Poller
  Client->>WSDispatcher: WS action (trigger/list/update)
  WSDispatcher->>GitLabService: call service handler
  GitLabService->>Store: read/write watches/tasks
  Poller->>GitLabService: periodic poll -> discoveries
  GitLabService->>Client: publish events / return responses
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • kdlbs/kandev#861: Prior GitLab route wiring; overlaps on registerSecondaryRoutes/main wiring.

Suggested labels

codex

Poem

"I'm a rabbit in code, I hop through the land,
Presets and watches held gently in hand.
Poller hums softly, events hop to play,
Tasks get reserved then created by day.
Hooray for GitLab — nibble, sync, hooray!"

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/full-gitlab-integrat-5h2

@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Claude finished @jcfs's task in 6m 27s —— View job


Code Review

  • Read review skill instructions
  • Gather context — read changed files
  • Review backend changes (service, store, handlers, poller)
  • Review frontend changes (types, API client, store slice, hooks)
  • Post findings

Findings

Blocker (must fix before merge)

1. Missing backend testsservice_watches.go, store_watches.go, service_cleanup.go, service_merge.go, poller.go, action_presets_service.go

  • Issue: ~2100 LOC of new business logic across 6 files has no *_test.go coverage. The 5 new SQLite tables in store_watches.go, all CRUD operations, the cleanup sweep logic, merge-method validation, and the poller loops are untested. Existing service_test.go / store_test.go cover only the pre-existing task-MR sync functionality.
  • Why: Per codebase convention (CLAUDE.md + AGENTS.md) backend logic requires test coverage. The cleanup and dedup reservation paths have subtle edge cases (policy enforcement, TOCTOU handling) that are particularly risky without regression coverage.
  • Fix: Add store_watches_test.go covering the CRUD + dedup reservation round-trips (SQLite in-memory via sql.Open("sqlite3", ":memory:")), service_watches_test.go covering CheckReviewWatch/CheckIssueWatch dedup logic, and service_cleanup_test.go covering policy variants. Use /tdd.

2. Missing frontend testsgitlab-slice.ts, gitlab-api.ts

  • Issue: gitlab-slice.test.ts was not updated — reviewWatchActions, issueWatchActions, mrWatchActions, presetActions, statsActions, statusActions (~130 new lines) are untested. gitlab-api.test.ts was not updated — the ~18 new API functions for watches, presets, write-actions, and projects are untested.
  • Why: The existing test files cover the pre-existing task-MR and auth APIs; this PR adds ~2× as much API surface without extending them.
  • Fix: Extend gitlab-slice.test.ts with describe blocks for the new action groups (parallel to the existing setTaskMRs block). Extend gitlab-api.test.ts with one test per new exported function (URL + method assertions).

3. Label filter silently dropped when custom_query and labels are both setservice_watches.go:583–590

  • Issue: In fetchIssues, when watch.CustomQuery != "" and watch.Labels is non-empty, the labels are appended to filter but buildIssueSearchQuery ignores filter when customQuery != "" — labels are silently lost.
// current:
filter := watch.CustomQuery      // filter = customQuery
if filter == "" {
    filter = "assignee_username=..."
}
if len(watch.Labels) > 0 {
    filter = filter + "&labels=..." // appended to filter, but...
}
issues, err := client.ListIssues(ctx, filter, watch.CustomQuery)
//   buildIssueSearchQuery ignores `filter` ↑ because customQuery != ""
  • Why: A watch created with both custom_query and labels will never filter by label, with no error or warning — silent wrong behaviour.
  • Fix: Either document that custom_query takes full ownership and labels is ignored when it is set (and add validation at CreateIssueWatch rejecting the combination), or merge labels into the customQuery before passing it:
if watch.CustomQuery != "" {
    q := watch.CustomQuery
    if len(watch.Labels) > 0 {
        q += "&labels=" + url.QueryEscape(strings.Join(watch.Labels, ","))
    }
    return client.ListIssues(ctx, "", q)
}
filter := "assignee_username=" + url.QueryEscape(username)
if len(watch.Labels) > 0 {
    filter += "&labels=" + url.QueryEscape(strings.Join(watch.Labels, ","))
}
return client.ListIssues(ctx, filter, "")

4. Watch events are published but no orchestrator subscriber creates tasksmain.go:439–447, service_watches.go:699–744

  • Issue: GitLabNewReviewMR and GitLabNewIssue are published to the event bus, but the orchestrator never subscribes to them. There is no orchestratorSvc.SetGitLabService(...) call in services.go or main.go (compare lines 454/464 for Jira/Linear). The ReserveReviewMRTask / AssignReviewMRTaskID APIs are fully implemented but unreachable — no code path ever calls them. Triggering a review or issue watch will discover new items and publish events that silently vanish; no Kandev tasks are ever created.
  • Why: The PR's test plan ("trigger → confirm dedup row appears + event fires") would fail on step one because the dedup row is only written by the task-creation reservation path, which doesn't exist.
  • Fix: Wire up orchestrator handlers for GitLab in the same pattern as event_handlers_github.go. Either add event_handlers_gitlab.go with a handleNewReviewMR + handleNewGitLabIssue subscriber, or — if this is intentionally deferred — remove the watch-level test plan claims from the PR description and document the gap explicitly. The unsubscribed events should also not be polluting the bus until the handlers exist (low cost: they just enqueue to a dead channel, but confusing for anyone debugging).

Suggestion (recommended, doesn't block)

5. Poller.started not mutex-protectedpoller.go:38, 41, 52, 57

  • p.started is read/written in Start and Stop without synchronization. In practice these are called from init/shutdown serially, but the race detector would flag it. Fix: add a sync.Mutex field to Poller and guard the started check-and-set.

6. Redundant filter assignment in fetchReviewMRsservice_watches.go:325–331

  • When watch.CustomQuery != "", filter is set to watch.CustomQuery then passed as both filter and customQuery to SearchMRs. Since buildMRSearchQuery ignores filter when customQuery != "", the conditional branch is dead code and misleading. Clean fix:
filter := "reviewer_username=" + url.QueryEscape(username)
if watch.CustomQuery != "" {
    filter = ""  // customQuery overrides; filter is ignored
}
return client.SearchMRs(ctx, filter, watch.CustomQuery)

7. requireStore() returns nil without error in watch pathservice_watches.go:51, 56, 61, 66, 71, 76

  • Callers like GetMRWatchBySession call s.requireStore().GetMRWatchBySession(...) without nil-checking — a nil store causes a panic. CreateMRWatch (line 30) does check, which shows the inconsistency. While SetStore is always called at init and nil is unreachable in production, the inconsistency is fragile. Fix: either have requireStore() return (*Store, error) so every call site is forced to handle it, or at minimum add a nil guard in GetMRWatchBySession etc. as CreateMRWatch does.

Summary

Severity Count
Blocker 4
Suggestion 3

Verdict: Blocked — fix blockers first.

The infrastructure here is well-built (dedup protocol, cleanup policies, action presets, WS handler symmetry with GitHub) but items 1–2 (missing tests) and item 4 (unsubscribed events → no task creation) need to be addressed before this can ship. Item 3 is a logic bug that would confuse users who set both custom_query and labels on an issue watch.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

26 issues found across 34 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/backend/internal/gitlab/store_watches.go">

<violation number="1" location="apps/backend/internal/gitlab/store_watches.go:170">
P1: Normalize `PollIntervalSeconds` in `UpdateReviewWatch` the same way as create, otherwise updates can persist invalid (<=0) intervals.</violation>

<violation number="2" location="apps/backend/internal/gitlab/store_watches.go:416">
P1: `UpdateIssueWatch` should clamp/default `PollIntervalSeconds`; currently invalid values can be persisted on update.</violation>
</file>

<file name="apps/web/hooks/domains/gitlab/use-gitlab-status.ts">

<violation number="1" location="apps/web/hooks/domains/gitlab/use-gitlab-status.ts:18">
P1: The initial-effect guard uses `status` truthiness instead of a loaded marker, so `null` results trigger repeated status refetches (disconnect/error loops).</violation>
</file>

<file name="apps/backend/internal/gitlab/service_cleanup.go">

<violation number="1" location="apps/backend/internal/gitlab/service_cleanup.go:59">
P1: Issue-watch fetch errors are defaulted to `CleanupPolicyAuto`, which can delete issue tasks incorrectly when watch metadata retrieval fails transiently. Erroring watches should be treated as unknown and skipped until a later sweep.

(Based on your team's feedback about treating watch fetch errors as unknown during cleanup sweeps.) [FEEDBACK_USED]</violation>
</file>

<file name="apps/backend/internal/gitlab/mock_client.go">

<violation number="1" location="apps/backend/internal/gitlab/mock_client.go:440">
P2: `SearchProjects` is documented as substring search but currently uses exact equality, causing partial queries to fail in mock tests.</violation>
</file>

<file name="apps/web/hooks/domains/gitlab/use-gitlab-stats.ts">

<violation number="1" location="apps/web/hooks/domains/gitlab/use-gitlab-stats.ts:18">
P2: This effect can hammer `/api/v1/gitlab/stats` in a tight loop when the fetch fails (or returns null), because `stats` stays null and the guard never transitions to a loaded state.</violation>
</file>

<file name="apps/backend/internal/gitlab/poller.go">

<violation number="1" location="apps/backend/internal/gitlab/poller.go:38">
P1: Poller lifecycle state is not thread-safe; concurrent Start/Stop can race, leak loops, or block shutdown.</violation>
</file>

<file name="apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts">

<violation number="1" location="apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts:16">
P2: Action preset loading is tracked globally instead of per workspace, so one workspace request can block or misreport loading for another.</violation>

<violation number="2" location="apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts:21">
P1: The effect can enter a continuous retry loop when the presets fetch fails, causing repeated requests and unnecessary load.</violation>
</file>

<file name="apps/backend/internal/gitlab/service_sync.go">

<violation number="1" location="apps/backend/internal/gitlab/service_sync.go:15">
P2: TriggerMRSync breaks the optional-store contract by returning an error instead of an empty result when the store is unset.</violation>
</file>

<file name="apps/backend/cmd/kandev/main.go">

<violation number="1" location="apps/backend/cmd/kandev/main.go:443">
P2: GitLab service is started without wiring task cleanup dependencies, so GitLab cleanup operations can fail at runtime with "task deleter not configured".</violation>
</file>

<file name="apps/backend/internal/gitlab/handlers.go">

<violation number="1" location="apps/backend/internal/gitlab/handlers.go:121">
P2: Do not ignore payload parse errors in list/search handlers; malformed WS payloads currently fall through and can trigger unintended unfiltered operations.</violation>
</file>

<file name="apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts">

<violation number="1" location="apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts:33">
P1: The `loaded` guard is not workspace-scoped, so changing `workspaceId` can leave stale review watches and skip the needed refetch.</violation>

<violation number="2" location="apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts:36">
P2: Guard async watch fetch callbacks by workspace so an older request cannot overwrite state after `workspaceId` changes.

(Based on your team's feedback about guarding workspace-scoped async fetches.) [FEEDBACK_USED]</violation>
</file>

<file name="apps/backend/internal/gitlab/action_presets_service.go">

<violation number="1" location="apps/backend/internal/gitlab/action_presets_service.go:11">
P2: `ResetActionPresets` should validate `workspaceID` to avoid silent resets with empty workspace IDs.</violation>

<violation number="2" location="apps/backend/internal/gitlab/action_presets_service.go:38">
P2: Partial updates incorrectly persist default presets for untouched kinds, which can freeze defaults and block future default updates.</violation>
</file>

<file name="apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts">

<violation number="1" location="apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts:27">
P1: `loaded` is not scoped per workspace, so changing `workspaceId` can skip refetch and leave stale issue watches from another workspace.</violation>

<violation number="2" location="apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts:30">
P1: Guard the async callbacks so stale responses from a previous workspace cannot overwrite the current workspace state.

(Based on your team's feedback about guarding workspace-scoped async fetches.) [FEEDBACK_USED]</violation>
</file>

<file name="apps/backend/internal/gitlab/service_search.go">

<violation number="1" location="apps/backend/internal/gitlab/service_search.go:48">
P2: `GetStats` masks GitLab/auth/query failures as zero counts, which can return misleading stats instead of signaling an error.</violation>
</file>

<file name="apps/web/lib/api/domains/gitlab-api.ts">

<violation number="1" location="apps/web/lib/api/domains/gitlab-api.ts:146">
P2: `UpdateReviewWatchRequest` incorrectly allows `workspace_id`, but the backend update schema does not support it. This can lead callers to send unsupported fields that are silently ignored.</violation>

<violation number="2" location="apps/web/lib/api/domains/gitlab-api.ts:195">
P2: `UpdateIssueWatchRequest` should exclude `workspace_id`; allowing it in the type creates a frontend/backend contract mismatch.</violation>
</file>

<file name="apps/backend/internal/gitlab/store.go">

<violation number="1" location="apps/backend/internal/gitlab/store.go:86">
P2: Add indexes for `workspace_id` and `enabled` on `gitlab_review_watches` to avoid full scans in polling/list paths.</violation>

<violation number="2" location="apps/backend/internal/gitlab/store.go:116">
P2: Add indexes for `workspace_id` and `enabled` on `gitlab_issue_watches` to keep watch polling queries scalable.</violation>
</file>

<file name="apps/backend/internal/gitlab/controller_watches.go">

<violation number="1" location="apps/backend/internal/gitlab/controller_watches.go:173">
P2: Return 404 for missing review watches instead of mapping not-found to 500 in trigger endpoint.</violation>

<violation number="2" location="apps/backend/internal/gitlab/controller_watches.go:252">
P2: Return 404 for missing issue watches instead of treating not-found as internal server errors.</violation>
</file>

<file name="apps/backend/internal/gitlab/pat_client_actions.go">

<violation number="1" location="apps/backend/internal/gitlab/pat_client_actions.go:123">
P2: `ListUserProjects` only fetches the first page (`per_page=100`) and drops additional projects. This can silently hide repositories in project pickers/watch setup for users in many projects.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread apps/backend/internal/gitlab/store_watches.go
Comment thread apps/backend/internal/gitlab/store_watches.go
Comment thread apps/web/hooks/domains/gitlab/use-gitlab-status.ts Outdated
Comment thread apps/backend/internal/gitlab/service_cleanup.go Outdated
Comment thread apps/backend/internal/gitlab/poller.go
Comment thread apps/backend/internal/gitlab/store.go
Comment thread apps/backend/internal/gitlab/store.go
Comment thread apps/backend/internal/gitlab/controller_watches.go
Comment thread apps/backend/internal/gitlab/controller_watches.go
Comment thread apps/backend/internal/gitlab/pat_client_actions.go
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

26 issues found across 34 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/backend/internal/gitlab/store_watches.go">

<violation number="1" location="apps/backend/internal/gitlab/store_watches.go:170">
P1: Normalize `PollIntervalSeconds` in `UpdateReviewWatch` the same way as create, otherwise updates can persist invalid (<=0) intervals.</violation>

<violation number="2" location="apps/backend/internal/gitlab/store_watches.go:416">
P1: `UpdateIssueWatch` should clamp/default `PollIntervalSeconds`; currently invalid values can be persisted on update.</violation>
</file>

<file name="apps/web/hooks/domains/gitlab/use-gitlab-status.ts">

<violation number="1" location="apps/web/hooks/domains/gitlab/use-gitlab-status.ts:18">
P1: The initial-effect guard uses `status` truthiness instead of a loaded marker, so `null` results trigger repeated status refetches (disconnect/error loops).</violation>
</file>

<file name="apps/backend/internal/gitlab/service_cleanup.go">

<violation number="1" location="apps/backend/internal/gitlab/service_cleanup.go:59">
P1: Issue-watch fetch errors are defaulted to `CleanupPolicyAuto`, which can delete issue tasks incorrectly when watch metadata retrieval fails transiently. Erroring watches should be treated as unknown and skipped until a later sweep.

(Based on your team's feedback about treating watch fetch errors as unknown during cleanup sweeps.) [FEEDBACK_USED]</violation>
</file>

<file name="apps/backend/internal/gitlab/mock_client.go">

<violation number="1" location="apps/backend/internal/gitlab/mock_client.go:440">
P2: `SearchProjects` is documented as substring search but currently uses exact equality, causing partial queries to fail in mock tests.</violation>
</file>

<file name="apps/web/hooks/domains/gitlab/use-gitlab-stats.ts">

<violation number="1" location="apps/web/hooks/domains/gitlab/use-gitlab-stats.ts:18">
P2: This effect can hammer `/api/v1/gitlab/stats` in a tight loop when the fetch fails (or returns null), because `stats` stays null and the guard never transitions to a loaded state.</violation>
</file>

<file name="apps/backend/internal/gitlab/poller.go">

<violation number="1" location="apps/backend/internal/gitlab/poller.go:38">
P1: Poller lifecycle state is not thread-safe; concurrent Start/Stop can race, leak loops, or block shutdown.</violation>
</file>

<file name="apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts">

<violation number="1" location="apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts:16">
P2: Action preset loading is tracked globally instead of per workspace, so one workspace request can block or misreport loading for another.</violation>

<violation number="2" location="apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts:21">
P1: The effect can enter a continuous retry loop when the presets fetch fails, causing repeated requests and unnecessary load.</violation>
</file>

<file name="apps/backend/internal/gitlab/service_sync.go">

<violation number="1" location="apps/backend/internal/gitlab/service_sync.go:15">
P2: TriggerMRSync breaks the optional-store contract by returning an error instead of an empty result when the store is unset.</violation>
</file>

<file name="apps/backend/cmd/kandev/main.go">

<violation number="1" location="apps/backend/cmd/kandev/main.go:443">
P2: GitLab service is started without wiring task cleanup dependencies, so GitLab cleanup operations can fail at runtime with "task deleter not configured".</violation>
</file>

<file name="apps/backend/internal/gitlab/handlers.go">

<violation number="1" location="apps/backend/internal/gitlab/handlers.go:121">
P2: Do not ignore payload parse errors in list/search handlers; malformed WS payloads currently fall through and can trigger unintended unfiltered operations.</violation>
</file>

<file name="apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts">

<violation number="1" location="apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts:33">
P1: The `loaded` guard is not workspace-scoped, so changing `workspaceId` can leave stale review watches and skip the needed refetch.</violation>

<violation number="2" location="apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts:36">
P2: Guard async watch fetch callbacks by workspace so an older request cannot overwrite state after `workspaceId` changes.

(Based on your team's feedback about guarding workspace-scoped async fetches.) [FEEDBACK_USED]</violation>
</file>

<file name="apps/backend/internal/gitlab/action_presets_service.go">

<violation number="1" location="apps/backend/internal/gitlab/action_presets_service.go:11">
P2: `ResetActionPresets` should validate `workspaceID` to avoid silent resets with empty workspace IDs.</violation>

<violation number="2" location="apps/backend/internal/gitlab/action_presets_service.go:38">
P2: Partial updates incorrectly persist default presets for untouched kinds, which can freeze defaults and block future default updates.</violation>
</file>

<file name="apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts">

<violation number="1" location="apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts:27">
P1: `loaded` is not scoped per workspace, so changing `workspaceId` can skip refetch and leave stale issue watches from another workspace.</violation>

<violation number="2" location="apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts:30">
P1: Guard the async callbacks so stale responses from a previous workspace cannot overwrite the current workspace state.

(Based on your team's feedback about guarding workspace-scoped async fetches.) [FEEDBACK_USED]</violation>
</file>

<file name="apps/backend/internal/gitlab/service_search.go">

<violation number="1" location="apps/backend/internal/gitlab/service_search.go:48">
P2: `GetStats` masks GitLab/auth/query failures as zero counts, which can return misleading stats instead of signaling an error.</violation>
</file>

<file name="apps/web/lib/api/domains/gitlab-api.ts">

<violation number="1" location="apps/web/lib/api/domains/gitlab-api.ts:146">
P2: `UpdateReviewWatchRequest` incorrectly allows `workspace_id`, but the backend update schema does not support it. This can lead callers to send unsupported fields that are silently ignored.</violation>

<violation number="2" location="apps/web/lib/api/domains/gitlab-api.ts:195">
P2: `UpdateIssueWatchRequest` should exclude `workspace_id`; allowing it in the type creates a frontend/backend contract mismatch.</violation>
</file>

<file name="apps/backend/internal/gitlab/store.go">

<violation number="1" location="apps/backend/internal/gitlab/store.go:86">
P2: Add indexes for `workspace_id` and `enabled` on `gitlab_review_watches` to avoid full scans in polling/list paths.</violation>

<violation number="2" location="apps/backend/internal/gitlab/store.go:116">
P2: Add indexes for `workspace_id` and `enabled` on `gitlab_issue_watches` to keep watch polling queries scalable.</violation>
</file>

<file name="apps/backend/internal/gitlab/controller_watches.go">

<violation number="1" location="apps/backend/internal/gitlab/controller_watches.go:173">
P2: Return 404 for missing review watches instead of mapping not-found to 500 in trigger endpoint.</violation>

<violation number="2" location="apps/backend/internal/gitlab/controller_watches.go:252">
P2: Return 404 for missing issue watches instead of treating not-found as internal server errors.</violation>
</file>

<file name="apps/backend/internal/gitlab/pat_client_actions.go">

<violation number="1" location="apps/backend/internal/gitlab/pat_client_actions.go:123">
P2: `ListUserProjects` only fetches the first page (`per_page=100`) and drops additional projects. This can silently hide repositories in project pickers/watch setup for users in many projects.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread apps/backend/internal/gitlab/store_watches.go
Comment thread apps/backend/internal/gitlab/store_watches.go
Comment thread apps/web/hooks/domains/gitlab/use-gitlab-status.ts Outdated
Comment thread apps/backend/internal/gitlab/service_cleanup.go Outdated
Comment thread apps/backend/internal/gitlab/poller.go
Comment thread apps/backend/internal/gitlab/store.go
Comment thread apps/backend/internal/gitlab/store.go
Comment thread apps/backend/internal/gitlab/controller_watches.go
Comment thread apps/backend/internal/gitlab/controller_watches.go
Comment thread apps/backend/internal/gitlab/pat_client_actions.go
Comment thread apps/backend/internal/gitlab/service_watches.go Outdated
Comment thread apps/backend/cmd/kandev/main.go
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 28, 2026

Greptile Summary

This PR brings internal/gitlab to full parity with internal/github, adding 5 SQLite tables, background polling loops for MR/review/issue watches, a complete write-action surface (merge, approve, labels, assignees, discussion notes), action presets, and matching frontend types, API client, store slice, and domain hooks.

  • Backend: New poller with three loops, full CRUD for watches and dedup tasks, merge-method validation, cleanup sweepers with configurable policies, and ~35 WS/HTTP endpoints.
  • Frontend: gitlab-slice.ts with Immer actions for all new state, typed API wrappers in gitlab-api.ts, and five domain hooks mirroring the GitHub side.

Confidence Score: 3/5

Both fetchReviewMRs and fetchIssues call GetAuthenticatedUser unconditionally; any watch with a custom_query will silently break if that API call fails, making the feature unreliable in limited-PAT-scope environments.

Two watch-polling functions share the same bug: GetAuthenticatedUser is invoked on every poll cycle even when custom_query is set and the username is never consumed. These are in the hot poll path and are straightforward to fix.

service_issue_watches.go (fetchIssues) and service_watches.go (fetchReviewMRs) — both have the same unconditional user-lookup that needs to move inside the customQuery == "" branch.

Important Files Changed

Filename Overview
apps/backend/internal/gitlab/service_issue_watches.go Issue watch CRUD + poller logic; fetchIssues calls GetAuthenticatedUser unconditionally, blocking custom-query watches when the user-info endpoint fails.
apps/backend/internal/gitlab/service_watches.go MR/review watch CRUD + polling; fetchReviewMRs also calls GetAuthenticatedUser unconditionally; initialReviewCheck goroutine uses context.Background() and is untracked (flagged in prior review).
apps/backend/internal/gitlab/service_cleanup.go Cleanup sweep for review/issue tasks; nil store guards are present and correct; logic is clean.
apps/backend/internal/gitlab/poller.go Three-loop background poller; mutex properly guards started/cancel across concurrent Start/Stop calls; shutdown sequence is correct.
apps/backend/internal/orchestrator/event_handlers_gitlab.go GitLab event-to-task pipeline; goroutines use context.Background() and are not tracked in a WaitGroup (flagged in prior review).
apps/backend/internal/gitlab/store_watches.go Full CRUD for all five new SQLite tables; parameterised queries throughout; ON CONFLICT dedup for task reservation is correctly implemented.
apps/backend/internal/gitlab/handlers.go ~35 WebSocket handlers wired; input validation is present on all mutating handlers.
apps/backend/internal/gitlab/controller_watches.go HTTP surface for watches, presets, write actions, projects; parameter extraction and error mapping look correct.
apps/backend/internal/gitlab/pat_client_actions.go PATClient extensions for merge, labels, assignees, projects; API endpoints and payload shapes are correct for GitLab v4.
apps/web/lib/api/domains/gitlab-api.ts Frontend HTTP wrappers for all new backend routes; response types match backend shapes; IDs are URL-encoded on parameterised routes.
apps/web/lib/state/slices/gitlab/gitlab-slice.ts Immer-backed Zustand slice for all new GitLab state; action factories are consistently structured and match the declared types.

Sequence Diagram

sequenceDiagram
    participant Main as main.go
    participant Poller as Poller
    participant Svc as Service
    participant Store as SQLite Store
    participant GL as GitLab API
    participant Bus as EventBus
    participant Orch as Orchestrator

    Main->>Poller: Start(ctx)
    activate Poller
    loop Every 5 min (reviewWatchLoop)
        Poller->>Svc: ListAllReviewWatches(ctx)
        Svc->>Store: SELECT gitlab_review_watches
        Store-->>Svc: "[]*ReviewWatch"
        loop per watch (if due)
            Poller->>Svc: CheckReviewWatch(ctx, watch)
            Svc->>GL: GetAuthenticatedUser + SearchMRs
            GL-->>Svc: "[]*MR"
            Svc->>Store: HasReviewMRTask (dedup)
            Svc->>Store: RecordReviewWatchPoll
            Svc-->>Poller: new MRs
            Poller->>Bus: Publish(GitLabNewReviewMR)
            Bus->>Orch: handleGitLabNewReviewMR
            Orch->>Svc: ReserveReviewMRTask
            Orch->>Orch: createGitLabReviewTask (goroutine)
            Orch->>Svc: AssignReviewMRTaskID
        end
    end
    Main->>Poller: Stop()
    deactivate Poller
Loading

Reviews (12): Last reviewed commit: "fix(gitlab): address final review round ..." | Re-trigger Greptile

Comment thread apps/backend/internal/gitlab/service_watches.go Outdated
Comment thread apps/backend/internal/gitlab/service_watches.go Outdated
Comment thread apps/backend/internal/gitlab/poller.go
Comment thread apps/backend/internal/gitlab/service_watches.go
@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Claude finished @jcfs's task in 6m 45s —— View job


Code Review

  • Load tools
  • Read changed files and understand current state
  • Review backend changes (poller, cleanup, store, handlers, merge, search)
  • Review frontend changes (hooks, API client, slice)
  • Post inline findings
  • Final summary

Summary

The latest commit (475bd15) addressed the two blockers from the previous Claude review: the label-filter bug in fetchIssues is now correctly fixed with appendLabelsToQuery, and the orchestrator GitLab event handlers (event_handlers_gitlab.go) are now wired with subscribeGitLabEvents() called at boot. Good work.

Remaining issues found in this pass:

Blockers (must fix before merge)

# File Issue
1 cmd/kandev/main.go:441 SetTaskDeleter / SetTaskSessionChecker never called for GitLab — CleanupAllReviewTasks and CleanupAllIssueTasks always fail with "task deleter not configured"
2 service_cleanup.go:54–59, 131–136 lookupReviewPolicy / lookupIssuePolicy return CleanupPolicyAuto on transient DB error — can silently delete tasks that should be preserved
3 service_watches.go:51–76 Six methods call s.requireStore().X() without nil guard — panics when the store is not configured (startup failure path)
4 use-gitlab-status.ts:18, use-gitlab-stats.ts:17 Infinite re-fetch loop when GitLab is disconnected — status stays null, guard never trips, effect fires on every render

Should fix (won't block CI but are real bugs)

# File Issue
5 service_watches.go:325–331 fetchReviewMRs: filter is set to watch.CustomQuery then passed as both args to SearchMRs — dead code that mirrors the fetchIssues bug fixed in this commit
6 service_watches.go:158, 425 initialReviewCheck / initialIssueCheck launched with context.Background(), untracked by Poller — continue running after Poller.Stop() returns
7 poller.go:38–57 p.started read/written without mutex — go test -race would flag this
8 store_watches.go:246, 497 UpdateReviewWatch / UpdateIssueWatch don't normalize PollIntervalSeconds ≤ 0 (create path does; update path doesn't)
9 controller_watches.go:174, 252 httpTriggerReviewWatch / httpTriggerIssueWatch return 500 for not-found watches — should be 404
10 use-gitlab-review-watches.ts:33, use-gitlab-issue-watches.ts:27 loaded guard is global, not workspace-scoped — stale watches from workspace A survive switch to workspace B
11 store.go:103, 133 No index on workspace_id or enabled for gitlab_review_watches / gitlab_issue_watches — full scans on every poll cycle

Noted (intentional per PR description, no action required)

  • SetGitLabIssueTaskCreator / SetGitLabReviewTaskCreator are not called in main.go — handlers gracefully log "skipping task creation". The PR description lists task creation as an explicit follow-up scope. ✓

Comment thread apps/backend/cmd/kandev/main.go
Comment thread apps/backend/internal/gitlab/service_cleanup.go Outdated
Comment thread apps/backend/internal/gitlab/service_watches.go Outdated
Comment thread apps/backend/internal/gitlab/service_watches.go
Comment thread apps/backend/internal/gitlab/service_watches.go
Comment thread apps/backend/internal/gitlab/poller.go
Comment thread apps/backend/internal/gitlab/store_watches.go
Comment thread apps/backend/internal/gitlab/controller_watches.go Outdated
Comment thread apps/backend/internal/gitlab/store.go
Comment thread apps/web/hooks/domains/gitlab/use-gitlab-status.ts Outdated
Comment thread apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts Outdated
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 8 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/backend/internal/gitlab/handlers.go">

<violation number="1" location="apps/backend/internal/gitlab/handlers.go:121">
P2: Do not ignore payload parse errors in list/search handlers; malformed WS payloads currently fall through and can trigger unintended unfiltered operations.</violation>
</file>

<file name="apps/backend/internal/gitlab/action_presets_service.go">

<violation number="1" location="apps/backend/internal/gitlab/action_presets_service.go:11">
P2: `ResetActionPresets` should validate `workspaceID` to avoid silent resets with empty workspace IDs.</violation>

<violation number="2" location="apps/backend/internal/gitlab/action_presets_service.go:38">
P2: Partial updates incorrectly persist default presets for untouched kinds, which can freeze defaults and block future default updates.</violation>
</file>

<file name="apps/web/lib/api/domains/gitlab-api.ts">

<violation number="1" location="apps/web/lib/api/domains/gitlab-api.ts:146">
P2: `UpdateReviewWatchRequest` incorrectly allows `workspace_id`, but the backend update schema does not support it. This can lead callers to send unsupported fields that are silently ignored.</violation>

<violation number="2" location="apps/web/lib/api/domains/gitlab-api.ts:195">
P2: `UpdateIssueWatchRequest` should exclude `workspace_id`; allowing it in the type creates a frontend/backend contract mismatch.</violation>
</file>

<file name="apps/backend/internal/gitlab/store.go">

<violation number="1" location="apps/backend/internal/gitlab/store.go:86">
P2: Add indexes for `workspace_id` and `enabled` on `gitlab_review_watches` to avoid full scans in polling/list paths.</violation>

<violation number="2" location="apps/backend/internal/gitlab/store.go:116">
P2: Add indexes for `workspace_id` and `enabled` on `gitlab_issue_watches` to keep watch polling queries scalable.</violation>
</file>

<file name="apps/backend/internal/gitlab/controller_watches.go">

<violation number="1" location="apps/backend/internal/gitlab/controller_watches.go:173">
P2: Return 404 for missing review watches instead of mapping not-found to 500 in trigger endpoint.</violation>

<violation number="2" location="apps/backend/internal/gitlab/controller_watches.go:252">
P2: Return 404 for missing issue watches instead of treating not-found as internal server errors.</violation>
</file>

<file name="apps/backend/internal/gitlab/store_watches.go">

<violation number="1" location="apps/backend/internal/gitlab/store_watches.go:170">
P1: Normalize `PollIntervalSeconds` in `UpdateReviewWatch` the same way as create, otherwise updates can persist invalid (<=0) intervals.</violation>

<violation number="2" location="apps/backend/internal/gitlab/store_watches.go:416">
P1: `UpdateIssueWatch` should clamp/default `PollIntervalSeconds`; currently invalid values can be persisted on update.</violation>
</file>

<file name="apps/backend/internal/gitlab/service_search.go">

<violation number="1" location="apps/backend/internal/gitlab/service_search.go:48">
P2: `GetStats` masks GitLab/auth/query failures as zero counts, which can return misleading stats instead of signaling an error.</violation>
</file>

<file name="apps/backend/internal/gitlab/mock_client.go">

<violation number="1" location="apps/backend/internal/gitlab/mock_client.go:440">
P2: `SearchProjects` is documented as substring search but currently uses exact equality, causing partial queries to fail in mock tests.</violation>
</file>

<file name="apps/backend/internal/gitlab/poller.go">

<violation number="1" location="apps/backend/internal/gitlab/poller.go:38">
P1: Poller lifecycle state is not thread-safe; concurrent Start/Stop can race, leak loops, or block shutdown.</violation>
</file>

<file name="apps/backend/internal/gitlab/service_sync.go">

<violation number="1" location="apps/backend/internal/gitlab/service_sync.go:15">
P2: TriggerMRSync breaks the optional-store contract by returning an error instead of an empty result when the store is unset.</violation>
</file>

<file name="apps/backend/internal/gitlab/pat_client_actions.go">

<violation number="1" location="apps/backend/internal/gitlab/pat_client_actions.go:123">
P2: `ListUserProjects` only fetches the first page (`per_page=100`) and drops additional projects. This can silently hide repositories in project pickers/watch setup for users in many projects.</violation>
</file>

<file name="apps/backend/internal/gitlab/store_watches_test.go">

<violation number="1" location="apps/backend/internal/gitlab/store_watches_test.go:78">
P2: The post-delete assertion drops `GetReviewWatch` errors, which can let the test pass on query failures.</violation>

<violation number="2" location="apps/backend/internal/gitlab/store_watches_test.go:91">
P2: The test ignores an error on the duplicate reserve path, so it can pass even when the operation fails unexpectedly.</violation>
</file>

<file name="apps/backend/internal/gitlab/service_watches.go">

<violation number="1" location="apps/backend/internal/gitlab/service_watches.go:786">
P2: `appendLabelsToQuery` uses a raw substring match for `labels=`; parse query params instead to avoid skipping label injection on false matches.</violation>
</file>

<file name="apps/backend/internal/orchestrator/event_handlers_gitlab.go">

<violation number="1" location="apps/backend/internal/orchestrator/event_handlers_gitlab.go:82">
P2: This adds a new ad-hoc watcher task-creation flow instead of using `WatcherDispatchCoordinator`, reintroducing the `createXIssueTask` pattern that the backend architecture explicitly forbids.</violation>

<violation number="2" location="apps/backend/internal/orchestrator/event_handlers_gitlab.go:83">
P2: Returning early when `gitlabService` is nil drops watch events; this should fail open (skip dedup) instead of skipping task creation.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/backend/internal/gitlab/store_watches_test.go
Comment thread apps/backend/internal/gitlab/store_watches_test.go
Comment thread apps/backend/internal/gitlab/service_watches.go Outdated
Comment thread apps/backend/internal/orchestrator/event_handlers_gitlab.go
Comment thread apps/backend/internal/orchestrator/event_handlers_gitlab.go
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/backend/internal/gitlab/handlers.go`:
- Around line 553-563: The wsNewDiscussionNote WebSocket handler currently
doesn't validate that the incoming request Body is non-empty, letting empty
discussion notes proceed and cause internal errors; update the handler
(wsNewDiscussionNote) to check req.Body after parseInto and return
badRequest(msg, "project, iid, discussion_id, body required") when req.Body ==
"" (alongside existing checks for Project, IID, and DiscussionID) so empty-body
requests are rejected like the HTTP handler.

In `@apps/backend/internal/gitlab/mock_client.go`:
- Around line 433-444: The mock SearchProjects implementation is too strict
because MockClient.SearchProjects only returns projects when p.PathWithNamespace
or p.Path exactly equals the query; change it to perform partial (substring)
matching so autocomplete/search flows work: normalize the query (trim and
optionally lowercase) and check if strings.Contains(p.PathWithNamespace, query)
|| strings.Contains(p.Path, query) (use case-insensitive comparison if
production does) and append matches to out; keep the same return types and
behavior when query is empty.

In `@apps/backend/internal/gitlab/service_cleanup.go`:
- Around line 147-151: The current check calls
checker.HasUserAuthoredMessage(ctx, t.TaskID) but ignores the error, which can
cause deletion on failure; change the logic in the block that checks
CleanupPolicyAuto to capture the (authored, err) result and if err != nil treat
it as a fail-safe by returning false (i.e., do not proceed with cleanup). Update
the conditional around HasUserAuthoredMessage (referencing checker,
HasUserAuthoredMessage, CleanupPolicyAuto, and t.TaskID) so any error causes an
immediate false return instead of continuing.
- Around line 74-78: The check currently discards the error from
checker.HasUserAuthoredMessage(ctx, t.TaskID) causing a fail-open delete; change
the logic in the CleanupPolicyAuto branch to handle the error explicitly: call
checker.HasUserAuthoredMessage(ctx, t.TaskID), if it returns (true, nil) return
false (preserve), if it returns (false, err) treat this as a safe failure — log
the error and return false to skip deletion (i.e., preserve the task) so
transient errors don't cause user-interacted tasks to be deleted; update the
code around CleanupPolicyAuto and the checker.HasUserAuthoredMessage call
accordingly.

In `@apps/backend/internal/gitlab/service_watches.go`:
- Around line 233-235: Normalize the updated poll interval the same way the
create path does instead of writing raw values: in the update handler where
req.PollIntervalSeconds is checked and assigned to rw.PollIntervalSeconds, read
the incoming value into a local (e.g., v := *req.PollIntervalSeconds) and clamp
it using the same bounds used by create (e.g., if v <= 0 then set to
DefaultPollIntervalSeconds, and if v < MinPollIntervalSeconds set to
MinPollIntervalSeconds, and optionally cap to MaxPollIntervalSeconds if
applicable) before assigning rw.PollIntervalSeconds = v; use the same constants
or helper (MinPollIntervalSeconds, DefaultPollIntervalSeconds or the create-path
clamp function) to ensure consistent behavior.
- Around line 457-470: UpdateIssueWatch currently calls applyIssueWatchPatch(iw,
req) without checking if req is nil, causing a panic when req is nil; add a nil
guard: only call applyIssueWatchPatch when req != nil, and only validate
req.CleanupPolicy when req is non-nil (i.e., move the CleanupPolicy validation
inside the req != nil branch). Locate UpdateIssueWatch and implement the
nil-check around applyIssueWatchPatch and the IsValidCleanupPolicy check so the
function safely updates the issue watch when req is provided and simply persists
the existing iw when req is nil.
- Around line 190-203: UpdateReviewWatch currently calls
applyReviewWatchPatch(rw, req) and dereferences req.CleanupPolicy without
guarding req for nil; add a nil-check at the start of UpdateReviewWatch to
return a clear error (e.g., "nil update request") if req is nil, and only call
applyReviewWatchPatch and evaluate req.CleanupPolicy when req != nil. Reference:
UpdateReviewWatch, applyReviewWatchPatch, IsValidCleanupPolicy,
requireStore().GetReviewWatch/UpdateReviewWatch.
- Around line 786-787: The current check uses strings.Contains(customQuery,
"labels=") which can false-match keys like "mylabels="; update the condition to
detect the literal query key "labels" instead—e.g. parse the query with net/url
(url.ParseQuery(strings.TrimPrefix(customQuery, "?"))) and test for the "labels"
key in the returned map, or use a regex that looks for "(^|&|\\?)labels=";
replace the existing strings.Contains(customQuery, "labels=") usage accordingly
so existence is determined by the actual query key, not a raw substring.

In `@apps/backend/internal/orchestrator/event_handlers_gitlab.go`:
- Line 98: The calls to s.gitlabService.ReleaseReviewMRTask (and the similar
ReleaseReviewIssueTask) currently ignore returned errors; change those call
sites to capture the error, log a clear error message including identifiers
(evt.ReviewWatchID, mr.ProjectPath, mr.IID or issue equivalents) and return or
surface the error so the caller can handle retries/cleanup rather than silently
leaving the dedup-reservation in place; ensure you update both the
ReleaseReviewMRTask and ReleaseReviewIssueTask invocations to check the error
and handle it consistently.

In `@apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts`:
- Around line 20-29: The effect currently swallows errors and never marks
presets as attempted, causing an infinite refetch when workspaceId exists and
presets is still unset; update the .catch handler for
getActionPresets(workspaceId) to set a sentinel value via the existing setter
(e.g., call set(workspaceId, null) or set(workspaceId, [])) to mark that the
fetch was attempted and failed, and keep the .finally(() => setLoading(false))
as-is so loading is cleared; reference the useEffect block and the
functions/variables getActionPresets, set, presets, loading, workspaceId, and
setLoading to locate where to add the catch-side set call.

In `@apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts`:
- Around line 26-33: The effect uses a global loaded/loading flag so switching
workspaceId can skip fetching and show stale watches; make the load state
scope-aware by tracking which workspace was last loaded (e.g., add
lastLoadedWorkspaceId or a map keyed by workspaceId) and update setLoading/set
accordingly: when workspaceId changes clear or compare against
lastLoadedWorkspaceId before returning early, and ensure the useEffect depends
on workspaceId and lastLoadedWorkspaceId so listIssueWatches(workspaceId, ...)
runs whenever the current workspace’s watches haven’t been loaded yet; update
the logic around useEffect, loaded, loading, listIssueWatches, setLoading, and
set to reference the per-workspace loaded marker.

In `@apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts`:
- Around line 32-39: The effect currently gates fetching by a global loaded flag
so when workspaceId changes and loaded is already true the fetch is skipped
leaving stale data; update the logic in the useEffect that references
workspaceId, loaded, loading, listReviewWatches, set and setLoading so that
loaded is scoped to the current workspace (e.g., track loadedForWorkspaceId or
reset loaded when workspaceId changes) and/or include workspaceId in the gating
check—ensure the effect triggers a fetch for a new workspaceId by clearing or
scoping the loaded flag before calling listReviewWatches and then set or
setLoading as before.

In `@apps/web/hooks/domains/gitlab/use-gitlab-stats.ts`:
- Around line 17-24: The effect currently keeps retrying because a failed fetch
sets stats to null (falsy) so the guard (if (stats || loading) return) doesn't
stop subsequent runs; add a failure flag (e.g., statsError with setStatsError)
or a retry counter and set it in the catch of fetchGitLabStats, then include
that flag/counter in the effect's guard and dependency array so the effect
returns when a permanent failure is detected; update the catch handler to call
setStatsError(true) (or increment the retry counter) instead of only
setStats(null), and add statsError (or retry count) to the if-check and
dependency list to prevent endless retries.

In `@apps/web/hooks/domains/gitlab/use-gitlab-status.ts`:
- Around line 17-24: The effect in use-gitlab-status (the useEffect that checks
status/loading and calls fetchGitLabStatus) re-triggers after a failed fetch
because status is set to null; prevent the retry loop by adding and checking a
fetch-attempt flag (e.g., statusTried or statusFetchError) in the hook state and
dependencies, set that flag to true in both .then and .catch handlers (or set an
error flag in .catch), and update the effect condition to return early if
statusTried (or statusFetchError) is true; use the existing symbols status,
loading, setStatus, setStatusLoading, and fetchGitLabStatus to implement this
change so a failed fetch does not immediately re-run.

In `@apps/web/lib/state/slices/gitlab/gitlab-slice.ts`:
- Around line 6-8: The slice's workspace-scoped watch state
(gitlabReviewWatches, gitlabIssueWatches, gitlabMRWatches) uses a single global
loaded/loading/items bag which can serve stale data across workspace switches;
change the shape to key items/loading/loaded by workspaceId (e.g. {
[workspaceId]: { items, loaded, loading } }) or add a currentLoadedWorkspaceId
per watch and invalidate/reset the loaded flag and items when workspaceId
changes, updating all selectors and reducers that read/write gitlab*Watches (and
the corresponding logic referenced in the same file lines ~58-117) so consumers
always fetch fresh lists for the active workspace.

In `@apps/web/lib/state/store.ts`:
- Around line 190-195: Post-slice override only restores taskMRs but misses the
new GitLab fields so merged/hydrated values for gitlabReviewWatches,
gitlabIssueWatches, gitlabMRWatches, gitlabActionPresets, gitlabStats and
gitlabStatus get lost; update the post-slice override in store.ts (the block
that restores from merged state after slice creation) to also copy these
properties from the merged/hydrated state into the store (same pattern used for
taskMRs) so the new GitLab sub-state is preserved on store creation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e05f6286-65df-42cd-a3e6-54113d4a9926

📥 Commits

Reviewing files that changed from the base of the PR and between 3b1b347 and 475bd15.

📒 Files selected for processing (40)
  • apps/backend/cmd/kandev/helpers.go
  • apps/backend/cmd/kandev/main.go
  • apps/backend/cmd/kandev/services.go
  • apps/backend/internal/events/types.go
  • apps/backend/internal/gitlab/action_presets_service.go
  • apps/backend/internal/gitlab/action_presets_test.go
  • apps/backend/internal/gitlab/client.go
  • apps/backend/internal/gitlab/controller.go
  • apps/backend/internal/gitlab/controller_watches.go
  • apps/backend/internal/gitlab/handlers.go
  • apps/backend/internal/gitlab/mock_client.go
  • apps/backend/internal/gitlab/mock_controller.go
  • apps/backend/internal/gitlab/noop_client.go
  • apps/backend/internal/gitlab/pat_client_actions.go
  • apps/backend/internal/gitlab/poller.go
  • apps/backend/internal/gitlab/service.go
  • apps/backend/internal/gitlab/service_cleanup.go
  • apps/backend/internal/gitlab/service_merge.go
  • apps/backend/internal/gitlab/service_search.go
  • apps/backend/internal/gitlab/service_sync.go
  • apps/backend/internal/gitlab/service_watches.go
  • apps/backend/internal/gitlab/service_watches_test.go
  • apps/backend/internal/gitlab/store.go
  • apps/backend/internal/gitlab/store_watches.go
  • apps/backend/internal/gitlab/store_watches_test.go
  • apps/backend/internal/gitlab/watch_models.go
  • apps/backend/internal/orchestrator/event_handlers_gitlab.go
  • apps/backend/internal/orchestrator/service.go
  • apps/backend/pkg/websocket/actions.go
  • apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts
  • apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts
  • apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts
  • apps/web/hooks/domains/gitlab/use-gitlab-stats.ts
  • apps/web/hooks/domains/gitlab/use-gitlab-status.ts
  • apps/web/lib/api/domains/gitlab-api.ts
  • apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts
  • apps/web/lib/state/slices/gitlab/gitlab-slice.ts
  • apps/web/lib/state/slices/gitlab/types.ts
  • apps/web/lib/state/store.ts
  • apps/web/lib/types/gitlab.ts

Comment thread apps/backend/internal/gitlab/handlers.go
Comment thread apps/backend/internal/gitlab/mock_client.go
Comment thread apps/backend/internal/gitlab/service_cleanup.go
Comment thread apps/backend/internal/gitlab/service_cleanup.go
Comment thread apps/backend/internal/gitlab/service_watches.go
Comment thread apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts
Comment thread apps/web/hooks/domains/gitlab/use-gitlab-stats.ts Outdated
Comment thread apps/web/hooks/domains/gitlab/use-gitlab-status.ts Outdated
Comment thread apps/web/lib/state/slices/gitlab/gitlab-slice.ts
Comment thread apps/web/lib/state/store.ts
@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Claude finished @jcfs's task in 5m 45s —— View job


Code Review

  • Load tools
  • Read review skill instructions
  • Gather context — read changed files
  • Review backend changes (service, store, handlers, poller, cleanup, merge)
  • Review frontend changes (hooks, API client, store slice)
  • Post findings

Findings

Blocker (must fix before merge)

1. createGitLabReviewTask/createGitLabIssueTask bypass WatcherDispatchCoordinatorevent_handlers_gitlab.go:82
Both functions reimplement the Reserve→Create→AttachID→Release pipeline that WatcherDispatchCoordinator owns. apps/backend/AGENTS.md is explicit: "Do NOT add another createXIssueTask mirror." The fix is to implement WatcherSource for GitLab review MRs and issues and call s.watcherDispatch.Dispatch(ctx, src, evt).

2. HasUserAuthoredMessage error silently dropped → fail-open task deletionservice_cleanup.go:83,159
Error is discarded with _; on any transient DB failure authored defaults to false and the task is deleted even if the user sent a message. Fix: authored, err := ...; if err != nil || authored { return false }.

3. Poll interval not normalized in update pathservice_watches.go:264,531
applyReviewWatchPatch and applyIssueWatchPatch write raw PollIntervalSeconds without the ≤0 → 300 / < 60 → 60 clamp the create path enforces. A zero value is silently persisted to the DB.

Suggestion (recommended, doesn't block)

  1. poller.go:38p.started read/written without mutex; go test -race would flag it
  2. service_watches.go:188,455 — initial-check goroutines use context.Background(), uncancellable on shutdown
  3. event_handlers_gitlab.go:166 — dead code: gitlabRepoSlug function + var _ = blank identifier (speculative scaffolding)
  4. service_watches.go:816strings.Contains(customQuery, "labels=") false-matches mylabels=; use url.ParseQuery
  5. use-gitlab-review-watches.ts:33, use-gitlab-issue-watches.ts:27loaded is global, not workspace-scoped; stale watches survive workspace switch

Summary

Severity Count
Blocker 3
Suggestion 5

Verdict: Blocked — fix blockers first.

View job

Comment thread apps/backend/internal/orchestrator/event_handlers_gitlab.go
Comment thread apps/backend/internal/gitlab/service_cleanup.go Outdated
Comment thread apps/backend/internal/gitlab/service_watches.go Outdated
Comment thread apps/backend/internal/gitlab/poller.go
Comment thread apps/backend/internal/gitlab/service_watches.go
Comment thread apps/backend/internal/orchestrator/event_handlers_gitlab.go Outdated
Comment thread apps/backend/internal/gitlab/service_watches.go Outdated
if username == "" {
return nil, fmt.Errorf("no authenticated gitlab user")
}
filter := watch.CustomQuery
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: dead code in the custom-query branch of fetchReviewMRs

When watch.CustomQuery != "", filter is set to watch.CustomQuery here, then SearchMRs(ctx, filter, watch.CustomQuery) is called with both args being the same value. buildMRSearchQuery returns customQuery verbatim when non-empty, so the filter arg is silently discarded — the assignment on this line is dead code that implies it affects the query but doesn't.

Suggested change
filter := watch.CustomQuery
filter := ""
if watch.CustomQuery == "" {
filter = "reviewer_username=" + url.QueryEscape(username)
}

// If the query already contains `labels=`, it is left alone (the user is
// explicitly controlling the filter and we don't want to silently double-up).
func appendLabelsToQuery(customQuery string, labels []string) string {
if strings.Contains(customQuery, "labels=") {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: strings.Contains false-matches keys like mylabels=

strings.Contains(customQuery, "labels=") returns true for "mylabels=critical", causing label injection to be silently skipped when the user hasn't actually set the labels key. Use url.ParseQuery for an exact key check:

Suggested change
if strings.Contains(customQuery, "labels=") {
func appendLabelsToQuery(customQuery string, labels []string) string {
if vals, err := url.ParseQuery(customQuery); err == nil {
if _, ok := vals["labels"]; ok {
return customQuery
}
}
encoded := url.QueryEscape(strings.Join(labels, ","))
if customQuery == "" {
return "labels=" + encoded
}
return customQuery + "&labels=" + encoded
}


// Start kicks off the polling loops. Repeated Start calls are no-ops.
func (p *Poller) Start(ctx context.Context) {
if p.started {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: p.started read/written without synchronization

Start reads p.started here and writes it on line 41; Stop reads (line 52) and writes (line 57) — no mutex guards any of these. go test -race would flag concurrent Start/Stop calls.

Suggested change
if p.started {
p.mu.Lock()
defer p.mu.Unlock()
if p.started {
return
}

Add mu sync.Mutex to the Poller struct. In Stop, acquire and release the lock before calling cancel() so wg.Wait() is outside the lock.

return fmt.Sprintf("gitlab:%s", projectPath)
}

var _ = gitlabRepoSlug // reserved for future structured-logging helpers
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: dead code — remove gitlabRepoSlug and blank identifier

gitlabRepoSlug has zero callers; var _ = gitlabRepoSlug exists only to suppress the compiler error. Per the project's no-speculative-code rule (CLAUDE.md): "No speculative code — unused flags/options, 'reserved for future' scaffolding". Delete both and re-add when there is an actual call site.

last_polled_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: missing indexes on gitlab_review_watches and gitlab_issue_watches

Both the 5-minute poller (ListAllReviewWatches / ListAllIssueWatches) and the HTTP list endpoints (?workspace_id=X) do full table scans. gitlab_mr_watches already has an index on line 84. Add matching pairs:

Suggested change
);
);
CREATE INDEX IF NOT EXISTS idx_gitlab_review_watches_workspace_id ON gitlab_review_watches(workspace_id);
CREATE INDEX IF NOT EXISTS idx_gitlab_review_watches_enabled ON gitlab_review_watches(enabled);
CREATE TABLE IF NOT EXISTS gitlab_review_mr_tasks (

Add the same two lines after the gitlab_issue_watches table definition (~line 133).

func (c *Controller) httpTriggerReviewWatch(ctx *gin.Context) {
id := ctx.Param("id")
mrs, err := c.service.TriggerReviewWatch(ctx.Request.Context(), id)
if err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: 500 instead of 404 for a missing watch

TriggerReviewWatch returns fmt.Errorf("review watch not found: %s", id) when the watch doesn't exist (service_watches.go). That error maps here to http.StatusInternalServerError, making it impossible for clients to distinguish a missing watch from a real server fault.

Suggested change
if err != nil {
mrs, err := c.service.TriggerReviewWatch(ctx.Request.Context(), id)
if err != nil {
if errors.Is(err, errStoreUnavailable) || strings.Contains(err.Error(), "not found") {
ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: err.Error()})
return
}
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
return
}

Same issue in httpTriggerIssueWatch below (~line 253). Long-term, a sentinel ErrWatchNotFound in watch_models.go + errors.Is would be cleaner than string matching.

}
out := make([]Project, 0, len(projects))
for _, p := range projects {
if p.PathWithNamespace == query || p.Path == query {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: exact match diverges from documented "substring" behavior

The comment on line 432 says "filtered by query substring" but the implementation uses ==. Any autocomplete/search flow that sends a partial query string returns zero results in mock mode, creating false test negatives.

Suggested change
if p.PathWithNamespace == query || p.Path == query {
if strings.Contains(strings.ToLower(p.PathWithNamespace), strings.ToLower(query)) ||
strings.Contains(strings.ToLower(p.Path), strings.ToLower(query)) {
out = append(out, p)
}

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/backend/internal/gitlab/service_events.go Outdated
@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Claude finished @jcfs's task in 3m 51s —— View job


Code Review

  • Load tools
  • Read review skill instructions
  • Gather context — read current state of changed files
  • Review backend changes (poller, cleanup, store, watches, handlers)
  • Review frontend changes (hooks, slice)
  • Post findings

Findings

Good progress across all prior rounds. The latest commit (a1f2316) addressed the remaining suggestions from the previous pass: appendLabelsToQuery now uses url.ParseQuery, fetchReviewMRs dead code is removed, indexes added for gitlab_review_watches/gitlab_issue_watches, SearchProjects uses substring matching, Poller.started is mutex-guarded, ErrWatchNotFound sentinel maps trigger endpoints to 404, WS handlers reject malformed JSON, publishWatchEvent logs errors, and gitlabRepoSlug dead code is gone.

One blocker remains.

Blocker (must fix before merge)

CheckMRWatch nil-store dereference — poller crashes every minute

store := s.requireStore() at line 124 of service_watches.go can return nil when NewStore failed at boot (non-fatal path). The function then uses store unconditionally at lines 130 and 133 without a nil check — both panic on nil. The mrMonitorLoop calls CheckMRWatch every minute for every active MR watch, so this crash repeats on a timer.

Every sibling method in this file that was flagged in prior rounds (GetReviewWatch, CheckReviewWatch, CheckIssueWatch, DeleteReviewWatch, DeleteIssueWatch, cleanup functions) all have explicit nil guards added in commits 8097bd9 and dcb9614. This one was missed.

See inline comment — one-line fix.

Suggestions (non-blocking)

ReleaseReviewMRTask / ReleaseIssueWatchTask errors silently droppedevent_handlers_gitlab.go:97,144

If the release call fails after a task-create failure, the dedup reservation row stays locked and the same MR/issue will never be retried. The error is currently discarded with _ =. Logging the failure (like the surrounding ReleaseReviewMRTask callers do) would at minimum make this detectable.


Severity Count
Blocker 1
Suggestion 1

Verdict: Blocked — fix the CheckMRWatch nil store dereference (one-line fix, see inline) and this is ready to merge.

mr, err := client.FindMRByBranch(ctx, watch.ProjectPath, watch.Branch)
if err != nil || mr == nil {
now := time.Now().UTC()
_ = store.UpdateMRWatchTimestamps(ctx, watch.ID, now, watch.LastNoteAt, watch.LastPipelineState, watch.LastApprovalState)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: nil store dereference — panics on every MR watch poll when the store is unavailable

store := s.requireStore() at line 124 can return nil when NewStore failed at boot (non-fatal path in services.go). store is then used unconditionally at line 130 (store.UpdateMRWatchTimestamps) and line 133 (store.UpdateMRWatchMRIID), both of which panic on nil.

The mrMonitorLoop calls CheckMRWatch every minute for every active MR watch — this crash fires repeatedly on a timer.

Every sibling method in this file (GetReviewWatch, CheckReviewWatch, CheckIssueWatch, DeleteReviewWatch, DeleteIssueWatch) all have explicit nil guards added in prior commits. This one was missed.

Suggested change
_ = store.UpdateMRWatchTimestamps(ctx, watch.ID, now, watch.LastNoteAt, watch.LastPipelineState, watch.LastApprovalState)
store := s.requireStore()
if store == nil {
return nil, false, errStoreUnavailable
}
// If we don't yet know an iid, try to find it from the branch.

@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Claude finished @jcfs's task in 2m 4s —— View job


Code Review

  • Load tools
  • Read current state of changed files (post-a1f2316)
  • Check if CheckMRWatch nil-store blocker was addressed
  • Review remaining suggestions
  • Post inline findings + summary

Findings

Good progress since the last pass. All previous blocker-round items are confirmed resolved:

  • appendLabelsToQuery uses url.ParseQuery / .Has("labels")
  • fetchReviewMRs dead code removed — filter path is now clean ✅
  • Poller.started is mutex-guarded in both Start and Stop
  • SearchProjects mock uses case-insensitive substring matching ✅
  • httpRespondError sentinel properly maps ErrWatchNotFound → 404 ✅
  • Indexes on gitlab_review_watches and gitlab_issue_watches added ✅
  • Dead code gitlabRepoSlug removed ✅

Blocker (must fix before merge)

CheckMRWatch nil-store dereference — poller crashes every minute

store := s.requireStore() at line 124 can return nil when NewStore fails at boot (non-fatal path in services.go). store is then used unconditionally at lines 130, 133, and 145 without a nil guard — all three panic on nil. mrMonitorLoop calls CheckMRWatch every minute for every active MR watch, so this crash fires repeatedly on a timer.

Every sibling method (CheckReviewWatch, CheckIssueWatch, DeleteReviewWatch, DeleteIssueWatch, cleanup functions) was correctly guarded in prior commits. This one was missed again. See inline comment.

Suggestion (non-blocking)

ReleaseReviewMRTask / ReleaseIssueWatchTask errors silently droppedevent_handlers_gitlab.go:97,144

If the release call fails after task-create failure, the dedup reservation row stays locked and the same MR/issue can never be retried. The error is discarded with _ =. Logging the failure would at minimum make this detectable in production.


Severity Count
Blocker 1
Suggestion 1

Verdict: Blocked — one-line fix to CheckMRWatch (see inline), then this is ready to merge.

if client == nil {
return nil, false, ErrNoClient
}
store := s.requireStore()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: nil store dereference — crashes the MR poller every minute

s.requireStore() can return nil when NewStore failed at boot (non-fatal path in services.go). store is then used unconditionally at lines 130, 133, and 145 — all three panic on nil.

mrMonitorLoop calls CheckMRWatch every minute for every active MR watch, so this crash fires on a recurring timer. CheckReviewWatch and CheckIssueWatch both have the correct nil guard — this one was missed.

Suggested change
store := s.requireStore()
store := s.requireStore()
if store == nil {
return nil, false, errStoreUnavailable
}

taskID, err := s.gitlabReviewTaskCreator.CreateGitLabReviewTask(ctx, evt)
if err != nil {
s.logger.Warn("create gitlab review task", zap.Error(err))
_ = s.gitlabService.ReleaseReviewMRTask(ctx, evt.ReviewWatchID, mr.ProjectPath, mr.IID)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: release error silently dropped — can permanently lock dedup row

If ReleaseReviewMRTask fails after a task-create failure, the reservation row stays locked and the same MR will never be retried until the row expires or is manually cleared.

Suggested change
_ = s.gitlabService.ReleaseReviewMRTask(ctx, evt.ReviewWatchID, mr.ProjectPath, mr.IID)
if relErr := s.gitlabService.ReleaseReviewMRTask(ctx, evt.ReviewWatchID, mr.ProjectPath, mr.IID); relErr != nil {
s.logger.Warn("release gitlab review MR task reservation", zap.Error(relErr))
}

Kandev Agent and others added 12 commits May 29, 2026 17:28
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
…ooks

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).
… 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).
…ts, 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
…pdate

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
…atch 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
… 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)
…anup 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
…ive 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.
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
…gs, 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>
@jcfs jcfs force-pushed the feature/full-gitlab-integrat-5h2 branch from 7068cd7 to 49cd445 Compare May 29, 2026 16:38
@claude
Copy link
Copy Markdown

claude Bot commented May 29, 2026

Claude finished @jcfs's task in 3m 16s —— View job


Code Review

  • Read review skill instructions
  • Gather context — read current state of changed files (post-49cd445)
  • Review backend changes
  • Review frontend changes
  • Post findings

Findings

All previously flagged blockers are confirmed resolved in commit 49cd445:

  • CheckMRWatch nil store dereference (service_watches.go:124-127) — ✅ fixed
  • CheckReviewWatch / CheckIssueWatch nil guards — ✅ fixed
  • DeleteReviewWatch / DeleteIssueWatch nil guards — ✅ fixed
  • CleanupAllReviewTasks / CleanupAllIssueTasks nil + inline dereferences — ✅ fixed
  • HasUserAuthoredMessage fail-open data loss — ✅ fixed (error preserved, task kept)
  • Poll interval clamping on update path — ✅ fixed (clampPollInterval())
  • lookupReviewPolicy/lookupIssuePolicy fall back to CleanupPolicyNever on error — ✅ fixed
  • SetTaskDeleter/SetTaskSessionChecker wiring — ✅ fixed
  • Orchestrator GitLab event subscriptions wired — ✅ fixed
  • use-gitlab-status / use-gitlab-stats infinite re-fetch loops — ✅ fixed
  • use-gitlab-review-watches / use-gitlab-issue-watches workspace-scope stale data — ✅ fixed (lastFetchedRef)
  • use-gitlab-action-presets infinite retry — ✅ fixed (attemptedRef)
  • appendLabelsToQuery false-match on mylabels= — ✅ fixed (uses url.ParseQuery)
  • fetchReviewMRs dead code in custom-query branch — ✅ fixed
  • WS handler parse error handling — ✅ fixed
  • SearchProjects exact match → case-insensitive substring — ✅ fixed
  • Indexes on gitlab_review_watches/gitlab_issue_watches — ✅ fixed
  • 404 for missing watch trigger — ✅ fixed (ErrWatchNotFound sentinel + httpRespondError)
  • gitlabRepoSlug dead code — ✅ removed
  • ReleaseReviewMRTask/ReleaseIssueWatchTask release errors now logged — ✅ fixed
  • GitLab state fields in store-overrides.ts — ✅ present
  • ResetActionPresets workspace ID validation — ✅ fixed
  • Partial preset update no longer freezes defaults — ✅ fixed
  • Poller.started mutex protection — ✅ fixed

Suggestion (recommended, doesn't block)

1. Initial-check goroutines use context.Background() — carry-over from all prior rounds

service_watches.go:199 and service_issue_watches.go:42 both launch with context.Background(). These goroutines are invisible to Poller.Stop()'s WaitGroup and continue running after shutdown, publishing events to a draining bus. Low production risk in practice (short-lived check, poller shutdown waits for the ticker loops) but non-idiomatic and will trip goroutine-leak tests if the package ever adds goleak.VerifyTestMain. Passing a service-lifecycle context or at minimum the request ctx would close the gap.

2. Test assertions silently drop errors — store_watches_test.go:78, 91

// line 78 — GetReviewWatch error ignored after delete
got3, _ := store.GetReviewWatch(ctx, rw.ID)

// line 91 — second Reserve error ignored
ok2, _ := store.ReserveReviewMRTask(ctx, "watch-1", "group/project", 42, "https://x")

The tests check the ok/nil return but dropping the error means the test passes even if the DB call failed for an unrelated reason (e.g., schema mismatch). Should be got3, err := ...; if err != nil { t.Fatal(err) }.


Summary

Severity Count
Blocker 0
Suggestion 2

Verdict: Ready to merge. All blockers from prior rounds are resolved. The two suggestions are carry-overs (items 1 and 2 were flagged in earlier rounds as suggestions); they don't affect correctness or production safety.

if err := store.CreateReviewWatch(ctx, rw); err != nil {
return nil, fmt.Errorf("create review watch: %w", err)
}
go s.initialReviewCheck(context.Background(), rw)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (carry-over from prior rounds): goroutine uses context.Background() — uncancellable on shutdown

initialReviewCheck is launched with context.Background() and is not tracked by any WaitGroup. After Poller.Stop() returns the goroutine may still be running and publishing events to a draining bus. The same pattern applies to initialIssueCheck in service_issue_watches.go:42.

Low production risk (the check is short-lived), but non-idiomatic and would trip goleak.VerifyTestMain if the package ever adds goroutine-leak testing (which internal/gitlab doesn't have yet — see AGENTS.md on which packages are instrumented).

Suggested change
go s.initialReviewCheck(context.Background(), rw)
go s.initialReviewCheck(ctx, rw)

Passing the caller's ctx here (which is HTTP-request-scoped) bounds the goroutine to the request lifetime. A longer-lived service context (set at Service.Start time) would be even better for a recurring background goroutine, but either is better than Background().

if err := store.DeleteReviewWatch(ctx, rw.ID); err != nil {
t.Fatalf("DeleteReviewWatch: %v", err)
}
got3, _ := store.GetReviewWatch(ctx, rw.ID)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: error silently dropped — test can pass on unrelated DB failure

The blank identifier drops the error return. If GetReviewWatch fails for any reason other than "not found" (e.g., schema issue), the test still passes because got3 would be nil and the assertion if got3 != nil would succeed.

Suggested change
got3, _ := store.GetReviewWatch(ctx, rw.ID)
got3, err3 := store.GetReviewWatch(ctx, rw.ID)
if err3 != nil {
t.Fatalf("GetReviewWatch after delete: %v", err3)
}

Same pattern at line 71 (got2, _ := ...).

Comment on lines +220 to +242
func (s *Service) fetchIssues(ctx context.Context, watch *IssueWatch) ([]*Issue, error) {
client := s.Client()
username, err := client.GetAuthenticatedUser(ctx)
if err != nil {
return nil, fmt.Errorf("resolve gitlab username: %w", err)
}
// When a custom_query is set, labels need to be folded into it (the
// client's buildIssueSearchQuery returns customQuery verbatim and
// ignores the auxiliary `filter` arg). Otherwise build a default
// "assigned to me" filter and append labels there.
filter := ""
customQuery := watch.CustomQuery
switch {
case customQuery != "":
if len(watch.Labels) > 0 {
customQuery = appendLabelsToQuery(customQuery, watch.Labels)
}
default:
filter = "assignee_username=" + url.QueryEscape(username)
if len(watch.Labels) > 0 {
filter += "&labels=" + url.QueryEscape(strings.Join(watch.Labels, ","))
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Move GetAuthenticatedUser inside the branch that actually needs it. When customQuery != "", username is never referenced, so calling the user-info endpoint unconditionally wastes a round-trip and makes custom-query watches fail if that endpoint is unavailable or the PAT lacks read_user scope.

Suggested change
func (s *Service) fetchIssues(ctx context.Context, watch *IssueWatch) ([]*Issue, error) {
client := s.Client()
username, err := client.GetAuthenticatedUser(ctx)
if err != nil {
return nil, fmt.Errorf("resolve gitlab username: %w", err)
}
// When a custom_query is set, labels need to be folded into it (the
// client's buildIssueSearchQuery returns customQuery verbatim and
// ignores the auxiliary `filter` arg). Otherwise build a default
// "assigned to me" filter and append labels there.
filter := ""
customQuery := watch.CustomQuery
switch {
case customQuery != "":
if len(watch.Labels) > 0 {
customQuery = appendLabelsToQuery(customQuery, watch.Labels)
}
default:
filter = "assignee_username=" + url.QueryEscape(username)
if len(watch.Labels) > 0 {
filter += "&labels=" + url.QueryEscape(strings.Join(watch.Labels, ","))
}
}
func (s *Service) fetchIssues(ctx context.Context, watch *IssueWatch) ([]*Issue, error) {
client := s.Client()
// When a custom_query is set, labels need to be folded into it (the
// client's buildIssueSearchQuery returns customQuery verbatim and
// ignores the auxiliary `filter` arg). Otherwise build a default
// "assigned to me" filter and append labels there.
filter := ""
customQuery := watch.CustomQuery
switch {
case customQuery != "":
if len(watch.Labels) > 0 {
customQuery = appendLabelsToQuery(customQuery, watch.Labels)
}
default:
username, err := client.GetAuthenticatedUser(ctx)
if err != nil {
return nil, fmt.Errorf("resolve gitlab username: %w", err)
}
filter = "assignee_username=" + url.QueryEscape(username)
if len(watch.Labels) > 0 {
filter += "&labels=" + url.QueryEscape(strings.Join(watch.Labels, ","))
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant