Files
kandev/apps/backend/internal/gitlab/handlers.go
T
João Salavisa dfaab11959 feat: full GitLab integration — parity with GitHub (#1120)
* feat(backend): full GitLab parity — store, service, poller, WS, presets

Add the watcher/poll/write-action surface on top of the existing GitLab
client + service skeleton:

* 5 new SQLite tables (mr_watches, review_watches, review_mr_tasks,
  issue_watches, issue_watch_tasks, action_presets) + CRUD
* Client + Service: MergeMR, GetProjectMergeMethods, GetProtectedBranch,
  ListUserProjects, SearchProjects, SetMRLabels, SetMRAssignees
* Service: review/issue/MR watch CRUD + Check/Trigger, GetStats,
  CleanupAllReview/IssueTasks (cleanup-policy aware), TriggerMRSync,
  action-presets CRUD with defaults
* Background poller (MR / review / issue loops) wired from main.go
* ~35 GitLab WS action constants + handlers.go registrations
* Mock controller for E2E seeding (MRs, issues, pipelines, discussions,
  approvals, branches)
* eventBus + taskDeleter + taskSessionChecker plumbed through Service
* Event types: GitLabMRFeedback / NewReviewMR / NewIssue / TaskMRUpdated

* feat(web): GitLab frontend parity — types, API, store slice, domain hooks

Extend the frontend GitLab integration on top of the new backend surface:

* HTTP API client expansion (gitlab-api.ts): full CRUD for review/issue/MR
  watches, MR write actions (merge/approve/labels/assignees), MR
  files/commits/feedback, action presets, projects autocomplete, stats
* Store slice expansion: review/issue/MR watches lists, action presets
  by workspace, stats + status with loading flags. Properties prefixed
  with gitlab to avoid collision with GitHub slice
* Domain hooks: use-gitlab-status, use-gitlab-stats,
  use-gitlab-review-watches, use-gitlab-issue-watches,
  use-gitlab-action-presets — mirror github hook shapes
* Type model: ReviewWatch, IssueWatch, MRWatch, ActionPresets, Stats,
  ProjectMergeMethods, MRApproval, Pipeline, MRFeedback, etc.
* Backend controller HTTP routes for watches/presets/write-actions/
  projects/stats so the frontend HTTP API client has endpoints to hit

All passes: backend (build + vet + lint + tests), web (typecheck + lint +
2400 tests).

* fix(gitlab): wire orchestrator event handlers, fix label-filter drop, add tests

Address Claude review blockers on the parity PR:

1. Wire orchestrator subscriptions for GitLab review/issue watch events.
   Add event_handlers_gitlab.go with handleGitLabNewReviewMR /
   handleGitLabNewIssue, dedup reservation handshake, and task-creator
   interfaces (mirrors event_handlers_github.go). main.go now calls
   orchestratorSvc.SetGitLabService so the dedup APIs are usable
2. Fix silent label-filter drop: when a custom_query is set on an issue
   watch, labels were being appended to the unused filter arg. Fold
   labels into customQuery (or default filter) so they actually reach
   GitLab; extracted appendLabelsToQuery helper with explicit handling
   of pre-existing labels= clauses
3. Switch event payloads to pointer types (mirrors GitHub) so
   handlers can type-assert pointer events
4. Backend tests: store_watches_test.go (CRUD + reserve/assign for all
   4 new tables + presets), service_watches_test.go (label-filter
   helper, cleanup policy, project normalisation), action_presets_test.go
5. Frontend slice tests: review/issue watch CRUD round-trips, action
   presets + stats reducer coverage

All green: backend (build+vet+lint+tests), web (typecheck+lint+2403 tests).

* fix(gitlab): address remaining blockers — cleanup wiring, safe defaults, nil guards

Address the second round of Claude review blockers:

1. main.go: wire SetTaskDeleter + SetTaskSessionChecker on services.GitLab
   (mirrors GitHub). Without these, manual cleanup sweeps would always
   error with "task deleter not configured"
2. service_cleanup.go: default cleanup policy on transient DB error is now
   CleanupPolicyNever (preserve tasks) instead of CleanupPolicyAuto
   (silently delete). Genuine "watch was deleted" path also falls under
   the safe-side default; user can manually delete the orphan tasks
3. service_watches.go: add nil guards on requireStore() so the 6 MR-watch
   list/get/delete methods return an errStoreUnavailable error instead
   of panicking when the SQLite store failed to construct at boot
4. use-gitlab-status.ts + use-gitlab-stats.ts: add per-mount attemptedRef
   so an unreachable GitLab doesn't trigger an infinite re-fetch loop —
   the previous useEffect re-ran every render because the failure path
   left status null, satisfying the !status guard

* fix(gitlab): propagate session-check errors, clamp poll interval on update

Address remaining Claude blockers:

* service_cleanup.go: HasUserAuthoredMessage transient errors are now
  preserved (return false → skip delete) instead of silently ignored;
  the alternative was occasionally deleting tasks a user had touched
* service_watches.go: extract clampPollInterval helper applying the same
  bounds (0 → default, <60 → 60) as the create path; both
  applyReviewWatchPatch and applyIssueWatchPatch use it so user-supplied
  zero or tiny values via UpdateXxxWatchRequest no longer hammer GitLab

* fix(gitlab): clear dupl lint, fix preset retry loop + per-workspace watch cache

* service_watches.go: applyReviewWatchPatch / applyIssueWatchPatch get
  nolint:dupl markers — they share shape but per-domain field validation
  lives in the create paths, so deduplicating via generics would obscure
  the contract. Restores backend lint to 0 issues
* use-gitlab-action-presets.ts: per-workspace attemptedRef set so a
  failing preset fetch doesn't retry on every render
* use-gitlab-review-watches.ts + use-gitlab-issue-watches.ts: track
  lastFetchedRef per consumer so a workspace switch triggers a refetch.
  The slice-level loaded flag is shared across instances and can't
  double as a per-workspace cache key

* fix(gitlab): add nil-store guards across review/issue watch + cleanup paths

Round-3 Claude finding: ReviewWatch / IssueWatch / preset / reservation
methods still called s.requireStore().X() directly. If NewStore fails at
boot (table migration error), the service struct's store is nil and every
list/get/update/create/reserve method panics on first request.

Apply the same store-nil → errStoreUnavailable pattern from f95a929 to
every remaining method:
* CreateReviewWatch / CreateIssueWatch
* GetReviewWatch / ListReviewWatches / ListAllReviewWatches /
  UpdateReviewWatch / TriggerReviewWatchAll
* GetIssueWatch / ListIssueWatches / ListAllIssueWatches /
  UpdateIssueWatch / TriggerIssueWatchAll
* ReserveReviewMRTask / AssignReviewMRTaskID / ReleaseReviewMRTask
* ReserveIssueWatchTask / AssignIssueWatchTaskID / ReleaseIssueWatchTask
* lookupReviewPolicy / lookupIssuePolicy (return CleanupPolicyNever)

* fix(gitlab): nil-store guards on CheckXxxWatch + DeleteXxxWatch + cleanup paths

Round-4 Claude finding: 4 remaining panic sites the previous nil-guard
pass missed — these run from the background poller (every 5 min) and the
manual delete/cleanup flows, so a boot-time NewStore failure would
crash the orchestrator process on first tick.

* service_watches.go: add store-nil guards to DeleteReviewWatch /
  DeleteIssueWatch / CheckReviewWatch / CheckIssueWatch
* service_cleanup.go: guard CleanupAllReviewTasks / CleanupAllIssueTasks
  entry points; wrap the trailing DeleteReviewMRTask / DeleteIssueWatchTask
  calls in `if store := s.requireStore(); store != nil` blocks so the
  cleanup succeeds even when the dedup-row delete is unreachable

* refactor(gitlab): split service_watches.go to stay under 800-line revive limit

CI's --new-from-rev lint caught service_watches.go at 808 lines (limit
800). Split into three focused files:

