Re-evaluate approval policies after an agentic analyzer scan
What does this MR do and why?
- Approval policies are re-evaluated when a recognized agentic-analyzer workload pipeline (a
duo_workflowworkload pipeline with a succeeded SAST scan) finishes, not only when the CI pipeline does. - Only open MRs whose head is the scanned sha are re-evaluated, each against its own head pipeline, through the existing
SyncMergeRequestApprovalsWorker. MRs whose head pipeline is still running are skipped, as in the CI path. - Gated by
agentic_analyzer_security_ingestion; other pipelines behave as before. - Generic to agentic analyzers: nothing here is specific to one flow. Business logic security scanning is the first one.
Part of the BLSA split of !246889. Tracker: https://gitlab.com/gitlab-org/gitlab/-/work_items/630266
Before / after
| Situation | Before | After |
|---|---|---|
| Agentic analyzer scan finishes after the CI pipeline (the usual case) | Findings are not evaluated until something else re-runs the sync, so they rarely block the merge | The MR's approval rules are re-evaluated with the scan's findings |
| Agentic analyzer scan finishes while the CI pipeline is still running | Evaluated when the CI pipeline completes | Unchanged in effect: the workload pipeline skips the MR (unless the head pipeline already has security findings) and policies are re-evaluated when the CI pipeline completes, which picks up the scan's findings |
| Flag off, other pipeline sources, or MRs at other shas | No sync from the workload pipeline | Unchanged: no sync |
How to review
Security::AgenticAnalyzer.scan_pipeline?recognizes the workload pipeline with the same lookup !257196 (merged) uses to add it to policy evaluation (scan_pipeline_ids_for), so the sync only fires when the evaluation will actually see the scan.Security::AgenticAnalyzer.opened_merge_requests_atfinds open MRs whose latest diff head is the scanned sha (driven frommerge_request_diffs.head_commit_shawithANY(ARRAY(...)), newest first by id, limited to 20; see Database queries).SyncFindingsToApprovalRulesService#sync_scan_findingenqueuesSyncMergeRequestApprovalsWorkerwith each MR'sdiff_head_pipeline, before the existing CI-source guard. It skips MRs with no head pipeline, and MRs whose head pipeline is notcomplete_or_manual?and has no security findings (the same guard the CI path applies). It is reached from the existingProcessPipelineCompletionWorkeronCi::PipelineFinishedEvent/Security::ReportsIngestedEvent.
Backward compatibility
- Flag-gated by
agentic_analyzer_security_ingestion. With it off,scan_pipeline?returns false with no queries and the service behaves exactly as before. - No change for CI, security orchestration or any other pipeline: the new branch only matches a recognized agentic-analyzer workload pipeline (a
duo_workflowworkload pipeline with a succeeded SAST scan). - No new worker, schema or evaluation path.
Database queries
Security::AgenticAnalyzer.opened_merge_requests_at finds the open MRs whose latest diff head is the scanned sha. It runs when an agentic analyzer workload pipeline with a succeeded scan completes.
SQL from to_sql on this MR's code (Rails test env, project.id = 278964):
SELECT "merge_requests".* FROM "merge_requests"
WHERE "merge_requests"."target_project_id" = 278964
AND "merge_requests"."state_id" = 1
AND (merge_requests.latest_merge_request_diff_id = ANY(ARRAY(
SELECT "merge_request_diffs"."id" FROM "merge_request_diffs"
WHERE "merge_request_diffs"."head_commit_sha" = '<scanned sha>'
)))
ORDER BY "merge_requests"."id" DESC
LIMIT 20The preloads (head_pipeline, latest_merge_request_diff) are primary-key lookups for the at most 20 MRs returned.
Indexes used
index_on_merge_request_diffs_head_commit_sha(head_commit_sha): collects the diff ids at the sha.index_merge_requests_on_latest_merge_request_diff_id(latest_merge_request_diff_id): finds the MRs whose latest diff is one of them. The project and state filters then apply to those few rows.
Why ANY(ARRAY(...)): it collects the diff ids first, so the query always starts from the sha index, never by walking merge_requests by id and checking diffs per row (the slow case when nothing matches). MergeRequest.by_commit_sha is moving to the same form behind mr_by_commit_sha_use_array_subquery.
Timings (Database Lab, gitlab-production-main, project 278964, 2026-10-02, postgresai joe explain). Cold is the first run, warm the run right after. The match sha is the head of an open gitlab-org/gitlab MR (1 row); the no-match sha is made up.
| No match | Match | |
|---|---|---|
| Cold | 18.6 ms (execution 8.8 ms) | 28.6 ms (execution 18.5 ms) |
| Warm | 10.1 ms (execution 0.10 ms) | 11.4 ms (execution 0.14 ms) |
Planning is about 10 ms in every run; the plan shape is the same in all four. The CLI returned no shareable plan link; Joe command ids 508480, 508482, 508484, 508486.
Plan: match, cold
Limit (cost=51.72..51.73 rows=1 width=1043) (actual time=18.401..18.403 rows=1 loops=1)
Buffers: shared hit=5 read=9 dirtied=2
WAL: records=3 fpi=2 bytes=14668
I/O Timings: read=18.217 write=0.000
InitPlan 1
-> Index Scan using index_on_merge_request_diffs_head_commit_sha on public.merge_request_diffs (cost=0.70..20.41 rows=12 width=8) (actual time=12.383..12.385 rows=1 loops=1)
Index Cond: ((merge_request_diffs.head_commit_sha)::text = 'e172ab259c5782fbb1520074181ddaaffea375e7'::text)
Buffers: shared hit=1 read=5 dirtied=1
WAL: records=1 fpi=1 bytes=7601
I/O Timings: read=12.306 write=0.000
-> Sort (cost=31.31..31.32 rows=1 width=1043) (actual time=18.399..18.400 rows=1 loops=1)
Sort Key: merge_requests.id DESC
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=5 read=9 dirtied=2
WAL: records=3 fpi=2 bytes=14668
I/O Timings: read=18.217 write=0.000
-> Index Scan using index_merge_requests_on_latest_merge_request_diff_id on public.merge_requests (cost=0.57..31.30 rows=1 width=1043) (actual time=18.379..18.380 rows=1 loops=1)
Index Cond: (merge_requests.latest_merge_request_diff_id = ANY ((InitPlan 1).col1))
Filter: ((merge_requests.target_project_id = 278964) AND (merge_requests.state_id = 1))
Buffers: shared hit=2 read=9 dirtied=2
WAL: records=3 fpi=2 bytes=14668
I/O Timings: read=18.217 write=0.000Plan: no match, cold
Limit (cost=51.72..51.73 rows=1 width=1043) (actual time=8.741..8.743 rows=0 loops=1)
Buffers: shared hit=4 read=4
I/O Timings: read=8.660 write=0.000
InitPlan 1
-> Index Scan using index_on_merge_request_diffs_head_commit_sha on public.merge_request_diffs (cost=0.70..20.41 rows=12 width=8) (actual time=8.715..8.715 rows=0 loops=1)
Index Cond: ((merge_request_diffs.head_commit_sha)::text = 'b34603c94075c20dd4d3a1a61b9b42294495a426'::text)
Buffers: shared hit=1 read=4
I/O Timings: read=8.660 write=0.000
-> Sort (cost=31.31..31.32 rows=1 width=1043) (actual time=8.740..8.741 rows=0 loops=1)
Sort Key: merge_requests.id DESC
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=4 read=4
I/O Timings: read=8.660 write=0.000
-> Index Scan using index_merge_requests_on_latest_merge_request_diff_id on public.merge_requests (cost=0.57..31.30 rows=1 width=1043) (actual time=8.726..8.726 rows=0 loops=1)
Index Cond: (merge_requests.latest_merge_request_diff_id = ANY ((InitPlan 1).col1))
Filter: ((merge_requests.target_project_id = 278964) AND (merge_requests.state_id = 1))
Buffers: shared hit=1 read=4
I/O Timings: read=8.660 write=0.000Design decisions
- Reuse the MR's head pipeline. The existing worker and
UpdateApprovalsServicedo the evaluation; !257196 (merged) already pulls the workload pipeline's findings in throughRelatedPipelines. No new evaluation path. - Hook into the existing completion path (
SyncFindingsToApprovalRulesService) instead of theCi::Pipelinestate machine, so it runs after the scans are stored and inherits the policy-availability and license checks. - Recognition requires a succeeded scan, which matches what the evaluation includes and makes the trigger a no-op for workload pipelines without one.
- Idempotent and cheap: the sync worker is idempotent and deduplicated; the MR lookup is bounded (20) and restricted to opened MRs.
Known limitations
- An MR with no head pipeline at the scanned sha is skipped. It is evaluated when its CI pipeline completes.
- More than 20 open MRs at one sha: only the 20 newest (highest id) are re-evaluated.
- Forked merge requests: the lookup uses the workload pipeline's project as the target, so fork MRs aren't re-evaluated from this path. Business logic scans don't run for fork MRs today; any future analyzer that does relies on the CI-completion sync.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.