Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f986a27
fix(gitlab): scope MR automation switches per linked MR
ayattara-sfl Aug 11, 2026
e876cf1
fix(gitlab): scope MR automation UI per linked MR
ayattara-sfl Aug 11, 2026
43c4e0f
docs(gitlab): document per-MR automation scoping
ayattara-sfl Aug 11, 2026
9d52dc0
fix(gitlab): tie per-MR automation rows to the MR's linked lifetime
ayattara-sfl Aug 12, 2026
5135056
test(gitlab): cover per-MR automation gating at orchestrator and HTTP…
ayattara-sfl Aug 12, 2026
ef42189
fix(gitlab): drop per-MR automation options on the second MR delete path
ayattara-sfl Aug 12, 2026
9510898
test(gitlab): add MR options to chip fixtures
ayattara-sfl Aug 13, 2026
851cb88
fix(gitlab): scope MR status chip automation badges per MR
ayattara-sfl Aug 14, 2026
f8e103f
test(gitlab): cover per-MR chip badges with two linked MRs
ayattara-sfl Aug 14, 2026
23b73a9
fix(gitlab): share MR automation request ordering across mounted inst…
ayattara-sfl Aug 15, 2026
0901c8b
ci(e2e): use dwell() for the manual seed script's poll wait
ayattara-sfl Aug 15, 2026
a019c7c
fix(gitlab): use reader for MR option migration
ayattara-sfl Aug 20, 2026
f626d35
fix(gitlab): harden MR automation updates
ayattara-sfl Aug 21, 2026
d6c2df8
test(gitlab): share MR automation E2E fixture
ayattara-sfl Aug 21, 2026
19196ae
test(e2e): retry transient submodule Git locks
ayattara-sfl Aug 21, 2026
55a7b0b
test(e2e): keep Vitest helpers outside Playwright tree
ayattara-sfl Aug 21, 2026
749f039
fix(gitlab): harden MR automation review fixes
carlosflorencio Aug 21, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,6 @@ apps/web/e2e/.auth-screenshots/

# Restart-action screenshot artifacts (generated by feature-toggles-restart.spec.ts)
apps/web/e2e/.restart-screenshots/

# Manual dev-environment scratch repos (apps/web/e2e/manual-seed-*.ts)
apps/backend/.manual-env/
28 changes: 28 additions & 0 deletions apps/backend/internal/gitlab/controller_mr_automation.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ var errUnknownMRAutomationField = errors.New("unknown MR automation field")
// as a bad request.
var errNullMRAutomationSwitch = errors.New("MR automation switch must be a boolean, not null")

var errNullMRAutomationIdentity = errors.New("MR automation identity fields must not be null")