* service_events.go (94 LOC) — publish helpers for MR feedback /
  new review MR / new issue / watch lifecycle events
* service_issue_watches.go (302 LOC) — Issue watch CRUD + Check +
  Trigger + fetch + helpers
* service_reservations.go (59 LOC) — Reserve/Assign/Release dedup
  handles used by the orchestrator event handlers

service_watches.go is now 505 lines and contains MR watch + Review
watch only. No behavioral change.

* fix(gitlab): address inline code-review feedback

Address actionable inline comments from Claude/CodeRabbit/cubic/Greptile:

* appendLabelsToQuery: use url.ParseQuery for exact key match instead
  of strings.Contains; previously false-matched keys like mylabels=
  and silently dropped the watch's labels (test added)
* fetchReviewMRs: drop dead `filter = watch.CustomQuery` assignment —
  SearchMRs's buildMRSearchQuery returns customQuery verbatim and
  ignores filter when customQuery is non-empty
* store.go: add workspace_id indexes on gitlab_review_watches and
  gitlab_issue_watches (the 5-min poller and HTTP list endpoints did
  full table scans)
* mock_client.SearchProjects: switch from == to case-insensitive
  Contains so partial-query autocomplete returns the seeded project,
  matching the doc comment
* Poller: add sync.Mutex to guard `started` field; Start/Stop are
  now safe to call concurrently (go test -race would have flagged
  the previous read/write race)
