Files
kandev/apps/backend/internal/gitlab/service_mr_automation.go
T
Alassane ecda06748e fix(gitlab): scope MR automation switches per linked MR (#2676)
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-08-22 14:44:56 +00:00

601 lines
22 KiB
Go

package gitlab
import (
"context"
"errors"
"fmt"
"strings"
promptcfg "github.com/kandev/kandev/config/prompts"
)
// defaultMRAutoFixPromptName is the embedded default prompt template name
// (apps/backend/config/prompts/mr-auto-fix.md), mirroring GitHub's
// defaultCIAutoFixPromptName.
const defaultMRAutoFixPromptName = "mr-auto-fix"
// ErrTaskMRNotLinked reports a patch naming an MR that is not linked to the
// task. Exported so the HTTP controller and MCP handler can map it to a
// client error instead of a 500.
var ErrTaskMRNotLinked = errors.New("gitlab: merge request is not linked to this task")
// ErrTaskMRIdentityIncomplete reports a patch that set some but not all of
// repository_id/project_path/mr_iid. Sending a partial identity is a caller
// mistake, and treating it as "no identity" would silently fan the switch
// change out to every linked MR.
var ErrTaskMRIdentityIncomplete = fmt.Errorf(
"%w: repository_id, project_path and mr_iid must all be set", ErrTaskMRNotLinked,
)
// GetTaskMRAutomationResponse returns a task's MR automation options plus
// its per-MR lifecycle checkpoints (AC1).
func (s *Service) GetTaskMRAutomationResponse(ctx context.Context, taskID string) (*TaskMRAutomationResponse, error) {
if err := s.authorizeTaskMRAccess(ctx, taskID); err != nil {
return nil, err
}
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
// WorkspaceIDForTask also validates the task row exists — unlike
// authorizeTaskMRAccess (a no-op for unscoped/auth-disabled callers),
// this rejects an unknown task ID unconditionally instead of returning
// an implicit all-false default for it.
workspaceID, err := store.WorkspaceIDForTask(ctx, taskID)
if err != nil {
return nil, err
}
opts, err := store.GetTaskMRAutomationOptions(ctx, taskID)
if err != nil {
return nil, err
}
mrOptions, err := s.taskMRAutomationOptionsList(ctx, taskID)
if err != nil {
return nil, err
}
states, err := store.ListTaskMRLifecycleStates(ctx, taskID)
if err != nil {
return nil, err
}
return s.taskMRAutomationResponseFromOptions(ctx, opts, mrOptions, states, workspaceID), nil
}
// taskMRAutomationOptionsList returns one entry per MR currently linked to
// the task, synthesizing all-off defaults for a linked MR that has no stored
// row yet. A stored row whose MR is no longer linked is dropped: it can no
// longer be configured or evaluated, and surfacing it would let a detached
// MR's leftover switch drag the aggregate down.
func (s *Service) taskMRAutomationOptionsList(ctx context.Context, taskID string) ([]*TaskMRAutomationOptionsForMR, error) {
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
stored, err := store.ListTaskMRAutomationOptions(ctx, taskID)
if err != nil {
return nil, err
}
mrs, err := store.ListTaskMRsByTask(ctx, taskID)
if err != nil {
return nil, err
}
byIdentity := make(map[MRIdentity]*TaskMRAutomationOptionsForMR, len(stored))
for _, row := range stored {
byIdentity[row.Identity()] = row
}
out := make([]*TaskMRAutomationOptionsForMR, 0, len(mrs))
for _, mr := range mrs {
id := MRIdentity{RepositoryID: mr.RepositoryID, ProjectPath: mr.ProjectPath, MRIID: mr.MRIID}
if row, ok := byIdentity[id]; ok {
out = append(out, row)
continue
}
out = append(out, &TaskMRAutomationOptionsForMR{
TaskID: taskID, RepositoryID: id.RepositoryID,
ProjectPath: id.ProjectPath, MRIID: id.MRIID,
})
}
return out, nil
}
// GetTaskMRAutomationEvaluation returns the narrow snapshot needed for one
// lifecycle evaluation. It intentionally avoids the task-wide checkpoint scan
// used by the public response and takes reviewer identity from the persisted
// workspace configuration, whose save and health flows own identity refresh.
func (s *Service) GetTaskMRAutomationEvaluation(
ctx context.Context, taskID, repositoryID, projectPath string, mrIID int,
) (*TaskMRAutomationEvaluation, error) {
if err := s.authorizeTaskMRAccess(ctx, taskID); err != nil {
return nil, err
}
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
workspaceID, err := store.WorkspaceIDForTask(ctx, taskID)
if err != nil {
return nil, err
}
cfg, err := store.GetConfigForWorkspace(ctx, workspaceID)
if err != nil {
return nil, err
}
reviewerUsername := ""
if cfg != nil {
reviewerUsername = strings.TrimSpace(cfg.Username)
}
// Rebind before reading the target checkpoint. The store updates the
// persisted reviewer and clears review-request baselines in one transaction,
// so an account change cannot be evaluated against the previous account's
// edge state.
if _, err := s.rebindTaskMRReviewerFromConfig(ctx, taskID, reviewerUsername); err != nil {
return nil, err
}
opts, err := store.GetTaskMRAutomationOptions(ctx, taskID)
if err != nil {
return nil, err
}
id := MRIdentity{RepositoryID: repositoryID, ProjectPath: projectPath, MRIID: mrIID}
// Only this MR's own switches drive this evaluation — a sibling MR's
// configuration must never enable automation here.
mrOpts, err := store.GetTaskMRAutomationOptionsForMR(ctx, taskID, id)
if err != nil {
return nil, err
}
checkpoint, err := store.GetTaskMRLifecycleState(ctx, taskID, repositoryID, projectPath, mrIID)
if err != nil {
return nil, err
}
// Evaluation uses the workspace config as the authoritative current
// identity; a missing config/username therefore fails closed without
// probing GitLab for every MR.
evaluationOpts := *opts
evaluationOpts.ReviewReviewerUsername = reviewerUsername
return &TaskMRAutomationEvaluation{
Options: s.taskMRAutomationResponseFromOptions(
ctx, &evaluationOpts, []*TaskMRAutomationOptionsForMR{mrOpts}, nil, workspaceID,
),
Checkpoint: checkpoint,
}, nil
}
func (s *Service) rebindTaskMRReviewerFromConfig(ctx context.Context, taskID, username string) (bool, error) {
store := s.requireStore()
if store == nil {
return false, errStoreUnavailable
}
return store.RebindTaskMRReviewer(ctx, taskID, username)
}
func (s *Service) taskMRAutomationResponseFromOptions(
ctx context.Context, opts *TaskMRAutomationOptions, mrOptions []*TaskMRAutomationOptionsForMR,
states []*TaskMRLifecycleState, workspaceID string,
) *TaskMRAutomationResponse {
effectivePrompt, usingDefault := s.effectiveMRAutoFixPrompt(ctx, opts)
aggregate := aggregateMRAutomationOptions(mrOptions)
return &TaskMRAutomationResponse{
TaskID: opts.TaskID,
AutomationRevision: opts.AutomationRevision,
AutoFixEnabled: aggregate.AutoFixEnabled,
AutoMergeEnabled: aggregate.AutoMergeEnabled,
AutoFixPromptOverride: opts.AutoFixPromptOverride,
AutoFixMaxRounds: TaskMRAutoFixMaxRounds,
EffectiveAutoFixPrompt: effectivePrompt,
UsingDefaultPrompt: usingDefault,
PromptOnReviewRequested: aggregate.PromptOnReviewRequested,
PromptOnMerged: aggregate.PromptOnMerged,
PromptOnClosed: aggregate.PromptOnClosed,
ReviewReviewerUsername: opts.ReviewReviewerUsername,
UpdatedAt: opts.UpdatedAt,
MRStates: states,
MROptions: mrOptions,
WorkspaceID: workspaceID,
}
}
// aggregateMRAutomationOptions collapses per-MR switches into the response's
// top-level booleans: a switch reads as on only when every linked MR has it
// on and at least one MR is linked. "Every" rather than "any" keeps the
// aggregate a safe answer to "is this on for the MRs I'd act on" for an MCP
// caller that cannot name one MR.
func aggregateMRAutomationOptions(mrOptions []*TaskMRAutomationOptionsForMR) TaskMRAutomationOptionsForMR {
if len(mrOptions) == 0 {
return TaskMRAutomationOptionsForMR{}
}
aggregate := TaskMRAutomationOptionsForMR{
AutoFixEnabled: true, AutoMergeEnabled: true, PromptOnReviewRequested: true,
PromptOnMerged: true, PromptOnClosed: true,
}
for _, opt := range mrOptions {
aggregate.AutoFixEnabled = aggregate.AutoFixEnabled && opt.AutoFixEnabled
aggregate.AutoMergeEnabled = aggregate.AutoMergeEnabled && opt.AutoMergeEnabled
aggregate.PromptOnReviewRequested = aggregate.PromptOnReviewRequested && opt.PromptOnReviewRequested
aggregate.PromptOnMerged = aggregate.PromptOnMerged && opt.PromptOnMerged
aggregate.PromptOnClosed = aggregate.PromptOnClosed && opt.PromptOnClosed
}
return aggregate
}
// effectiveMRAutoFixPrompt resolves the prompt text that will actually be
// sent on the next auto-fix dispatch: a non-empty per-task override wins;
// otherwise the default template, itself resolved through the editable
// prompt service when wired (so a workspace-level edit to the "mr-auto-fix"
// prompt applies) or the embedded fallback when not. Mirrors GitHub's
// effectiveCIAutoFixPrompt.
func (s *Service) effectiveMRAutoFixPrompt(ctx context.Context, opts *TaskMRAutomationOptions) (string, bool) {
if opts.AutoFixPromptOverride != nil {
if override := strings.TrimSpace(*opts.AutoFixPromptOverride); override != "" {
return override, false
}
}
fallback := promptcfg.Get(defaultMRAutoFixPromptName)
resolver := s.getPromptResolver()
if resolver == nil {
return fallback, true
}
return resolver.ResolvePromptContent(ctx, defaultMRAutoFixPromptName, fallback), true
}
// UpdateTaskMRAutomationOptions applies a partial update. The five automation
// switches are applied to the single MR named by
// patch.RepositoryID/ProjectPath/MRIID, or — when no MR is named — fanned out
// to every MR currently linked to the task, which preserves the behavior of
// MCP callers that have no MR identity to send. The auto-fix prompt override
// stays task-level either way.
//
// When the patch turns prompt_on_review_requested on, the workspace's
// authenticated GitLab username is resolved and persisted (AC5); turning it
// off clears the stored username unless another linked MR still needs it.
// Resolution always goes through the strict, non-ambient workspace client
// (AC32).
func (s *Service) UpdateTaskMRAutomationOptions(ctx context.Context, taskID string, patch TaskMRAutomationPatch) (*TaskMRAutomationResponse, error) {
if err := s.authorizeTaskMRAccess(ctx, taskID); err != nil {
return nil, err
}
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
if patch.HasPartialMRIdentity() {
return nil, ErrTaskMRIdentityIncomplete
}
// See the identical check in GetTaskMRAutomationResponse: rejects an
// unknown task ID before it can create an orphan options row.
workspaceID, err := store.WorkspaceIDForTask(ctx, taskID)
if err != nil {
return nil, err
}
targets, err := s.resolveTaskMRAutomationTargets(ctx, taskID, patch)
if err != nil {
return nil, err
}
// The switches are per-MR, so with nothing linked there is no row to write
// them to. Returning 200 here would report success for a write that stored
// nothing, and the caller would only discover it much later when the
// automation it thought it had enabled never fired. (Before the switches
// became per-MR this persisted on the task row and a later-linked MR
// inherited it.) The prompt override is task-level and still allowed.
if patch.SwitchPatch().HasAny() && len(targets) == 0 {
return nil, fmt.Errorf("%w: the task has no linked merge requests", ErrTaskMRNotLinked)
}
reviewerUsername, err := s.resolveReviewerUsernameForPatch(ctx, taskID, patch, targets)
if err != nil {
return nil, err
}
// One transaction covering the task-level fields and every target: a
// failure must not leave the reviewer or prompt override committed while
// the per-MR switches roll back.
opts, err := store.UpdateTaskMRAutomationOptionsWithMRs(
ctx, taskID, patch, reviewerUsername, targets, patch.SwitchPatch(),
)
if err != nil {
return nil, err
}
mrOptions, err := s.taskMRAutomationOptionsList(ctx, taskID)
if err != nil {
return nil, err
}
states, err := store.ListTaskMRLifecycleStates(ctx, taskID)
if err != nil {
return nil, err
}
return s.taskMRAutomationResponseFromOptions(ctx, opts, mrOptions, states, workspaceID), nil
}
// resolveTaskMRAutomationTargets resolves which linked MR(s) a patch applies
// to. No identity fans out to every linked MR; a named identity that is not
// currently linked is rejected rather than silently creating an orphan
// automation row.
func (s *Service) resolveTaskMRAutomationTargets(
ctx context.Context, taskID string, patch TaskMRAutomationPatch,
) ([]MRIdentity, error) {
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
mrs, err := store.ListTaskMRsByTask(ctx, taskID)
if err != nil {
return nil, err
}
linked := make([]MRIdentity, 0, len(mrs))
for _, mr := range mrs {
linked = append(linked, MRIdentity{
RepositoryID: mr.RepositoryID, ProjectPath: mr.ProjectPath, MRIID: mr.MRIID,
})
}
if !patch.HasMRIdentity() {
return linked, nil
}
wanted := patch.MRIdentity()
for _, id := range linked {
if id == wanted {
return []MRIdentity{id}, nil
}
}
return nil, fmt.Errorf("%w: project_path=%s mr_iid=%d", ErrTaskMRNotLinked, wanted.ProjectPath, wanted.MRIID)
}
// resolveReviewerUsernameForPatch resolves the task-level reviewer username
// implied by a review-request switch change. Disabling only blanks the shared
// username when no *other* linked MR still has the switch on — otherwise this
// would silently break that MR's automation.
func (s *Service) resolveReviewerUsernameForPatch(
ctx context.Context, taskID string, patch TaskMRAutomationPatch, targets []MRIdentity,
) (*string, error) {
if patch.PromptOnReviewRequested == nil {
return nil, nil
}
if *patch.PromptOnReviewRequested {
username, err := s.resolveAuthenticatedUsernameStrict(ctx, taskID)
if err != nil {
return nil, err
}
return &username, nil
}
stillNeeded, err := s.anyOtherMRHasReviewRequestEnabled(ctx, taskID, targets)
if err != nil {
return nil, err
}
if stillNeeded {
return nil, nil
}
empty := ""
return &empty, nil
}
// anyOtherMRHasReviewRequestEnabled reports whether a linked MR outside
// `targets` still has prompt_on_review_requested on.
func (s *Service) anyOtherMRHasReviewRequestEnabled(
ctx context.Context, taskID string, targets []MRIdentity,
) (bool, error) {
store := s.requireStore()
if store == nil {
return false, errStoreUnavailable
}
stored, err := store.ListTaskMRAutomationOptions(ctx, taskID)
if err != nil {
return false, err
}
targeted := make(map[MRIdentity]struct{}, len(targets))
for _, id := range targets {
targeted[id] = struct{}{}
}
for _, opt := range stored {
if !opt.PromptOnReviewRequested {
continue
}
if _, ok := targeted[opt.Identity()]; ok {
continue
}
return true, nil
}
return false, nil
}
func (s *Service) resolveAuthenticatedUsernameStrict(ctx context.Context, taskID string) (string, error) {
client, err := s.clientForTaskStrict(ctx, taskID)
if err != nil {
return "", err
}
username, err := client.GetAuthenticatedUser(ctx)
if err != nil {
return "", err
}
if strings.TrimSpace(username) == "" {
return "", errors.New("gitlab: cannot resolve authenticated user")
}
return username, nil
}
// HasEnabledTaskMRAgentPrompts reports whether any MR linked to the task has
// a lifecycle switch on, used by cleanup retention (AC24) and by the
// poll/evaluation gate (AC16). Reads the per-MR table, so a task whose MRs
// are configured differently is still detected.
func (s *Service) HasEnabledTaskMRAgentPrompts(ctx context.Context, taskID string) (bool, error) {
store := s.requireStore()
if store == nil {
return false, errStoreUnavailable
}
stored, err := store.ListTaskMRAutomationOptions(ctx, taskID)
if err != nil {
return false, err
}
for _, opt := range stored {
if opt.PromptOnReviewRequested || opt.PromptOnMerged || opt.PromptOnClosed {
return true, nil
}
}
return false, nil
}
// RebindTaskMRReviewer re-resolves the workspace's authenticated GitLab
// username against the strict client and, when it changed, atomically
// clears every review-request baseline for the task (risk 3 in the plan).
// Returns the current username.
func (s *Service) RebindTaskMRReviewer(ctx context.Context, taskID string) (string, bool, error) {
store := s.requireStore()
if store == nil {
return "", false, errStoreUnavailable
}
username, err := s.resolveAuthenticatedUsernameStrict(ctx, taskID)
if err != nil {
return "", false, err
}
changed, err := store.RebindTaskMRReviewer(ctx, taskID, username)
if err != nil {
return "", false, err
}
return username, changed, nil
}
// GetTaskMRLifecycleState exposes the per-MR checkpoint to the orchestrator
// lifecycle evaluator.
func (s *Service) GetTaskMRLifecycleState(ctx context.Context, taskID, repositoryID, projectPath string, mrIID int) (*TaskMRLifecycleState, error) {
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
return store.GetTaskMRLifecycleState(ctx, taskID, repositoryID, projectPath, mrIID)
}
// SetTaskMRReviewRequestState pass-through to the store.
func (s *Service) SetTaskMRReviewRequestState(ctx context.Context, taskID, repositoryID, projectPath string, mrIID int, requested bool) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.SetTaskMRReviewRequestState(ctx, taskID, repositoryID, projectPath, mrIID, requested)
}
// SetTaskMRObservedState pass-through to the store.
func (s *Service) SetTaskMRObservedState(ctx context.Context, taskID, repositoryID, projectPath string, mrIID int, state string) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.SetTaskMRObservedState(ctx, taskID, repositoryID, projectPath, mrIID, state)
}
// RecordTaskMRLifecyclePrompt pass-through to the store.
func (s *Service) RecordTaskMRLifecyclePrompt(ctx context.Context, prompt TaskMRLifecyclePrompt) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.RecordTaskMRLifecyclePrompt(ctx, prompt)
}
// RecordTaskMRAutomationError pass-through to the store (AC25).
func (s *Service) RecordTaskMRAutomationError(ctx context.Context, taskID, repositoryID, projectPath string, mrIID int, message string) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.RecordTaskMRAutomationError(ctx, taskID, repositoryID, projectPath, mrIID, message)
}
// ClearTaskMRAutomationError pass-through to the store. Called after a
// successful lifecycle prompt delivery so a recovered delivery failure
// doesn't linger in last_error and read as an active problem.
func (s *Service) ClearTaskMRAutomationError(ctx context.Context, taskID, repositoryID, projectPath string, mrIID int) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.ClearTaskMRAutomationError(ctx, taskID, repositoryID, projectPath, mrIID)
}
// RecordTaskMRSyncError pass-through to the store. Called by the poller when
// a lifecycle sync pass fails — kept in a separate column from
// RecordTaskMRAutomationError's delivery error so the two concerns can't
// clobber each other.
func (s *Service) RecordTaskMRSyncError(ctx context.Context, taskID, repositoryID, projectPath string, mrIID int, message string) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.RecordTaskMRSyncError(ctx, taskID, repositoryID, projectPath, mrIID, message)
}
// RecordTaskMRFixAttempt pass-through to the store.
func (s *Service) RecordTaskMRFixAttempt(ctx context.Context, attempt TaskMRFixAttempt) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.RecordTaskMRFixAttempt(ctx, attempt)
}
// RefreshTaskMRFixCheckpoint pass-through to the store.
func (s *Service) RefreshTaskMRFixCheckpoint(ctx context.Context, taskID, repositoryID, projectPath string, mrIID int, signature, checkpointJSON string) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.RefreshTaskMRFixCheckpoint(ctx, taskID, repositoryID, projectPath, mrIID, signature, checkpointJSON)
}
// MarkTaskMRAutoFixExhausted pass-through to the store.
func (s *Service) MarkTaskMRAutoFixExhausted(ctx context.Context, taskID, repositoryID, projectPath string, mrIID int, message string) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.MarkTaskMRAutoFixExhausted(ctx, taskID, repositoryID, projectPath, mrIID, message)
}
// RecordTaskMRMergeAttempt pass-through to the store.
func (s *Service) RecordTaskMRMergeAttempt(ctx context.Context, attempt TaskMRMergeAttempt) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.RecordTaskMRMergeAttempt(ctx, attempt)
}
// ClearTaskMRSyncError pass-through to the store. Called after a successful
// lifecycle sync so a recovered sync failure doesn't linger in
// last_sync_error and read as an active problem.
func (s *Service) ClearTaskMRSyncError(ctx context.Context, taskID, repositoryID, projectPath string, mrIID int) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
return store.ClearTaskMRSyncError(ctx, taskID, repositoryID, projectPath, mrIID)
}
// IsReviewerOnMR reports whether username currently appears in the MR's
// Reviewers list — GitLab's assignment-as-request signal, since GitLab has
// no distinct "review requested" API event (unlike GitHub's requested
// reviewers). Always resolves the client via the strict, workspace-scoped
// helper (AC32); never falls back to the ambient client.
func (s *Service) IsReviewerOnMR(ctx context.Context, taskID, projectPath string, mrIID int, username string) (bool, error) {
client, err := s.clientForTaskStrict(ctx, taskID)
if err != nil {
return false, err
}
mr, err := client.GetMR(ctx, projectPath, mrIID)
if err != nil {
return false, err
}
if mr == nil {
return false, nil
}
for _, reviewer := range mr.Reviewers {
if strings.EqualFold(reviewer.Username, username) {
return true, nil
}
}
return false, nil
}
// ListAutomationSubscribedTaskMRs pass-through to the store, used by the
// poller's sync pass (AC22) for any MR whose task has a lifecycle,
// auto-fix, or auto-merge switch on.
func (s *Service) ListAutomationSubscribedTaskMRs(ctx context.Context) ([]*TaskMR, error) {
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
return store.ListAutomationSubscribedTaskMRs(ctx)
}