// RegisterMRAutomationHTTPRoutes registers the GET/PATCH MR automation
// endpoints on an existing /api/v1/gitlab router group.
func (c *Controller) RegisterMRAutomationHTTPRoutes(api *gin.RouterGroup) {
Expand Down Expand Up @@ -74,6 +76,12 @@ func (c *Controller) httpPatchTaskMRAutomation(ctx *gin.Context) {
if writeMRAutomationTaskNotFound(ctx, err) {
return
}
// A patch naming an MR that isn't linked (or naming one only
// partially) is a caller mistake, not a server fault.
if errors.Is(err, ErrTaskMRNotLinked) {
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()})
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
Expand Down Expand Up @@ -150,6 +158,12 @@ func applyMRAutomationPatchField(patch *TaskMRAutomationPatch, key string, value
return decodeMRAutomationSwitch(value, &patch.PromptOnMerged)
case "prompt_on_closed":
return decodeMRAutomationSwitch(value, &patch.PromptOnClosed)
case "repository_id":
return decodeMRAutomationIdentityString(value, &patch.RepositoryID)
case "project_path":
return decodeMRAutomationIdentityString(value, &patch.ProjectPath)
case "mr_iid":
return decodeMRAutomationIdentityInteger(value, &patch.MRIID)
case "review_prompt_override", "merged_prompt_override", "closed_prompt_override":
return errLifecyclePromptOverridesUnsupported
default:
Expand Down Expand Up @@ -187,6 +201,20 @@ func decodeMRAutomationSwitch(value json.RawMessage, dst **bool) error {
return json.Unmarshal(value, dst)
}

func decodeMRAutomationIdentityString(value json.RawMessage, dst **string) error {
if string(value) == "null" {
return errNullMRAutomationIdentity
}
return json.Unmarshal(value, dst)
}

func decodeMRAutomationIdentityInteger(value json.RawMessage, dst **int) error {
if string(value) == "null" {
return errNullMRAutomationIdentity
}
return json.Unmarshal(value, dst)
}

func (c *Controller) publishTaskMRAutomationUpdated(ctx context.Context, resp *TaskMRAutomationResponse) {
if c.service == nil || resp == nil {
return
Expand Down
82 changes: 82 additions & 0 deletions apps/backend/internal/gitlab/controller_mr_automation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ func newMRAutomationControllerFixture(t *testing.T) (*gin.Engine, *Service) {
store := newTestStore(t)
seedWorkspace(t, store, "ws-1")
seedTask(t, store, "task-1", "ws-1")
// The switches are per-MR, so a PATCH only has somewhere to land once the
// task has at least one linked MR.
if err := store.UpsertTaskMR(context.Background(), newTestMR("task-1", "", "group/a", 1)); err != nil {
t.Fatalf("seed linked MR: %v", err)
}
if err := store.SaveConfigForWorkspace(context.Background(), "ws-1", &GitLabConfig{
Host: "https://gitlab.example.com", AuthMethod: AuthMethodPAT,
}); err != nil {
Expand Down Expand Up @@ -379,3 +384,80 @@ func TestControllerPatchTaskMRAutomation_PublishesEvent(t *testing.T) {
t.Fatal("expected GitLabTaskMROptionsUpdated event to be published")
}
}

// TestControllerPatchTaskMRAutomation_ScopesToTheNamedMR covers the per-MR
// PATCH contract: a body carrying repository_id/project_path/mr_iid applies
// the switch to that MR alone.
func TestControllerPatchTaskMRAutomation_ScopesToTheNamedMR(t *testing.T) {
router, svc := newMRAutomationControllerFixture(t)
if err := svc.store.UpsertTaskMR(context.Background(), newTestMR("task-1", "", "group/b", 2)); err != nil {
t.Fatalf("seed second MR: %v", err)
}

body := `{"repository_id":"","project_path":"group/a","mr_iid":1,"auto_merge_enabled":true}`
req := httptest.NewRequest(http.MethodPatch, "/api/v1/gitlab/tasks/task-1/mr-automation", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("PATCH status = %d, body = %s", resp.Code, resp.Body.String())
}
var got TaskMRAutomationResponse
if err := json.Unmarshal(resp.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.MROptions) != 2 {
t.Fatalf("expected one mr_options entry per linked MR, got %+v", got.MROptions)
}
for _, opt := range got.MROptions {
wantEnabled := opt.ProjectPath == "group/a"
if opt.AutoMergeEnabled != wantEnabled {
t.Errorf("MR %s auto_merge_enabled = %v, want %v", opt.ProjectPath, opt.AutoMergeEnabled, wantEnabled)
}
}
}

// TestControllerPatchTaskMRAutomation_RejectsBadMRIdentity keeps a caller
// mistake a 400 rather than a 500 — and, for a partial identity, keeps it
// from being silently reinterpreted as "apply to every linked MR".
func TestControllerPatchTaskMRAutomation_RejectsBadMRIdentity(t *testing.T) {
router, _ := newMRAutomationControllerFixture(t)
for name, body := range map[string]string{
"unlinked MR": `{"repository_id":"","project_path":"group/nope","mr_iid":9,"auto_fix_enabled":true}`,
"partial identity": `{"project_path":"group/a","auto_fix_enabled":true}`,
"null identity": `{"repository_id":null,"project_path":null,"mr_iid":null,"auto_fix_enabled":true}`,
"identity only": `{"repository_id":"","project_path":"group/a","mr_iid":1}`,
} {
req := httptest.NewRequest(http.MethodPatch, "/api/v1/gitlab/tasks/task-1/mr-automation", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d, want 400 (body = %s)", name, resp.Code, resp.Body.String())
}
}
}

// TestControllerPatchTaskMRAutomation_RejectsSwitchesWithNoLinkedMRs is the
// HTTP half of the zero-target rule: because the switches only exist per MR,
// a task with none has nowhere to store them, and answering 200 would report
// a write that never happened. The task-level prompt override stays accepted.
func TestControllerPatchTaskMRAutomation_RejectsSwitchesWithNoLinkedMRs(t *testing.T) {
router, svc := newMRAutomationControllerFixture(t)
seedTask(t, svc.store, "task-2", "ws-1")

patch := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPatch, "/api/v1/gitlab/tasks/task-2/mr-automation", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
return resp
}

if resp := patch(`{"auto_merge_enabled":true}`); resp.Code != http.StatusBadRequest {
t.Errorf("switch patch status = %d, want 400 (body = %s)", resp.Code, resp.Body.String())
}
if resp := patch(`{"auto_fix_prompt_override":"custom"}`); resp.Code != http.StatusOK {
t.Errorf("prompt override status = %d, want 200 (body = %s)", resp.Code, resp.Body.String())
}
}
141 changes: 130 additions & 11 deletions apps/backend/internal/gitlab/models_mr_automation.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,87 @@ const (
// Mirrors github.TaskCIAutoFixMaxRounds.
const TaskMRAutoFixMaxRounds = 10

// TaskMRAutomationOptions stores task-level MR automation preferences:
// the three lifecycle notification switches from #2125, plus auto-fix CI
// and auto-merge (this task). Parallel to github.TaskCIOptions.
// MRIdentity names one merge request linked to a task. RepositoryID may be
// empty for single-repo tasks, matching gitlab_task_mrs' own key.
type MRIdentity struct {
RepositoryID string
ProjectPath string
MRIID int
}

// TaskMRAutomationOptions stores the genuinely task-level MR automation
// preferences: the auto-fix prompt override and the server-resolved reviewer
// username. Parallel to github.TaskCIOptions.
//
// The five switch fields below are legacy: they are no longer written by
// UpdateTaskMRAutomationOptions and are read only by the one-time
// mr_scope_migrated_at fan-out migration (migrateTaskMROptionsToMRScope).
// The per-MR source of truth is TaskMRAutomationOptionsForMR /
// gitlab_task_mr_automation_options.
type TaskMRAutomationOptions struct {
TaskID string `json:"task_id" db:"task_id"`
AutomationRevision int64 `json:"-" db:"automation_revision"`
AutoFixEnabled bool `json:"-" db:"auto_fix_enabled"`
AutoMergeEnabled bool `json:"-" db:"auto_merge_enabled"`
AutoFixPromptOverride *string `json:"auto_fix_prompt_override,omitempty" db:"auto_fix_prompt_override"`
PromptOnReviewRequested bool `json:"-" db:"prompt_on_review_requested"`
PromptOnMerged bool `json:"-" db:"prompt_on_merged"`
PromptOnClosed bool `json:"-" db:"prompt_on_closed"`
ReviewReviewerUsername string `json:"review_reviewer_username" db:"review_reviewer_username"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

// TaskMRAutomationOptionsForMR stores the five automation switches for one
// linked merge request. This is the per-MR source of truth; the aggregated
// booleans on TaskMRAutomationResponse only report "every linked MR has this
// switch on". Parallel to github.TaskPRAutomationOptions.
type TaskMRAutomationOptionsForMR struct {
TaskID string `json:"task_id" db:"task_id"`
RepositoryID string `json:"repository_id" db:"repository_id"`
ProjectPath string `json:"project_path" db:"project_path"`
MRIID int `json:"mr_iid" db:"mr_iid"`
AutoFixEnabled bool `json:"auto_fix_enabled" db:"auto_fix_enabled"`
AutoMergeEnabled bool `json:"auto_merge_enabled" db:"auto_merge_enabled"`
AutoFixPromptOverride *string `json:"auto_fix_prompt_override,omitempty" db:"auto_fix_prompt_override"`
PromptOnReviewRequested bool `json:"prompt_on_review_requested" db:"prompt_on_review_requested"`
PromptOnMerged bool `json:"prompt_on_merged" db:"prompt_on_merged"`
PromptOnClosed bool `json:"prompt_on_closed" db:"prompt_on_closed"`
ReviewReviewerUsername string `json:"review_reviewer_username" db:"review_reviewer_username"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

// Identity returns the MR this options row belongs to.
func (o *TaskMRAutomationOptionsForMR) Identity() MRIdentity {
return MRIdentity{RepositoryID: o.RepositoryID, ProjectPath: o.ProjectPath, MRIID: o.MRIID}
}

// TaskMRAutomationSwitchPatch is a partial update for one MR's automation
// switches.
type TaskMRAutomationSwitchPatch struct {
AutoFixEnabled *bool
AutoMergeEnabled *bool
PromptOnReviewRequested *bool
PromptOnMerged *bool
PromptOnClosed *bool
}

// HasAny reports whether the patch contains at least one requested field change.
func (p TaskMRAutomationSwitchPatch) HasAny() bool {
return p.AutoFixEnabled != nil || p.AutoMergeEnabled != nil ||
p.PromptOnReviewRequested != nil || p.PromptOnMerged != nil || p.PromptOnClosed != nil
}

// TaskMRAutomationPatch is a partial update for task MR automation options.
// ReviewReviewerUsername is intentionally absent — it is server-resolved from
// the workspace's authenticated GitLab user, never client-supplied.
// RepositoryID/ProjectPath/MRIID optionally target one linked MR for the five
// automation switches; when all three are nil the switches fan out to every
// MR currently linked to the task. AutoFixPromptOverride is always
// task-level. ReviewReviewerUsername is intentionally absent — it is
// server-resolved from the workspace's authenticated GitLab user, never
// client-supplied.
type TaskMRAutomationPatch struct {
RepositoryID *string
ProjectPath *string
MRIID *int
AutoFixEnabled *bool
AutoMergeEnabled *bool
AutoFixPromptOverride *string
Expand All @@ -46,16 +107,71 @@ type TaskMRAutomationPatch struct {
PromptOnClosed *bool
}

// HasAny reports whether the patch contains at least one requested field change.
// HasAny reports whether the patch contains at least one requested field
// change. MR identity alone is not a change — it only says which MR the
// (absent) switch changes would have applied to.
func (p TaskMRAutomationPatch) HasAny() bool {
return p.AutoFixEnabled != nil || p.AutoMergeEnabled != nil || p.AutoFixPromptOverride != nil ||
p.PromptOnReviewRequested != nil || p.PromptOnMerged != nil || p.PromptOnClosed != nil
return p.AutoFixPromptOverride != nil || p.SwitchPatch().HasAny()
}

// SwitchPatch extracts the per-MR automation switch fields.
func (p TaskMRAutomationPatch) SwitchPatch() TaskMRAutomationSwitchPatch {
return TaskMRAutomationSwitchPatch{
AutoFixEnabled: p.AutoFixEnabled,
AutoMergeEnabled: p.AutoMergeEnabled,
PromptOnReviewRequested: p.PromptOnReviewRequested,
PromptOnMerged: p.PromptOnMerged,
PromptOnClosed: p.PromptOnClosed,
}
}

// HasMRIdentity reports whether the patch names a specific MR.
func (p TaskMRAutomationPatch) HasMRIdentity() bool {
return p.RepositoryID != nil && p.ProjectPath != nil && p.MRIID != nil
}

// HasPartialMRIdentity reports whether the patch names some but not all of
// the three MR identity fields — a caller mistake that must be rejected
// rather than silently fanned out to every linked MR.
func (p TaskMRAutomationPatch) HasPartialMRIdentity() bool {
set := 0
if p.RepositoryID != nil {
set++
}
if p.ProjectPath != nil {
set++
}
if p.MRIID != nil {
set++
}
return set != 0 && set != 3
}

// MRIdentity returns the MR named by the patch. Only meaningful when
// HasMRIdentity reports true.
func (p TaskMRAutomationPatch) MRIdentity() MRIdentity {
id := MRIdentity{}
if p.RepositoryID != nil {
id.RepositoryID = *p.RepositoryID
}
if p.ProjectPath != nil {
id.ProjectPath = *p.ProjectPath
}
if p.MRIID != nil {
id.MRIID = *p.MRIID
}
return id
}

// TaskMRAutomationResponse is the HTTP/MCP shape for task MR automation
// options, including the per-MR lifecycle checkpoints for observability.
// options, including the per-MR switches, and the per-MR lifecycle
// checkpoints for observability. The five top-level switch booleans are an
// aggregate over MROptions ("every linked MR has this switch on, and at
// least one MR is linked") kept for MCP/API read compatibility; MROptions is
// the per-MR source of truth.
type TaskMRAutomationResponse struct {
TaskID string `json:"task_id"`
AutomationRevision int64 `json:"automation_revision"`
AutoFixEnabled bool `json:"auto_fix_enabled"`
AutoMergeEnabled bool `json:"auto_merge_enabled"`
AutoFixPromptOverride *string `json:"auto_fix_prompt_override"`
Expand All @@ -68,6 +184,9 @@ type TaskMRAutomationResponse struct {
ReviewReviewerUsername string `json:"review_reviewer_username"`
UpdatedAt time.Time `json:"updated_at"`
MRStates []*TaskMRLifecycleState `json:"mr_states"`
// MROptions carries one entry per MR currently linked to the task, so
// the UI can render each MR's own switches instead of the aggregate.
MROptions []*TaskMRAutomationOptionsForMR `json:"mr_options"`
// WorkspaceID is internal routing metadata (best-effort resolved, may be
// empty) that lets the websocket broadcaster scope the
// gitlab.task_mr_options.updated event to the owning workspace instead of
Expand Down
Loading
Loading