* Add ErrWatchNotFound sentinel; controller_watches.go maps it to
  HTTP 404 via httpRespondError helper. Update/Trigger handlers
  previously returned 500 for missing watches, hiding the distinction
  from real server faults
* service_events.go: log Publish errors on watch lifecycle events
  instead of swallowing them silently
* handlers.go: reject malformed JSON payloads in list-style WS
  handlers rather than silently falling through to broader
  list/search behavior
* event_handlers_gitlab.go: remove dead gitlabRepoSlug helper +
  blank-identifier suppression

* ci: re-trigger to clear flaky E2E shard 3

* fix(gitlab): address final review round — nil guards, store merge, logs, body validation

- CheckMRWatch, UpdateReviewWatch, UpdateIssueWatch: nil guards for store/req
- handlers: wsNewDiscussionNote rejects empty body
- action_presets: Update reads raw stored row (no default freezing);
  Reset validates workspace_id
- service_search.GetStats: log Warn per sub-call failure before zero
- event_handlers_gitlab: log Release* dedup-row errors with context
- web/store.ts: post-slice merge restores every GitLab sub-state field;
  extract buildStateOverrides helper
- web/default-state.ts: add GitLab fields to defaultState +
  mergeInitialState via mergeGitLabFields helper
- web/gitlab-api.ts: UpdateXxxWatchRequest now Omit workspace_id

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 22:10:07 +01:00

735 lines
25 KiB
Go

