Skip to content

Commit dfaab11

Browse files
jcfsKandev Agentclaude
authored
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>
1 parent 6b98185 commit dfaab11

45 files changed

Lines changed: 6417 additions & 135 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/backend/cmd/kandev/helpers.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -747,8 +747,9 @@ func registerSecondaryRoutes(
747747
}
748748

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

754755
if p.services.Jira != nil {

apps/backend/cmd/kandev/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030

3131
// GitHub integration
3232
githubpkg "github.com/kandev/kandev/internal/github"
33+
gitlabpkg "github.com/kandev/kandev/internal/gitlab"
3334

3435
// JIRA integration
3536
jirapkg "github.com/kandev/kandev/internal/jira"
@@ -437,6 +438,18 @@ func startAgentInfrastructure(
437438
log.Info("GitHub poller started")
438439
}
439440

441+
// Start GitLab background poller + wire the service into the
442+
// orchestrator so review/issue watch events get turned into tasks.
443+
if services.GitLab != nil {
444+
orchestratorSvc.SetGitLabService(services.GitLab)
445+
services.GitLab.SetTaskDeleter(&taskDeleterAdapter{svc: services.Task})
446+
services.GitLab.SetTaskSessionChecker(&taskSessionCheckerAdapter{repo: repos.Task})
447+
glPoller := gitlabpkg.NewPoller(services.GitLab, eventBus, log)
448+
glPoller.Start(ctx)
449+
addCleanup(func() error { glPoller.Stop(); return nil })
450+
log.Info("GitLab poller started")
451+
}
452+
440453
// Start JIRA poller. Drives two background loops sharing one service: an
441454
// auth-health probe (so the UI can show connect status without polling
442455
// JIRA itself) and an issue-watch loop that runs configured JQL queries

apps/backend/cmd/kandev/services.go

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
9292
)
9393

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

