package gitlab import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "github.com/gin-gonic/gin" "go.uber.org/zap" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/repository/repoerrors" ) // errLifecyclePromptOverridesUnsupported mirrors github's sentinel — GitLab's // three prompts are immutable server-owned templates; only the booleans are // a supported API surface (AC4). var errLifecyclePromptOverridesUnsupported = errors.New("lifecycle prompt overrides are not supported") // errAtLeastOneMRAutomationOptionRequired backs AC3: unlike GitHub's PATCH // (which treats an empty body as a no-op GET), an empty MR automation PATCH // is rejected outright. var errAtLeastOneMRAutomationOptionRequired = errors.New("at least one MR automation option is required") // errUnknownMRAutomationField rejects a misspelled or unsupported key rather // than silently ignoring it — without this, a caller-side typo like // "promt_on_merged" would drop that field with no error while any other // valid field in the same body still applied. var errUnknownMRAutomationField = errors.New("unknown MR automation field") // errNullMRAutomationSwitch rejects an explicit `null` for a boolean switch. // Unmarshaling `null` into a *bool sets it back to nil, which is // indistinguishable from the field never being sent at all — silently // treating a caller's explicit null as "no change" rather than surfacing it // as a bad request. var errNullMRAutomationSwitch = errors.New("MR automation switch must be a boolean, not null") // RegisterMRAutomationHTTPRoutes registers the GET/PATCH MR automation // endpoints on an existing /api/v1/gitlab router group. func (c *Controller) RegisterMRAutomationHTTPRoutes(api *gin.RouterGroup) { api.GET("/tasks/:taskID/mr-automation", c.httpGetTaskMRAutomation) api.PATCH("/tasks/:taskID/mr-automation", c.httpPatchTaskMRAutomation) } func (c *Controller) httpGetTaskMRAutomation(ctx *gin.Context) { resp, err := c.service.GetTaskMRAutomationResponse(ctx.Request.Context(), ctx.Param("taskID")) if err != nil { if writeMRAutomationTaskNotFound(ctx, err) { return } c.logger.Error("get task MR automation failed", zap.String("task_id", ctx.Param("taskID")), zap.Error(err)) ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "failed to load MR automation options"}) return } ctx.JSON(http.StatusOK, resp) } func (c *Controller) httpPatchTaskMRAutomation(ctx *gin.Context) { patch, err := parseTaskMRAutomationPatch(ctx) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()}) return } if !patch.HasAny() { ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: errAtLeastOneMRAutomationOptionRequired.Error()}) return } resp, err := c.service.UpdateTaskMRAutomationOptions(ctx.Request.Context(), ctx.Param("taskID"), patch) if err != nil { if writeMRAutomationTaskNotFound(ctx, err) { return } c.logger.Error("update task MR automation failed", zap.String("task_id", ctx.Param("taskID")), zap.Error(err)) ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "failed to update MR automation options"}) return } c.publishTaskMRAutomationUpdated(ctx.Request.Context(), resp) ctx.JSON(http.StatusOK, resp) } // writeMRAutomationTaskNotFound maps the scoped-visibility denial from // Service.authorizeTaskMRAccess, and an unknown task ID from // store.WorkspaceIDForTask (ErrNotConfigured), to a 404 — matching the // "foreign workspace is indistinguishable from nonexistent" no-existence-leak // convention (task/service/service_access.go) instead of a generic 500. func writeMRAutomationTaskNotFound(ctx *gin.Context, err error) bool { if errors.Is(err, repoerrors.ErrTaskNotFound) || errors.Is(err, repoerrors.ErrWorkspaceNotFound) || errors.Is(err, ErrNotConfigured) { ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: "task not found"}) return true } return false } func parseTaskMRAutomationPatch(ctx *gin.Context) (TaskMRAutomationPatch, error) { if ctx.Request.Body == nil || ctx.Request.ContentLength == 0 { return TaskMRAutomationPatch{}, nil } raw, err := decodeSingleJSONObject(ctx.Request.Body) if err != nil { if errors.Is(err, io.EOF) { return TaskMRAutomationPatch{}, nil } return TaskMRAutomationPatch{}, err } var patch TaskMRAutomationPatch for key, value := range raw { if err := applyMRAutomationPatchField(&patch, key, value); err != nil { return TaskMRAutomationPatch{}, err } } return patch, nil } // decodeSingleJSONObject decodes exactly one JSON object from body and // requires EOF immediately after it. Decode alone stops after the first // JSON value, so a body like `{...}{...}` would otherwise silently apply // only the first object and ignore the rest — requiring EOF here rejects // any trailing content as malformed instead. func decodeSingleJSONObject(body io.Reader) (map[string]json.RawMessage, error) { decoder := json.NewDecoder(body) var raw map[string]json.RawMessage if err := decoder.Decode(&raw); err != nil { return nil, err } if err := decoder.Decode(new(json.RawMessage)); !errors.Is(err, io.EOF) { if err == nil { err = errors.New("unexpected trailing content after JSON body") } return nil, err } return raw, nil } func applyMRAutomationPatchField(patch *TaskMRAutomationPatch, key string, value json.RawMessage) error { switch key { case "prompt_on_review_requested": return decodeMRAutomationSwitch(value, &patch.PromptOnReviewRequested) case "prompt_on_merged": return decodeMRAutomationSwitch(value, &patch.PromptOnMerged) case "prompt_on_closed": return decodeMRAutomationSwitch(value, &patch.PromptOnClosed) case "review_prompt_override", "merged_prompt_override", "closed_prompt_override": return errLifecyclePromptOverridesUnsupported default: return fmt.Errorf("%w: %q", errUnknownMRAutomationField, key) } } // decodeMRAutomationSwitch unmarshals a boolean patch field, rejecting an // explicit JSON null rather than silently unmarshaling it into a nil *bool // (indistinguishable from the field being absent). func decodeMRAutomationSwitch(value json.RawMessage, dst **bool) error { if string(value) == "null" { return errNullMRAutomationSwitch } return json.Unmarshal(value, dst) } func (c *Controller) publishTaskMRAutomationUpdated(ctx context.Context, resp *TaskMRAutomationResponse) { if c.service == nil || resp == nil { return } c.service.mu.RLock() eb := c.service.eventBus c.service.mu.RUnlock() if eb == nil { return } event := bus.NewEvent(events.GitLabTaskMROptionsUpdated, eventSource, resp) if err := eb.Publish(ctx, events.GitLabTaskMROptionsUpdated, event); err != nil { c.logger.Debug("publish task MR automation update failed", zap.String("task_id", resp.TaskID), zap.Error(err)) } }