Add merge_request created Duo Agent Platform trigger
Adds a created action to the shared merge_request Duo Agent Platform flow-trigger event type, so a flow can run automatically on a new merge request, for example a baseline Duo Code Review or a context-enrichment agent.
It fires at the mergeability check rather than at record creation, because that is the first point where the diff is built and code-owner approval rules are synced against it. Behind the merge_request_create_flow_trigger feature flag, default off.
Detailed context for AI agents
How it works
Follows the shared merge_request trigger pattern (same as the existing approved action):
- New event
MergeRequests::AfterCreateCloudEvent, published once the MR's diff is built and its code-owner approval rules are synced against that diff (see decision below). Ai::Catalog::Flows::ExecuteMergeRequestCreatedWorkflowTriggersWorkersubscribes to it and runs triggers for the user-facingcreatedaction. The subscription is guarded byAi::FlowTrigger.registered_for?(…, :merge_request), so the worker is only scheduled for projects that have configured amerge_requesttrigger.createdis registered inAi::FlowTrigger::ALLOWED_FILTER_ACTIONSformerge_request(alongsideapproved).- Frontend:
createdis a new action under the shared "Merge request" trigger type (MERGE_REQUEST_ACTION_CREATED), alongside the existingapproved,ready, andcode_conflictactions.
Design decision: when does it fire?
Consumers require both a built diff and code-owner approval rules synced against it, so the event fires once both hold, not at raw record creation.
The code-owner sync that CreateService enqueues cannot be used: it runs concurrently with AfterCreateService and can execute before the diff exists, leaving rules that do not match the diff.
The create flow already re-syncs code owners against the built diff, in ReloadMergeHeadDiffService via the mergeability check AfterCreateService schedules. So this MR adds no sync of its own, it hooks the existing one: MergeRequests::AfterCreateEventPublisher marks the merge request as AfterCreateService schedules the mergeability check, and the same publisher publishes from that check once it has resolved the merge request.
There is one publish point and no branch. An earlier revision published inline from AfterCreateService for projects without the code_owners licensed feature, and deferred only for licensed ones, with EE overriding a defer? predicate. That was collapsed during maintainer review: every project schedules a mergeability check on create regardless, so deferring unconditionally costs unlicensed projects a few hundred milliseconds and buys one code path, no EE override, and an event that means the same thing everywhere. This MR now touches no EE application code at all.
Deferring unconditionally is safe because check_state? (app/models/merge_request.rb:325) excludes :preparing. While a merge request is preparing, nothing else can start a mergeability check, so the create path schedules exactly one. In the rare interleaving where a push moves the state first, that push's own check consumes the token.
Why the one-shot token stays
The mergeability check is not the tail end of create. It is a reconciler that also runs on page loads, pushes and target-branch updates, and the create path only kicks it once. A reconciler has no notion of a "first run" unless something marks it, which is what the token does. That is not a property of AfterCreateService's shape, so restructuring after-create does not remove it: whoever completes the code-owner sync has to be the one to publish, and that happens in a different process from the decision to wait.
Alternatives considered and rejected during review (!242698 (comment 3737974044)):
- Delay
prepared_atuntil the async preparation steps finish.NewMergeRequestWorkerreturns early whenprepared?is true (app/workers/new_merge_request_worker.rb:26) and that is the real retry guard, sincededuplicate :until_executeddoes not cover Sidekiq retries. Moving it means a retried job re-runs all ofAfterCreateService.prepared_atis also insafe_hook_attributes, so the create webhook would start sendingprepared_at: null. The documentation mismatch this exposes is filed separately: #622540 - A further "after after create" worker that runs pipeline creation and the mergeability check synchronously, so the event can fire at the end of it without a token. Those steps were made async deliberately to get them off the create path (the mergeability check in
76e5f067c857, the diffs cache in #417973 (closed)), and this re-couples them, serialises work that currently runs concurrently, and merges their failure domains, for every merge request creation.
This mirrors ReviewerAssignment::PendingInitialAssignment, which defers reviewer auto-assignment to the same checkpoint for the same reason, with its own one-shot flag. The two flags are deliberately separate, as they have different enablement conditions, and no shared mixin was extracted: PendingInitialAssignment is expected to go away once reviewer assignment moves onto this event in #603494, which would leave the mixin with a single user. Longer term, "code-owner rules are settled for this merge request" wants to be a signal that MergeRequests::ApprovalRulesSyncCoordinator owns and both consumers subscribe to, rather than something each feature hand-rolls.
Ordering matters in both directions:
- The token is written before the mergeability check is scheduled. The check can reach its publish point in a few hundred milliseconds, well before the rest of the after-create work finishes, and would otherwise find nothing to publish. Observed happening naturally on GDK.
- The token is consumed immediately after
update_merge_status, the one point every outcome of the check passes through, rather than next to the code-owner sync, which is only reached when the MR is mergeable and the merge-head-diff reload succeeds. So a merge request created with conflicts still publishes (unlike reviewer auto-assignment), and a failed reload publishes rather than stranding the event.
The token's one-hour TTL only bounds the leak when the mergeability check never runs at all (for example, the job is lost). Firing late beats silently never firing. Because update_merge_status runs on every mergeability check, and therefore on every push, publish_deferred returns early when prepared_at is older than that TTL: the token cannot still be live, so the Redis DEL would be guaranteed to find nothing. prepared_at is nil on the first pass if the check beats the rest of the create, which is exactly when the token may be live, so the guard cannot strand the event.
Naming: AfterCreateCloudEvent, wire type created
- Class / publisher →
AfterCreate*: names the phase of the lifecycle the event belongs to without claiming an instant.createdwas rejected for the class because the event does not fire when the record is created, andpreparedwas rejected becauseMergeRequests::MergeRequestPreparedEventalready exists and becauseprepared_atdoes not mean what its own documentation claims (#622540). - Wire
event_type, user-facing action, and the trigger worker →created:com.gitlab.merge_requests.created, matching what users configure and the stored filter value (no data migration). The worker and its Sidekiq queue are named for the action rather than the event, so what an operator greps matches what a user configured.
Discussed in !242698 (comment 3624136244) and settled in !242698 (comment 3737974044).
Feature flag
Behind merge_request_create_flow_trigger (gitlab_com_derisk, default off), rollout issue #618743. Mirrors the sibling merge_request_merged_flow_trigger.
An earlier revision shipped straight to GA, on the grounds that triggers are a low-risk category and that gating each of the many planned triggers would stagger availability. That was reversed during maintainer review, because Sidekiq compatibility across updates requires it: ExecuteMergeRequestCreatedWorkflowTriggersWorker is a brand new worker schedulable from an HTTP request, and GitLab.com has no Sidekiq canary stage, so it could be enqueued from canary several hours before the Sidekiq fleet can run it. The registered_for? subscription guard does not help, because it matches on the event type rather than the action, so any project with an existing approved, ready or code_conflict trigger would enqueue the new worker on every merge request create.
The gate lives in MergeRequests::AfterCreateEventPublisher rather than inside the worker, so that with the flag off the event is never published: no worker is scheduled and no Redis keys are written. The created action is also hidden in the UI through push_frontend_feature_flag.
Documentation
The Created action is documented in doc/user/duo_agent_platform/triggers/_index.md, with version-history entries also added to doc/user/duo_agent_platform/agents/external.md and doc/user/duo_agent_platform/flows/custom.md. The trigger docs state that code owner approval rules are synced before the flow runs, with a note covering the cases where they are not: a merge request on a merge train, or a merge head diff that cannot be reloaded. Both publish anyway rather than strand the event.
Verification steps
Prerequisites: an EE project with Duo Agent Platform flows available, an active service account, and a flow (catalog item consumer) configured.
- In the project, go to Automate → Triggers and create a trigger:
- Event type: Merge request
- Action: Created
- Create a new merge request in the project (draft or non-draft both fire).
- Confirm the flow starts:
AfterCreateEventPublisherpublishesMergeRequests::AfterCreateCloudEventfrom the mergeability check once it resolves, thenAi::Catalog::Flows::ExecuteMergeRequestCreatedWorkflowTriggersWorkerruns andAi::FlowTriggers::RunServicestarts the configured flow. - Negative check: in a project with no
merge_requesttrigger configured, the worker is not scheduled (theregistered_for?subscription guard skips it). - Negative check: pushing to the MR afterwards does not re-fire the trigger (the one-shot token was consumed).
- Conflict check: create a merge request with conflicts, and the trigger still fires, published when the check resolves it as unmergeable.
Specs: spec/services/merge_requests/after_create_event_publisher_spec.rb, spec/services/merge_requests/after_create_service_spec.rb, spec/services/merge_requests/mergeability_check_service_spec.rb, spec/events/merge_requests/after_create_cloud_event_spec.rb, ee/spec/workers/ai/catalog/flows/execute_merge_request_created_workflow_triggers_worker_spec.rb. The CE chain was also re-run under FOSS_ONLY=1, since the publisher, the event and the mergeability check are all CE and only the subscriber is EE.
Manual verification on GDK covered: a normal merge request, a conflicting one, a draft, a project with the flag off, ten concurrent requests racing mergeability checks on one merge request, and a second push to an existing merge request. The event fired exactly once where it should, never with the flag off, and left no Redis keys behind.
Related to: #592452