308308
// initGitLabService wires up the GitLab integration. Failures are non-fatal:
309309
// the rest of the backend still boots without GitLab configured.
310-
func initGitLabService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *gitlab.Service {
310+
func initGitLabService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *gitlab.Service {
311311
adapter := &gitlabSecretAdapter{store: secretsStore}
312-
// Host persistence (per-workspace gitlab_host) is deferred to a
313-
// follow-up; v1 reads from DefaultHost on every boot.
314312
svc, _, err := gitlab.Provide(context.Background(), adapter, nil, log)
315313
if err != nil {
316314
log.Warn("GitLab service initialization failed (non-fatal)", zap.Error(err))
317315
}
318316
if svc != nil {
319317
svc.SetSecretManager(adapter)
320-
// Task↔MR association store backs the topbar review surface.
321-
// Non-fatal: if the table fails to create the rest of the
322-
// integration (status, configure, MR feedback) still works.
318+
svc.SetEventBus(eventBus)
323319
if store, storeErr := gitlab.NewStore(dbPool.Writer(), dbPool.Reader()); storeErr == nil {
324320
svc.SetStore(store)
325321
} else {

apps/backend/internal/events/types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,16 @@ const (
228228
GitHubRateLimitUpdated = "github.rate_limit.updated" // GitHub API rate-limit snapshot changed
229229
)
230230

231+
// Event types for GitLab integration
232+
const (
233+
GitLabMRFeedback = "gitlab.mr_feedback" // MR has new feedback (UI notification only)
234+
GitLabMRStateChanged = "gitlab.mr_state_changed" // MR state changed (merged, closed, etc.)
235+
GitLabNewReviewMR = "gitlab.new_mr_to_review" // New MR found needing review
236+
GitLabNewIssue = "gitlab.new_issue" // New issue found matching issue watch
237+
GitLabTaskMRUpdated = "gitlab.task_mr.updated" // TaskMR record updated (for UI refresh)
238+
GitLabWatchEvent = "gitlab.watch.event" // Watch created/deleted
239+
)
240+
231241
// Event types for Jira integration
232242
const (
233243
JiraNewIssue = "jira.new_issue" // New issue found matching a Jira issue watch
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package gitlab
2+
3+
import (
4+
"context"
5+
"fmt"
6+
)
7+
8+
// GetActionPresetsOrDefault returns the workspace's stored presets, falling
9+
// back to the built-in defaults when none are stored.
10+
func (s *Service) GetActionPresetsOrDefault(ctx context.Context, workspaceID string) (*ActionPresets, error) {
11+
store := s.requireStore()
12+
if store == nil {
13+
return defaultPresets(workspaceID), nil
14+
}
15+
presets, err := store.GetActionPresets(ctx, workspaceID)
16+
if err != nil {
17+
return nil, fmt.Errorf("get action presets: %w", err)
18+
}
19+
if len(presets.MR) == 0 {
20+
presets.MR = DefaultMRActionPresets()
21+
}
22+
if len(presets.Issue) == 0 {
23+
presets.Issue = DefaultIssueActionPresets()
24+
}
25+
return presets, nil
26+
}
27+
28+
// UpdateActionPresets persists a partial update to a workspace's presets.
29+
// Nil fields are left unchanged. Untouched kinds are NOT filled with current
30+
// defaults before persistence — that would freeze stale defaults into the
31+
// workspace row, masking future default changes. The reader
32+
// (GetActionPresetsOrDefault) substitutes defaults on read instead.
33+
func (s *Service) UpdateActionPresets(ctx context.Context, req *UpdateActionPresetsRequest) (*ActionPresets, error) {
34+
if req == nil || req.WorkspaceID == "" {
35+
return nil, fmt.Errorf("workspace_id required")
36+
}
37+
store := s.requireStore()
38+
if store == nil {
39+
return nil, fmt.Errorf("gitlab store not configured")
40+
}
41+
current, err := store.GetActionPresets(ctx, req.WorkspaceID)
42+
if err != nil {
43+
return nil, fmt.Errorf("get action presets: %w", err)
44+
}
45+
if current == nil {
46+
current = &ActionPresets{WorkspaceID: req.WorkspaceID}
47+
}
48+
if req.MR != nil {
49+
current.MR = *req.MR
50+
}
51+
if req.Issue != nil {
52+
current.Issue = *req.Issue
53+
}
54+
if err := store.UpsertActionPresets(ctx, current); err != nil {
55+
return nil, fmt.Errorf("upsert action presets: %w", err)
56+
}
57+
// Return the rendered view (defaults substituted) so the caller sees the
58+
// same shape the read endpoint produces.
59+
return s.GetActionPresetsOrDefault(ctx, req.WorkspaceID)
60+
}
61+
62+
// ResetActionPresets removes a workspace's stored presets, falling back to defaults.
63+
func (s *Service) ResetActionPresets(ctx context.Context, workspaceID string) (*ActionPresets, error) {
64+
if workspaceID == "" {
65+
return nil, fmt.Errorf("workspace_id required")
66+
}
67+
store := s.requireStore()
68+
if store == nil {
69+
return defaultPresets(workspaceID), nil
70+
}
71+
if err := store.DeleteActionPresets(ctx, workspaceID); err != nil {
72+
return nil, fmt.Errorf("reset action presets: %w", err)
73+
}
74+
return defaultPresets(workspaceID), nil
75+
}
76+
77+
func defaultPresets(workspaceID string) *ActionPresets {
78+
return &ActionPresets{
79+
WorkspaceID: workspaceID,
80+
MR: DefaultMRActionPresets(),
81+
Issue: DefaultIssueActionPresets(),
82+
}
83+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package gitlab
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
func TestService_GetActionPresetsOrDefault_FallsBackToDefaults(t *testing.T) {
9+
svc := newServiceWithStore(t)
10+
got, err := svc.GetActionPresetsOrDefault(context.Background(), "ws-new")
11+
if err != nil {
12+
t.Fatalf("GetActionPresetsOrDefault: %v", err)
13+
}
14+
if got == nil {
15+
t.Fatalf("expected non-nil presets")
16+
}
17+
if len(got.MR) == 0 || len(got.Issue) == 0 {
18+
t.Fatalf("expected defaults to be injected when none stored, got %+v", got)
19+
}
20+
if got.MR[0].ID == "" {
21+
t.Fatalf("default presets missing ID: %+v", got.MR[0])
22+
}
23+
}
24+
25+
func TestService_UpdateActionPresets_PartialMerge(t *testing.T) {
26+
svc := newServiceWithStore(t)
27+
ctx := context.Background()
28+
// First write only MR presets.
29+
mrPresets := []ActionPreset{{ID: "x", Label: "Custom", PromptTemplate: "do {{url}}"}}
30+
if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{
31+
WorkspaceID: "ws-1",
32+
MR: &mrPresets,
33+
}); err != nil {
34+
t.Fatalf("UpdateActionPresets: %v", err)
35+
}
36+
// Read back: MR should be custom, Issue should still be defaults (from
37+
// GetActionPresetsOrDefault).
38+
got, err := svc.GetActionPresetsOrDefault(ctx, "ws-1")
39+
if err != nil {
40+
t.Fatalf("get: %v", err)
41+
}
42+
if len(got.MR) != 1 || got.MR[0].ID != "x" {
43+
t.Fatalf("MR presets not persisted: %+v", got.MR)
44+
}
45+
if len(got.Issue) == 0 {
46+
t.Fatalf("Issue presets should fallback to defaults when empty")
47+
}
48+
}
49+
50+
func TestService_ResetActionPresets(t *testing.T) {
51+
svc := newServiceWithStore(t)
52+
ctx := context.Background()
53+
custom := []ActionPreset{{ID: "x", Label: "x"}}
54+
if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{
55+
WorkspaceID: "ws-1",
56+
MR: &custom,
57+
}); err != nil {
58+
t.Fatalf("UpdateActionPresets: %v", err)
59+
}
60+
got, err := svc.ResetActionPresets(ctx, "ws-1")
61+
if err != nil {
62+
t.Fatalf("ResetActionPresets: %v", err)
63+
}
64+
if len(got.MR) == 0 || got.MR[0].ID == "x" {
65+
t.Fatalf("reset should restore defaults, got %+v", got.MR)
66+
}
67+
}
68+
69+
func newServiceWithStore(t *testing.T) *Service {
70+
t.Helper()
71+
store := newTestStore(t)
72+
log := newTestLogger(t)
73+
svc := NewService("https://gitlab.com", NewNoopClient("https://gitlab.com"), AuthMethodNone, nil, log)
74+
svc.SetStore(store)
75+
return svc
76+
}

apps/backend/internal/gitlab/client.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,28 @@ type Client interface {
108108

109109
// GetIssueState returns the state of a single issue ("opened" or "closed").
110110
GetIssueState(ctx context.Context, projectPath string, iid int) (string, error)
111+
112+
// MergeMR accepts an MR. squash=true performs a squash merge regardless of
113+
// project merge method. squashCommitMessage is used when squash=true.
114+
MergeMR(ctx context.Context, projectPath string, iid int, squash bool, squashCommitMessage string) (*MR, error)
115+
116+
// GetProjectMergeMethods reads the project's merge_method + squash_option
117+
// settings.
118+
GetProjectMergeMethods(ctx context.Context, projectPath string) (*ProjectMergeMethods, error)
119+
120+
// GetProtectedBranch returns the protected-branch settings for a branch.
121+
// Returns (nil, nil) when the branch isn't protected.
122+
GetProtectedBranch(ctx context.Context, projectPath, branch string) (*ProtectedBranch, error)
123+
124+
// ListUserProjects lists projects the authenticated user is a member of.
125+
ListUserProjects(ctx context.Context) ([]Project, error)
126+
127+
// SearchProjects searches all projects matching `query`.
128+
SearchProjects(ctx context.Context, query string, limit int) ([]Project, error)
129+
130+
// SetMRLabels replaces an MR's labels.
131+
SetMRLabels(ctx context.Context, projectPath string, iid int, labels []string) error
132+
133+
// SetMRAssignees replaces an MR's assignees (by user ID).
134+
SetMRAssignees(ctx context.Context, projectPath string, iid int, assigneeIDs []int) error
111135
}

apps/backend/internal/gitlab/controller.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
4141

4242
api.GET("/user/mrs", c.httpSearchUserMRs)
4343
api.GET("/user/issues", c.httpSearchUserIssues)
44+
45+
c.RegisterWatchHTTPRoutes(router)
4446
}
4547

4648
// RegisterRoutes is the package-level entrypoint mirroring github.RegisterRoutes.

0 commit comments

Comments
 (0)