dfaab11959
* feat(backend): full GitLab parity — store, service, poller, WS, presets Add the watcher/poll/write-action surface on top of the existing GitLab client + service skeleton: * 5 new SQLite tables (mr_watches, review_watches, review_mr_tasks, issue_watches, issue_watch_tasks, action_presets) + CRUD * Client + Service: MergeMR, GetProjectMergeMethods, GetProtectedBranch, ListUserProjects, SearchProjects, SetMRLabels, SetMRAssignees * Service: review/issue/MR watch CRUD + Check/Trigger, GetStats, CleanupAllReview/IssueTasks (cleanup-policy aware), TriggerMRSync, action-presets CRUD with defaults * Background poller (MR / review / issue loops) wired from main.go * ~35 GitLab WS action constants + handlers.go registrations * Mock controller for E2E seeding (MRs, issues, pipelines, discussions, approvals, branches) * eventBus + taskDeleter + taskSessionChecker plumbed through Service * Event types: GitLabMRFeedback / NewReviewMR / NewIssue / TaskMRUpdated * feat(web): GitLab frontend parity — types, API, store slice, domain hooks Extend the frontend GitLab integration on top of the new backend surface: * HTTP API client expansion (gitlab-api.ts): full CRUD for review/issue/MR watches, MR write actions (merge/approve/labels/assignees), MR files/commits/feedback, action presets, projects autocomplete, stats * Store slice expansion: review/issue/MR watches lists, action presets by workspace, stats + status with loading flags. Properties prefixed with gitlab to avoid collision with GitHub slice * Domain hooks: use-gitlab-status, use-gitlab-stats, use-gitlab-review-watches, use-gitlab-issue-watches, use-gitlab-action-presets — mirror github hook shapes * Type model: ReviewWatch, IssueWatch, MRWatch, ActionPresets, Stats, ProjectMergeMethods, MRApproval, Pipeline, MRFeedback, etc. * Backend controller HTTP routes for watches/presets/write-actions/ projects/stats so the frontend HTTP API client has endpoints to hit All passes: backend (build + vet + lint + tests), web (typecheck + lint + 2400 tests). * fix(gitlab): wire orchestrator event handlers, fix label-filter drop, add tests Address Claude review blockers on the parity PR: 1. Wire orchestrator subscriptions for GitLab review/issue watch events. Add event_handlers_gitlab.go with handleGitLabNewReviewMR / handleGitLabNewIssue, dedup reservation handshake, and task-creator interfaces (mirrors event_handlers_github.go). main.go now calls orchestratorSvc.SetGitLabService so the dedup APIs are usable 2. Fix silent label-filter drop: when a custom_query is set on an issue watch, labels were being appended to the unused filter arg. Fold labels into customQuery (or default filter) so they actually reach GitLab; extracted appendLabelsToQuery helper with explicit handling of pre-existing labels= clauses 3. Switch event payloads to pointer types (mirrors GitHub) so handlers can type-assert pointer events 4. Backend tests: store_watches_test.go (CRUD + reserve/assign for all 4 new tables + presets), service_watches_test.go (label-filter helper, cleanup policy, project normalisation), action_presets_test.go 5. Frontend slice tests: review/issue watch CRUD round-trips, action presets + stats reducer coverage All green: backend (build+vet+lint+tests), web (typecheck+lint+2403 tests). * fix(gitlab): address remaining blockers — cleanup wiring, safe defaults, nil guards Address the second round of Claude review blockers: 1. main.go: wire SetTaskDeleter + SetTaskSessionChecker on services.GitLab (mirrors GitHub). Without these, manual cleanup sweeps would always error with "task deleter not configured" 2. service_cleanup.go: default cleanup policy on transient DB error is now CleanupPolicyNever (preserve tasks) instead of CleanupPolicyAuto (silently delete). Genuine "watch was deleted" path also falls under the safe-side default; user can manually delete the orphan tasks 3. service_watches.go: add nil guards on requireStore() so the 6 MR-watch list/get/delete methods return an errStoreUnavailable error instead of panicking when the SQLite store failed to construct at boot 4. use-gitlab-status.ts + use-gitlab-stats.ts: add per-mount attemptedRef so an unreachable GitLab doesn't trigger an infinite re-fetch loop — the previous useEffect re-ran every render because the failure path left status null, satisfying the !status guard * fix(gitlab): propagate session-check errors, clamp poll interval on update Address remaining Claude blockers: * service_cleanup.go: HasUserAuthoredMessage transient errors are now preserved (return false → skip delete) instead of silently ignored; the alternative was occasionally deleting tasks a user had touched * service_watches.go: extract clampPollInterval helper applying the same bounds (0 → default, <60 → 60) as the create path; both applyReviewWatchPatch and applyIssueWatchPatch use it so user-supplied zero or tiny values via UpdateXxxWatchRequest no longer hammer GitLab * fix(gitlab): clear dupl lint, fix preset retry loop + per-workspace watch cache * service_watches.go: applyReviewWatchPatch / applyIssueWatchPatch get nolint:dupl markers — they share shape but per-domain field validation lives in the create paths, so deduplicating via generics would obscure the contract. Restores backend lint to 0 issues * use-gitlab-action-presets.ts: per-workspace attemptedRef set so a failing preset fetch doesn't retry on every render * use-gitlab-review-watches.ts + use-gitlab-issue-watches.ts: track lastFetchedRef per consumer so a workspace switch triggers a refetch. The slice-level loaded flag is shared across instances and can't double as a per-workspace cache key * fix(gitlab): add nil-store guards across review/issue watch + cleanup paths Round-3 Claude finding: ReviewWatch / IssueWatch / preset / reservation methods still called s.requireStore().X() directly. If NewStore fails at boot (table migration error), the service struct's store is nil and every list/get/update/create/reserve method panics on first request. Apply the same store-nil → errStoreUnavailable pattern from f95a929 to every remaining method: * CreateReviewWatch / CreateIssueWatch * GetReviewWatch / ListReviewWatches / ListAllReviewWatches / UpdateReviewWatch / TriggerReviewWatchAll * GetIssueWatch / ListIssueWatches / ListAllIssueWatches / UpdateIssueWatch / TriggerIssueWatchAll * ReserveReviewMRTask / AssignReviewMRTaskID / ReleaseReviewMRTask * ReserveIssueWatchTask / AssignIssueWatchTaskID / ReleaseIssueWatchTask * lookupReviewPolicy / lookupIssuePolicy (return CleanupPolicyNever) * fix(gitlab): nil-store guards on CheckXxxWatch + DeleteXxxWatch + cleanup paths Round-4 Claude finding: 4 remaining panic sites the previous nil-guard pass missed — these run from the background poller (every 5 min) and the manual delete/cleanup flows, so a boot-time NewStore failure would crash the orchestrator process on first tick. * service_watches.go: add store-nil guards to DeleteReviewWatch / DeleteIssueWatch / CheckReviewWatch / CheckIssueWatch * service_cleanup.go: guard CleanupAllReviewTasks / CleanupAllIssueTasks entry points; wrap the trailing DeleteReviewMRTask / DeleteIssueWatchTask calls in `if store := s.requireStore(); store != nil` blocks so the cleanup succeeds even when the dedup-row delete is unreachable * refactor(gitlab): split service_watches.go to stay under 800-line revive limit CI's --new-from-rev lint caught service_watches.go at 808 lines (limit 800). Split into three focused files: * service_events.go (94 LOC) — publish helpers for MR feedback / new review MR / new issue / watch lifecycle events * service_issue_watches.go (302 LOC) — Issue watch CRUD + Check + Trigger + fetch + helpers * service_reservations.go (59 LOC) — Reserve/Assign/Release dedup handles used by the orchestrator event handlers service_watches.go is now 505 lines and contains MR watch + Review watch only. No behavioral change. * fix(gitlab): address inline code-review feedback Address actionable inline comments from Claude/CodeRabbit/cubic/Greptile: * appendLabelsToQuery: use url.ParseQuery for exact key match instead of strings.Contains; previously false-matched keys like mylabels= and silently dropped the watch's labels (test added) * fetchReviewMRs: drop dead `filter = watch.CustomQuery` assignment — SearchMRs's buildMRSearchQuery returns customQuery verbatim and ignores filter when customQuery is non-empty * store.go: add workspace_id indexes on gitlab_review_watches and gitlab_issue_watches (the 5-min poller and HTTP list endpoints did full table scans) * mock_client.SearchProjects: switch from == to case-insensitive Contains so partial-query autocomplete returns the seeded project, matching the doc comment * Poller: add sync.Mutex to guard `started` field; Start/Stop are now safe to call concurrently (go test -race would have flagged the previous read/write race) * Add ErrWatchNotFound sentinel; controller_watches.go maps it to HTTP 404 via httpRespondError helper. Update/Trigger handlers previously returned 500 for missing watches, hiding the distinction from real server faults * service_events.go: log Publish errors on watch lifecycle events instead of swallowing them silently * handlers.go: reject malformed JSON payloads in list-style WS handlers rather than silently falling through to broader list/search behavior * event_handlers_gitlab.go: remove dead gitlabRepoSlug helper + blank-identifier suppression * ci: re-trigger to clear flaky E2E shard 3 * fix(gitlab): address final review round — nil guards, store merge, logs, body validation - CheckMRWatch, UpdateReviewWatch, UpdateIssueWatch: nil guards for store/req - handlers: wsNewDiscussionNote rejects empty body - action_presets: Update reads raw stored row (no default freezing); Reset validates workspace_id - service_search.GetStats: log Warn per sub-call failure before zero - event_handlers_gitlab: log Release* dedup-row errors with context - web/store.ts: post-slice merge restores every GitLab sub-state field; extract buildStateOverrides helper - web/default-state.ts: add GitLab fields to defaultState + mergeInitialState via mergeGitLabFields helper - web/gitlab-api.ts: UpdateXxxWatchRequest now Omit workspace_id Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
324 lines
11 KiB
Go
324 lines
11 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
const responseErrorKey = "error"
|
|
|
|
// Controller handles HTTP endpoints for GitLab integration.
|
|
type Controller struct {
|
|
service *Service
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// NewController creates a new GitLab controller.
|
|
func NewController(svc *Service, log *logger.Logger) *Controller {
|
|
return &Controller{service: svc, logger: log}
|
|
}
|
|
|
|
// RegisterHTTPRoutes registers the v1 HTTP surface.
|
|
func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
|
api := router.Group("/api/v1/gitlab")
|
|
api.GET("/status", c.httpGetStatus)
|
|
api.POST("/token", c.httpConfigureToken)
|
|
api.DELETE("/token", c.httpClearToken)
|
|
api.POST("/host", c.httpConfigureHost)
|
|
|
|
api.GET("/mrs/feedback", c.httpGetMRFeedback)
|
|
api.POST("/mrs/discussions/notes", c.httpCreateDiscussionNote)
|
|
api.POST("/mrs/discussions/resolve", c.httpResolveDiscussion)
|
|
|
|
api.GET("/workspaces/:workspaceID/task-mrs", c.httpListWorkspaceTaskMRs)
|
|
api.GET("/tasks/:taskID/mrs", c.httpListTaskMRs)
|
|
api.POST("/tasks/:taskID/mrs/sync", c.httpSyncTaskMR)
|
|
|
|
api.GET("/user/mrs", c.httpSearchUserMRs)
|
|
api.GET("/user/issues", c.httpSearchUserIssues)
|
|
|
|
c.RegisterWatchHTTPRoutes(router)
|
|
}
|
|
|
|
// RegisterRoutes is the package-level entrypoint mirroring github.RegisterRoutes.
|
|
func RegisterRoutes(router *gin.Engine, svc *Service, log *logger.Logger) {
|
|
NewController(svc, log).RegisterHTTPRoutes(router)
|
|
}
|
|
|
|
func (c *Controller) httpGetStatus(ctx *gin.Context) {
|
|
status, err := c.service.GetStatus(ctx.Request.Context())
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, status)
|
|
}
|
|
|
|
func (c *Controller) httpConfigureToken(ctx *gin.Context) {
|
|
var req ConfigureTokenRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload: token is required"})
|
|
return
|
|
}
|
|
if err := c.service.ConfigureToken(ctx.Request.Context(), req.Token); err != nil {
|
|
// errors.Is against the package-level sentinel — durable across
|
|
// future rewording / wrapping of the ConfigureToken error message.
|
|
if errors.Is(err, ErrInvalidToken) {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"configured": true})
|
|
}
|
|
|
|
func (c *Controller) httpClearToken(ctx *gin.Context) {
|
|
if err := c.service.ClearToken(ctx.Request.Context()); err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"cleared": true})
|
|
}
|
|
|
|
func (c *Controller) httpConfigureHost(ctx *gin.Context) {
|
|
var req ConfigureHostRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload: host is required"})
|
|
return
|
|
}
|
|
if err := c.service.ConfigureHost(ctx.Request.Context(), req.Host); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"configured": true, "host": c.service.Host()})
|
|
}
|
|
|
|
func (c *Controller) httpGetMRFeedback(ctx *gin.Context) {
|
|
projectPath, iid, err := parseProjectAndIID(ctx)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
feedback, err := c.service.GetMRFeedback(ctx.Request.Context(), projectPath, iid)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, feedback)
|
|
}
|
|
|
|
func (c *Controller) httpCreateDiscussionNote(ctx *gin.Context) {
|
|
var req struct {
|
|
Project string `json:"project" binding:"required"`
|
|
IID int `json:"iid" binding:"required"`
|
|
DiscussionID string `json:"discussion_id" binding:"required"`
|
|
Body string `json:"body" binding:"required"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload"})
|
|
return
|
|
}
|
|
note, err := c.service.CreateMRDiscussionNote(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID, req.Body)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, note)
|
|
}
|
|
|
|
func (c *Controller) httpResolveDiscussion(ctx *gin.Context) {
|
|
var req struct {
|
|
Project string `json:"project" binding:"required"`
|
|
IID int `json:"iid" binding:"required"`
|
|
DiscussionID string `json:"discussion_id" binding:"required"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload"})
|
|
return
|
|
}
|
|
if err := c.service.ResolveMRDiscussion(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID); err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"resolved": true})
|
|
}
|
|
|
|
// parseProjectAndIID reads ?project=<path>&iid=<n> query params and validates
|
|
// them. project is the namespace/path slug (URL-decoded by Gin); iid must be
|
|
// a positive integer.
|
|
func parseProjectAndIID(ctx *gin.Context) (string, int, error) {
|
|
project := ctx.Query("project")
|
|
if project == "" {
|
|
return "", 0, errors.New("project query param required")
|
|
}
|
|
iidStr := ctx.Query("iid")
|
|
if iidStr == "" {
|
|
return "", 0, errors.New("iid query param required")
|
|
}
|
|
iid, err := strconv.Atoi(iidStr)
|
|
if err != nil || iid <= 0 {
|
|
return "", 0, errors.New("iid must be a positive integer")
|
|
}
|
|
return project, iid, nil
|
|
}
|
|
|
|
// SyncTaskMRRequest is the JSON body for POST /tasks/:taskID/mrs/sync.
|
|
// project_path is "namespace/path"; iid is the MR's per-project sequential id;
|
|
// repository_id is the kandev repository UUID (empty for single-repo tasks).
|
|
type SyncTaskMRRequest struct {
|
|
ProjectPath string `json:"project_path" binding:"required"`
|
|
IID int `json:"iid" binding:"required"`
|
|
RepositoryID string `json:"repository_id"`
|
|
}
|
|
|
|
func (c *Controller) httpListWorkspaceTaskMRs(ctx *gin.Context) {
|
|
wsID := ctx.Param("workspaceID")
|
|
if wsID == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspaceID required"})
|
|
return
|
|
}
|
|
taskMRs, err := c.service.ListTaskMRsByWorkspace(ctx.Request.Context(), wsID)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, TaskMRsResponse{TaskMRs: taskMRs})
|
|
}
|
|
|
|
func (c *Controller) httpListTaskMRs(ctx *gin.Context) {
|
|
taskID := ctx.Param("taskID")
|
|
if taskID == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "taskID required"})
|
|
return
|
|
}
|
|
mrs, err := c.service.ListTaskMRsByTask(ctx.Request.Context(), taskID)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"task_mrs": mrs})
|
|
}
|
|
|
|
func (c *Controller) httpSyncTaskMR(ctx *gin.Context) {
|
|
taskID := ctx.Param("taskID")
|
|
if taskID == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "taskID required"})
|
|
return
|
|
}
|
|
var req SyncTaskMRRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload: project_path and iid required"})
|
|
return
|
|
}
|
|
row, err := c.service.SyncTaskMR(ctx.Request.Context(), taskID, req.RepositoryID, req.ProjectPath, req.IID)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, row)
|
|
}
|
|
|
|
// httpSearchUserMRs surfaces the configured user's MR queue. filter is one
|
|
// of "assigned_to_me", "created_by_me", "review_requested" (the /gitlab
|
|
// page's tab values), or a raw GitLab API filter in `key=value` form;
|
|
// custom_query passes through verbatim for power users and disables tab
|
|
// translation entirely.
|
|
func (c *Controller) httpSearchUserMRs(ctx *gin.Context) {
|
|
page, perPage := paginationFromQuery(ctx)
|
|
filter := ctx.Query("filter")
|
|
customQuery := ctx.Query("custom_query")
|
|
if customQuery == "" {
|
|
translated, err := c.translateMRFilter(ctx, filter)
|
|
if err != nil {
|
|
// translateMRFilter has already written the HTTP error.
|
|
return
|
|
}
|
|
if translated != "" {
|
|
filter = translated
|
|
}
|
|
}
|
|
result, err := c.service.Client().SearchMRsPaged(
|
|
ctx.Request.Context(), filter, customQuery, page, perPage,
|
|
)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// httpSearchUserIssues surfaces the configured user's issue queue. filter
|
|
// supports "assigned_to_me" and "created_by_me" (the /gitlab page's issue
|
|
// tabs). "review_requested" is explicitly rejected with 400 — GitLab has no
|
|
// reviewer-assigned concept for issues, and silently serving the global
|
|
// unscoped listing would re-introduce the bug this translator layer was
|
|
// added to prevent.
|
|
func (c *Controller) httpSearchUserIssues(ctx *gin.Context) {
|
|
page, perPage := paginationFromQuery(ctx)
|
|
filter := ctx.Query("filter")
|
|
customQuery := ctx.Query("custom_query")
|
|
if customQuery == "" {
|
|
if filter == filterTokenReviewRequested {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "review_requested is not supported for issues"})
|
|
return
|
|
}
|
|
if translated := translateUserSearchFilter(filter, ""); translated != "" {
|
|
filter = translated
|
|
}
|
|
}
|
|
result, err := c.service.Client().ListIssuesPaged(
|
|
ctx.Request.Context(), filter, customQuery, page, perPage,
|
|
)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// translateMRFilter resolves the authenticated user (only when the
|
|
// review_requested tab needs it) and runs the filter through
|
|
// translateUserSearchFilter. On a username-lookup failure — including a
|
|
// successful call that returns no username (NoopClient, an unexpected
|
|
// GitLab response) — it writes a 500 to ctx and returns an error so the
|
|
// caller can short-circuit. Silently falling back to an unfiltered listing
|
|
// would re-introduce the very bug this translation layer was added to
|
|
// prevent.
|
|
func (c *Controller) translateMRFilter(ctx *gin.Context, filter string) (string, error) {
|
|
var username string
|
|
if filter == filterTokenReviewRequested {
|
|
u, err := c.service.Client().GetAuthenticatedUser(ctx.Request.Context())
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return "", err
|
|
}
|
|
if u == "" {
|
|
err := errors.New("cannot resolve authenticated GitLab user")
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return "", err
|
|
}
|
|
username = u
|
|
}
|
|
return translateUserSearchFilter(filter, username), nil
|
|
}
|
|
|
|
// paginationFromQuery reads ?page=&per_page= with the same clamps SearchMRsPaged
|
|
// applies internally — surfaced here so 400s on bad input are uniform.
|
|
func paginationFromQuery(ctx *gin.Context) (int, int) {
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
perPage, _ := strconv.Atoi(ctx.DefaultQuery("per_page", "50"))
|
|
if perPage <= 0 {
|
|
perPage = 50
|
|
}
|
|
return page, perPage
|
|
}
|