Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/backend/cmd/kandev/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -736,8 +736,9 @@ func registerSecondaryRoutes(
}

if p.services.GitLab != nil {
gitlab.RegisterRoutes(p.router, p.services.GitLab, p.log)
p.log.Debug("Registered GitLab handlers (HTTP)")
gitlab.RegisterRoutesWithDispatcher(p.router, p.gateway.Dispatcher, p.services.GitLab, p.log)
gitlab.RegisterMockRoutes(p.router, p.services.GitLab, p.log)
p.log.Debug("Registered GitLab handlers (HTTP + WebSocket)")
}

if p.services.Jira != nil {
Expand Down
13 changes: 13 additions & 0 deletions apps/backend/cmd/kandev/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (

// GitHub integration
githubpkg "github.com/kandev/kandev/internal/github"
gitlabpkg "github.com/kandev/kandev/internal/gitlab"

// JIRA integration
jirapkg "github.com/kandev/kandev/internal/jira"
Expand Down Expand Up @@ -435,6 +436,18 @@ func startAgentInfrastructure(
log.Info("GitHub poller started")
}

// Start GitLab background poller + wire the service into the
// orchestrator so review/issue watch events get turned into tasks.
if services.GitLab != nil {
orchestratorSvc.SetGitLabService(services.GitLab)
services.GitLab.SetTaskDeleter(&taskDeleterAdapter{svc: services.Task})
services.GitLab.SetTaskSessionChecker(&taskSessionCheckerAdapter{repo: repos.Task})
glPoller := gitlabpkg.NewPoller(services.GitLab, eventBus, log)
Comment thread
jcfs marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
glPoller.Start(ctx)
addCleanup(func() error { glPoller.Stop(); return nil })
log.Info("GitLab poller started")
}
Comment thread
jcfs marked this conversation as resolved.
Comment thread
jcfs marked this conversation as resolved.

// Start JIRA poller. Drives two background loops sharing one service: an
// auth-health probe (so the UI can show connect status without polling
// JIRA itself) and an issue-watch loop that runs configured JQL queries
Expand Down
10 changes: 3 additions & 7 deletions apps/backend/cmd/kandev/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
)

githubSvc := initGitHubService(dbPool, eventBus, repos.Secrets, log)
gitlabSvc := initGitLabService(dbPool, repos.Secrets, log)
gitlabSvc := initGitLabService(dbPool, eventBus, repos.Secrets, log)
jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log)
linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log)
slackSvc := initSlackService(dbPool, repos.Secrets, log)
Expand Down Expand Up @@ -307,19 +307,15 @@ func (a *gitlabSecretAdapter) Delete(ctx context.Context, id string) error {

// initGitLabService wires up the GitLab integration. Failures are non-fatal:
// the rest of the backend still boots without GitLab configured.
func initGitLabService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *gitlab.Service {
func initGitLabService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *gitlab.Service {
adapter := &gitlabSecretAdapter{store: secretsStore}
// Host persistence (per-workspace gitlab_host) is deferred to a
// follow-up; v1 reads from DefaultHost on every boot.
svc, _, err := gitlab.Provide(context.Background(), adapter, nil, log)
if err != nil {
log.Warn("GitLab service initialization failed (non-fatal)", zap.Error(err))
}
if svc != nil {
svc.SetSecretManager(adapter)
// Task↔MR association store backs the topbar review surface.
// Non-fatal: if the table fails to create the rest of the
// integration (status, configure, MR feedback) still works.
svc.SetEventBus(eventBus)
if store, storeErr := gitlab.NewStore(dbPool.Writer(), dbPool.Reader()); storeErr == nil {
svc.SetStore(store)
} else {
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/internal/events/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,16 @@ const (
GitHubRateLimitUpdated = "github.rate_limit.updated" // GitHub API rate-limit snapshot changed
)

// Event types for GitLab integration
const (
GitLabMRFeedback = "gitlab.mr_feedback" // MR has new feedback (UI notification only)
GitLabMRStateChanged = "gitlab.mr_state_changed" // MR state changed (merged, closed, etc.)
GitLabNewReviewMR = "gitlab.new_mr_to_review" // New MR found needing review
GitLabNewIssue = "gitlab.new_issue" // New issue found matching issue watch
GitLabTaskMRUpdated = "gitlab.task_mr.updated" // TaskMR record updated (for UI refresh)
GitLabWatchEvent = "gitlab.watch.event" // Watch created/deleted
)

// Event types for Jira integration
const (
JiraNewIssue = "jira.new_issue" // New issue found matching a Jira issue watch
Expand Down
83 changes: 83 additions & 0 deletions apps/backend/internal/gitlab/action_presets_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package gitlab

import (
"context"
"fmt"
)

// GetActionPresetsOrDefault returns the workspace's stored presets, falling
// back to the built-in defaults when none are stored.
func (s *Service) GetActionPresetsOrDefault(ctx context.Context, workspaceID string) (*ActionPresets, error) {
store := s.requireStore()
Comment thread
jcfs marked this conversation as resolved.
Comment thread
jcfs marked this conversation as resolved.
if store == nil {
return defaultPresets(workspaceID), nil
}
presets, err := store.GetActionPresets(ctx, workspaceID)
if err != nil {
return nil, fmt.Errorf("get action presets: %w", err)
}
if len(presets.MR) == 0 {
presets.MR = DefaultMRActionPresets()
}
if len(presets.Issue) == 0 {
presets.Issue = DefaultIssueActionPresets()
}
return presets, nil
}

// UpdateActionPresets persists a partial update to a workspace's presets.
// Nil fields are left unchanged. Untouched kinds are NOT filled with current
// defaults before persistence — that would freeze stale defaults into the
// workspace row, masking future default changes. The reader
// (GetActionPresetsOrDefault) substitutes defaults on read instead.
func (s *Service) UpdateActionPresets(ctx context.Context, req *UpdateActionPresetsRequest) (*ActionPresets, error) {
if req == nil || req.WorkspaceID == "" {
return nil, fmt.Errorf("workspace_id required")
}
store := s.requireStore()
if store == nil {
return nil, fmt.Errorf("gitlab store not configured")
}
current, err := store.GetActionPresets(ctx, req.WorkspaceID)
if err != nil {
return nil, fmt.Errorf("get action presets: %w", err)
}
if current == nil {
current = &ActionPresets{WorkspaceID: req.WorkspaceID}
}
if req.MR != nil {
current.MR = *req.MR
}
if req.Issue != nil {
current.Issue = *req.Issue
}
if err := store.UpsertActionPresets(ctx, current); err != nil {
return nil, fmt.Errorf("upsert action presets: %w", err)
}
// Return the rendered view (defaults substituted) so the caller sees the
// same shape the read endpoint produces.
return s.GetActionPresetsOrDefault(ctx, req.WorkspaceID)
}

// ResetActionPresets removes a workspace's stored presets, falling back to defaults.
func (s *Service) ResetActionPresets(ctx context.Context, workspaceID string) (*ActionPresets, error) {
if workspaceID == "" {
return nil, fmt.Errorf("workspace_id required")
}
store := s.requireStore()
if store == nil {
return defaultPresets(workspaceID), nil
}
if err := store.DeleteActionPresets(ctx, workspaceID); err != nil {
return nil, fmt.Errorf("reset action presets: %w", err)
}
return defaultPresets(workspaceID), nil
}

func defaultPresets(workspaceID string) *ActionPresets {
return &ActionPresets{
WorkspaceID: workspaceID,
MR: DefaultMRActionPresets(),
Issue: DefaultIssueActionPresets(),
}
}
76 changes: 76 additions & 0 deletions apps/backend/internal/gitlab/action_presets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package gitlab

import (
"context"
"testing"
)

func TestService_GetActionPresetsOrDefault_FallsBackToDefaults(t *testing.T) {
svc := newServiceWithStore(t)
got, err := svc.GetActionPresetsOrDefault(context.Background(), "ws-new")
if err != nil {
t.Fatalf("GetActionPresetsOrDefault: %v", err)
}
if got == nil {
t.Fatalf("expected non-nil presets")
}
if len(got.MR) == 0 || len(got.Issue) == 0 {
t.Fatalf("expected defaults to be injected when none stored, got %+v", got)
}
if got.MR[0].ID == "" {
t.Fatalf("default presets missing ID: %+v", got.MR[0])
}
}

func TestService_UpdateActionPresets_PartialMerge(t *testing.T) {
svc := newServiceWithStore(t)
ctx := context.Background()
// First write only MR presets.
mrPresets := []ActionPreset{{ID: "x", Label: "Custom", PromptTemplate: "do {{url}}"}}
if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{
WorkspaceID: "ws-1",
MR: &mrPresets,
}); err != nil {
t.Fatalf("UpdateActionPresets: %v", err)
}
// Read back: MR should be custom, Issue should still be defaults (from
// GetActionPresetsOrDefault).
got, err := svc.GetActionPresetsOrDefault(ctx, "ws-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if len(got.MR) != 1 || got.MR[0].ID != "x" {
t.Fatalf("MR presets not persisted: %+v", got.MR)
}
if len(got.Issue) == 0 {
t.Fatalf("Issue presets should fallback to defaults when empty")
}
}

func TestService_ResetActionPresets(t *testing.T) {
svc := newServiceWithStore(t)
ctx := context.Background()
custom := []ActionPreset{{ID: "x", Label: "x"}}
if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{
WorkspaceID: "ws-1",
MR: &custom,
}); err != nil {
t.Fatalf("UpdateActionPresets: %v", err)
}
got, err := svc.ResetActionPresets(ctx, "ws-1")
if err != nil {
t.Fatalf("ResetActionPresets: %v", err)
}
if len(got.MR) == 0 || got.MR[0].ID == "x" {
t.Fatalf("reset should restore defaults, got %+v", got.MR)
}
}

func newServiceWithStore(t *testing.T) *Service {
t.Helper()
store := newTestStore(t)
log := newTestLogger(t)
svc := NewService("https://gitlab.com", NewNoopClient("https://gitlab.com"), AuthMethodNone, nil, log)
svc.SetStore(store)
return svc
}
24 changes: 24 additions & 0 deletions apps/backend/internal/gitlab/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,28 @@ type Client interface {

// GetIssueState returns the state of a single issue ("opened" or "closed").
GetIssueState(ctx context.Context, projectPath string, iid int) (string, error)

// MergeMR accepts an MR. squash=true performs a squash merge regardless of
// project merge method. squashCommitMessage is used when squash=true.
MergeMR(ctx context.Context, projectPath string, iid int, squash bool, squashCommitMessage string) (*MR, error)

// GetProjectMergeMethods reads the project's merge_method + squash_option
// settings.
GetProjectMergeMethods(ctx context.Context, projectPath string) (*ProjectMergeMethods, error)

// GetProtectedBranch returns the protected-branch settings for a branch.
// Returns (nil, nil) when the branch isn't protected.
GetProtectedBranch(ctx context.Context, projectPath, branch string) (*ProtectedBranch, error)

// ListUserProjects lists projects the authenticated user is a member of.
ListUserProjects(ctx context.Context) ([]Project, error)

// SearchProjects searches all projects matching `query`.
SearchProjects(ctx context.Context, query string, limit int) ([]Project, error)

// SetMRLabels replaces an MR's labels.
SetMRLabels(ctx context.Context, projectPath string, iid int, labels []string) error

// SetMRAssignees replaces an MR's assignees (by user ID).
SetMRAssignees(ctx context.Context, projectPath string, iid int, assigneeIDs []int) error
}
2 changes: 2 additions & 0 deletions apps/backend/internal/gitlab/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {

api.GET("/user/mrs", c.httpSearchUserMRs)
api.GET("/user/issues", c.httpSearchUserIssues)

c.RegisterWatchHTTPRoutes(router)
}

// RegisterRoutes is the package-level entrypoint mirroring github.RegisterRoutes.
Expand Down
Loading
Loading