package gitlab
import (
"context"
"strings"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
)
const (
errMsgInvalidPayload = "invalid payload"
errMsgIDRequired = "id required"
respKeyDeleted = "deleted"
)
// RegisterRoutesWithDispatcher registers HTTP + WS routes for GitLab.
// Preserves the existing RegisterRoutes(router, svc, log) signature by
// adding a sibling function so callers can pass a dispatcher.
func RegisterRoutesWithDispatcher(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) {
NewController(svc, log).RegisterHTTPRoutes(router)
if dispatcher != nil {
registerWSHandlers(dispatcher, svc, log)
}
}
// RegisterMockRoutes registers mock control endpoints if the underlying
// client is a MockClient. No-op otherwise.
func RegisterMockRoutes(router *gin.Engine, svc *Service, log *logger.Logger) {
mock, ok := svc.Client().(*MockClient)
if !ok {
return
}
NewMockController(mock, svc, log).RegisterRoutes(router)
log.Info("registered GitLab mock control endpoints")
}
// registerWSHandlers wires the GitLab WS action surface onto the dispatcher.
func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) {
dispatcher.RegisterFunc(ws.ActionGitLabStatus, wsStatus(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabTaskMRsList, wsListTaskMRs(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabTaskMRGet, wsGetTaskMR(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRFeedbackGet, wsGetMRFeedback(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabReviewWatchesList, wsListReviewWatches(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabReviewWatchCreate, wsCreateReviewWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabReviewWatchUpdate, wsUpdateReviewWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabReviewWatchDelete, wsDeleteReviewWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabReviewTrigger, wsTriggerReviewWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabReviewTriggerAll, wsTriggerAllReviewChecks(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRWatchesList, wsListMRWatches(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRWatchDelete, wsDeleteMRWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRFilesGet, wsGetMRFiles(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRCommitsGet, wsGetMRCommits(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabTaskMRSync, wsSyncTaskMR(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabStats, wsGetStats(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRMerge, wsMergeMR(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRApprove, wsApproveMR(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRUnapprove, wsUnapproveMR(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRSetLabels, wsSetMRLabels(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRSetAssignees, wsSetMRAssignees(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRDiscussionNew, wsNewDiscussionNote(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabMRDiscussionResolve, wsResolveDiscussion(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabProjectMergeMethodsGet, wsGetProjectMergeMethods(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabIssueWatchesList, wsListIssueWatches(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabIssueWatchCreate, wsCreateIssueWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabIssueWatchUpdate, wsUpdateIssueWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabIssueWatchDelete, wsDeleteIssueWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabIssueTrigger, wsTriggerIssueWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabIssueTriggerAll, wsTriggerAllIssueChecks(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabActionPresetsList, wsListActionPresets(svc))
dispatcher.RegisterFunc(ws.ActionGitLabActionPresetsUpdate, wsUpdateActionPresets(svc))
dispatcher.RegisterFunc(ws.ActionGitLabActionPresetsReset, wsResetActionPresets(svc))
dispatcher.RegisterFunc(ws.ActionGitLabListUserProjects, wsListUserProjects(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabSearchProjects, wsSearchProjects(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabProjectBranches, wsListProjectBranches(svc, log))
dispatcher.RegisterFunc(ws.ActionGitLabCleanupReviewTasks, wsCleanupReviewTasks(svc))
dispatcher.RegisterFunc(ws.ActionGitLabCleanupIssueTasks, wsCleanupIssueTasks(svc))
}
// --- Generic helpers ---
func parseInto[T any](msg *ws.Message, out *T) error { return msg.ParsePayload(out) }
func badRequest(msg *ws.Message, reason string) (*ws.Message, error) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, reason, nil)
}
func internalError(msg *ws.Message, err error) (*ws.Message, error) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
}
func okResponse(msg *ws.Message, payload interface{}) (*ws.Message, error) {
return ws.NewResponse(msg.ID, msg.Action, payload)
}
// --- Status / list / get / sync ---
func wsStatus(svc *Service, log *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
status, err := svc.GetStatus(ctx)
if err != nil {
log.Warn("gitlab status: " + err.Error())
return internalError(msg, err)
}
return okResponse(msg, status)
}
}
func wsListTaskMRs(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
WorkspaceID string `json:"workspace_id"`
TaskID string `json:"task_id"`
}
// Malformed payloads must not silently fall through to the "list
// everything in workspace" branch — that exposes more data than the
// caller intended. Empty payload (both fields absent) is rejected
// further down by the workspace_id check.
if err := parseInto(msg, &req); err != nil {
return badRequest(msg, errMsgInvalidPayload)
}
if req.TaskID != "" {
mrs, err := svc.ListTaskMRsByTask(ctx, req.TaskID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"task_mrs": mrs})
}
if req.WorkspaceID == "" {
return badRequest(msg, "workspace_id or task_id required")
}
grouped, err := svc.ListTaskMRsByWorkspace(ctx, req.WorkspaceID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, TaskMRsResponse{TaskMRs: grouped})
}
}
func wsGetTaskMR(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
}
if err := parseInto(msg, &req); err != nil || req.TaskID == "" {
return badRequest(msg, "task_id required")
}
mrs, err := svc.ListTaskMRsByTask(ctx, req.TaskID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"task_mrs": mrs})
}
}
func wsGetMRFeedback(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
IID int `json:"iid"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 {
return badRequest(msg, "project and iid required")
}
feedback, err := svc.GetMRFeedback(ctx, req.Project, req.IID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, feedback)
}
}
func wsGetMRFiles(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
IID int `json:"iid"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 {
return badRequest(msg, "project and iid required")
}
files, err := svc.GetMRFiles(ctx, req.Project, req.IID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"files": files})
}
}
func wsGetMRCommits(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
IID int `json:"iid"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 {
return badRequest(msg, "project and iid required")
}
commits, err := svc.GetMRCommits(ctx, req.Project, req.IID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"commits": commits})
}
}
func wsSyncTaskMR(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
}
if err := parseInto(msg, &req); err != nil || req.TaskID == "" {
return badRequest(msg, "task_id required")
}
rows, err := svc.TriggerMRSync(ctx, req.TaskID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"task_mrs": rows})
}
}
func wsGetStats(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
stats, err := svc.GetStats(ctx)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, stats)
}
}
// --- Review watch handlers ---
func wsListReviewWatches(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
WorkspaceID string `json:"workspace_id"`
}
if err := parseInto(msg, &req); err != nil {
return badRequest(msg, errMsgInvalidPayload)
}
var watches []*ReviewWatch
var err error
if req.WorkspaceID == "" {
watches, err = svc.ListAllReviewWatches(ctx)
} else {
watches, err = svc.ListReviewWatches(ctx, req.WorkspaceID)
}
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"watches": watches})
}
}
func wsCreateReviewWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req CreateReviewWatchRequest
if err := parseInto(msg, &req); err != nil {
return badRequest(msg, errMsgInvalidPayload)
}
w, err := svc.CreateReviewWatch(ctx, &req)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, w)
}
}
func wsUpdateReviewWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
ID string `json:"id"`
UpdateReviewWatchRequest
}
if err := parseInto(msg, &req); err != nil || req.ID == "" {
return badRequest(msg, errMsgIDRequired)
}
if err := svc.UpdateReviewWatch(ctx, req.ID, &req.UpdateReviewWatchRequest); err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"updated": true})
}
}
func wsDeleteReviewWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
ID string `json:"id"`
}
if err := parseInto(msg, &req); err != nil || req.ID == "" {
return badRequest(msg, errMsgIDRequired)
}
if err := svc.DeleteReviewWatch(ctx, req.ID); err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{respKeyDeleted: true})
}
}
func wsTriggerReviewWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
ID string `json:"id"`
}
if err := parseInto(msg, &req); err != nil || req.ID == "" {
return badRequest(msg, errMsgIDRequired)
}
mrs, err := svc.TriggerReviewWatch(ctx, req.ID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"mrs": mrs})
}
}
func wsTriggerAllReviewChecks(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
n, err := svc.TriggerReviewWatchAll(ctx)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"count": n})
}
}
// --- MR watch handlers ---
func wsListMRWatches(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
}
if err := parseInto(msg, &req); err != nil {
return badRequest(msg, errMsgInvalidPayload)
}
switch {
case req.SessionID != "":
ws, err := svc.ListMRWatchesBySession(ctx, req.SessionID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"watches": ws})
case req.TaskID != "":
ws, err := svc.ListMRWatchesByTask(ctx, req.TaskID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"watches": ws})
default:
ws, err := svc.ListActiveMRWatches(ctx)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"watches": ws})
}
}
}
func wsDeleteMRWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
ID string `json:"id"`
}
if err := parseInto(msg, &req); err != nil || req.ID == "" {
return badRequest(msg, errMsgIDRequired)
}
if err := svc.DeleteMRWatch(ctx, req.ID); err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{respKeyDeleted: true})
}
}
// --- Issue watch handlers ---
func wsListIssueWatches(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
WorkspaceID string `json:"workspace_id"`
}
if err := parseInto(msg, &req); err != nil {
return badRequest(msg, errMsgInvalidPayload)
}
var watches []*IssueWatch
var err error
if req.WorkspaceID == "" {
watches, err = svc.ListAllIssueWatches(ctx)
} else {
watches, err = svc.ListIssueWatches(ctx, req.WorkspaceID)
}
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"watches": watches})
}
}
func wsCreateIssueWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req CreateIssueWatchRequest
if err := parseInto(msg, &req); err != nil {
return badRequest(msg, errMsgInvalidPayload)
}
w, err := svc.CreateIssueWatch(ctx, &req)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, w)
}
}
func wsUpdateIssueWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
ID string `json:"id"`
UpdateIssueWatchRequest
}
if err := parseInto(msg, &req); err != nil || req.ID == "" {
return badRequest(msg, errMsgIDRequired)
}
if err := svc.UpdateIssueWatch(ctx, req.ID, &req.UpdateIssueWatchRequest); err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"updated": true})
}
}
func wsDeleteIssueWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
ID string `json:"id"`
}
if err := parseInto(msg, &req); err != nil || req.ID == "" {
return badRequest(msg, errMsgIDRequired)
}
if err := svc.DeleteIssueWatch(ctx, req.ID); err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{respKeyDeleted: true})
}
}
func wsTriggerIssueWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
ID string `json:"id"`
}
if err := parseInto(msg, &req); err != nil || req.ID == "" {
return badRequest(msg, errMsgIDRequired)
}
issues, err := svc.TriggerIssueWatch(ctx, req.ID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"issues": issues})
}
}
func wsTriggerAllIssueChecks(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
n, err := svc.TriggerIssueWatchAll(ctx)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"count": n})
}
}
// --- Write actions ---
func wsMergeMR(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
IID int `json:"iid"`
Method string `json:"method"`
SquashCommitMessage string `json:"squash_commit_message"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 {
return badRequest(msg, "project and iid required")
}
mr, err := svc.MergeMR(ctx, req.Project, req.IID, req.Method, req.SquashCommitMessage)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, mr)
}
}
func wsApproveMR(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
IID int `json:"iid"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 {
return badRequest(msg, "project and iid required")
}
if err := svc.SubmitMRApproval(ctx, req.Project, req.IID); err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"approved": true})
}
}
func wsUnapproveMR(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
IID int `json:"iid"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 {
return badRequest(msg, "project and iid required")
}
if err := svc.SubmitMRUnapproval(ctx, req.Project, req.IID); err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"unapproved": true})
}
}
func wsSetMRLabels(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
IID int `json:"iid"`
Labels []string `json:"labels"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 {
return badRequest(msg, "project and iid required")
}
if err := svc.SetMRLabels(ctx, req.Project, req.IID, req.Labels); err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"updated": true})
}
}
func wsSetMRAssignees(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
IID int `json:"iid"`
AssigneeIDs []int `json:"assignee_ids"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 {
return badRequest(msg, "project and iid required")
}
if err := svc.SetMRAssignees(ctx, req.Project, req.IID, req.AssigneeIDs); err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"updated": true})
}
}
func wsNewDiscussionNote(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
IID int `json:"iid"`
DiscussionID string `json:"discussion_id"`
Body string `json:"body"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 || req.DiscussionID == "" {
return badRequest(msg, "project, iid, discussion_id required")
}
if strings.TrimSpace(req.Body) == "" {
return badRequest(msg, "body required")
}
note, err := svc.CreateMRDiscussionNote(ctx, req.Project, req.IID, req.DiscussionID, req.Body)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, note)
}
}
func wsResolveDiscussion(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
IID int `json:"iid"`
DiscussionID string `json:"discussion_id"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 || req.DiscussionID == "" {
return badRequest(msg, "project, iid, discussion_id required")
}
if err := svc.ResolveMRDiscussion(ctx, req.Project, req.IID, req.DiscussionID); err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"resolved": true})
}
}
func wsGetProjectMergeMethods(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" {
return badRequest(msg, "project required")
}
methods, err := svc.GetProjectMergeMethods(ctx, req.Project)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, methods)
}
}
// --- Projects ---
func wsListUserProjects(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
projects, err := svc.ListUserProjects(ctx)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"projects": projects})
}
}
func wsSearchProjects(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := parseInto(msg, &req); err != nil {
return badRequest(msg, errMsgInvalidPayload)
}
projects, err := svc.SearchProjects(ctx, req.Query, req.Limit)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"projects": projects})
}
}
func wsListProjectBranches(svc *Service, _ *logger.Logger) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
Project string `json:"project"`
}
if err := parseInto(msg, &req); err != nil || req.Project == "" {
return badRequest(msg, "project required")
}
branches, err := svc.ListProjectBranches(ctx, req.Project)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]interface{}{"branches": branches})
}
}
// --- Action presets ---
func wsListActionPresets(svc *Service) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
WorkspaceID string `json:"workspace_id"`
}
if err := parseInto(msg, &req); err != nil || req.WorkspaceID == "" {
return badRequest(msg, "workspace_id required")
}
presets, err := svc.GetActionPresetsOrDefault(ctx, req.WorkspaceID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, presets)
}
}
func wsUpdateActionPresets(svc *Service) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req UpdateActionPresetsRequest
if err := parseInto(msg, &req); err != nil || req.WorkspaceID == "" {
return badRequest(msg, "workspace_id required")
}
presets, err := svc.UpdateActionPresets(ctx, &req)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, presets)
}
}
func wsResetActionPresets(svc *Service) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
WorkspaceID string `json:"workspace_id"`
}
if err := parseInto(msg, &req); err != nil || req.WorkspaceID == "" {
return badRequest(msg, "workspace_id required")
}
presets, err := svc.ResetActionPresets(ctx, req.WorkspaceID)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, presets)
}
}
// --- Cleanup ---
func wsCleanupReviewTasks(svc *Service) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
n, err := svc.CleanupAllReviewTasks(ctx)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]int{respKeyDeleted: n})
}
}
func wsCleanupIssueTasks(svc *Service) ws.HandlerFunc {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
n, err := svc.CleanupAllIssueTasks(ctx)
if err != nil {
return internalError(msg, err)
}
return okResponse(msg, map[string]int{respKeyDeleted: n})
}
}