id: gitlab-merge-request-approval-gate
namespace: company.devops
inputs:
- id: gitlab_project_path
type: STRING
displayName: GitLab Project Full Path
defaults: "gitlab-org/gitlab"
description: Full path of the GitLab project (e.g., `group/subgroup/project` or
`org/repo`).
- id: target_branch
type: STRING
displayName: Target Branch
defaults: "master"
description: Target base branch to enforce code review and approval rules on.
- id: mr_limit
type: INT
displayName: Merge Request Inspection Limit
defaults: 25
description: Maximum number of open merge requests to evaluate in a single audit pass.
- id: max_unassigned_mrs
type: INT
displayName: Maximum Tolerable Unreviewed MRs
defaults: 0
description: Maximum allowable open merge requests without reviewers or
approvals before triggering an alert.
triggers:
- id: weekday_review_governance_schedule
type: io.kestra.plugin.core.trigger.Schedule
description: Audits GitLab merge request review assignments twice daily on
weekdays at 10:00 and 15:00 UTC. Shipped disabled by default.
cron: "0 10,15 * * 1-5"
disabled: true
tasks:
- id: fetch_open_merge_requests
type: io.kestra.plugin.graphql.Request
description: Query GitLab GraphQL API for active merge requests, approval
status, and reviewer assignments.
uri: "https://gitlab.com/api/graphql"
headers:
Authorization: "Bearer {{ secret('GITLAB_PRIVATE_TOKEN') }}"
Content-Type: "application/json"
User-Agent: "Kestra-GitLab-MR-Gate/1.0"
failOnGraphQLErrors: false
query: |
query ($projectPath: ID!, $limit: Int!) {
project(fullPath: $projectPath) {
mergeRequests(state: opened, first: $limit) {
nodes {
iid
title
webUrl
draft
targetBranch
createdAt
approved
approvalsRequired
approvalsLeft
author {
username
name
}
reviewers {
nodes {
username
}
}
}
}
}
}
variables:
projectPath: "{{ inputs.gitlab_project_path }}"
limit: "{{ inputs.mr_limit }}"
- id: audit_mr_approvals
type: io.kestra.plugin.scripts.python.Script
description: Identify merge requests lacking assigned reviewers or pending
critical approvals before merge.
containerImage: python:3.11-slim
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
env:
RESPONSE_BODY: "{{ outputs.fetch_open_merge_requests.body }}"
TARGET_BRANCH: "{{ inputs.target_branch }}"
PROJECT_PATH: "{{ inputs.gitlab_project_path }}"
script: |
import os
import json
from kestra import Kestra
raw_data = os.environ.get("RESPONSE_BODY", "{}")
target_branch = os.environ.get("TARGET_BRANCH", "main")
project_path = os.environ.get("PROJECT_PATH", "")
try:
data = json.loads(raw_data)
except Exception:
data = {}
mr_nodes = data.get("data", {}).get("project", {}).get("mergeRequests", {}).get("nodes", [])
stalled_mrs = []
for mr in mr_nodes:
if mr.get("draft"):
continue
if target_branch and mr.get("targetBranch") != target_branch:
continue
reviewers = mr.get("reviewers", {}).get("nodes", [])
approvals_left = mr.get("approvalsLeft", 0)
is_approved = mr.get("approved", False)
if len(reviewers) == 0 or (approvals_left and approvals_left > 0 and not is_approved):
stalled_mrs.append({
"iid": mr.get("iid"),
"title": mr.get("title"),
"url": mr.get("webUrl"),
"author": mr.get("author", {}).get("username", "unknown"),
"reviewers_count": len(reviewers),
"approvals_left": approvals_left
})
summary = f"Project {project_path} has {len(stalled_mrs)} open merge request(s) targeting '{target_branch}' needing reviewer assignment or approvals."
mr_lines = [f"• [!{m['iid']}]({m['url']}) {m['title']} (by @{m['author']}) - Reviewers: {m['reviewers_count']}, Approvals needed: {m['approvals_left']}" for m in stalled_mrs[:5]]
Kestra.outputs({
"pending_count": len(stalled_mrs),
"stalled_mrs": stalled_mrs,
"summary": summary,
"details_formatted": "\n".join(mr_lines)
})
- id: check_review_compliance
type: io.kestra.plugin.core.flow.If
description: Evaluate if count of unreviewed merge requests exceeds allowable threshold.
condition: "{{ outputs.audit_mr_approvals.vars.pending_count >
inputs.max_unassigned_mrs }}"
then:
- id: notify_slack_mr_review_alert
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post unassigned and unapproved merge request digest to engineering
team channel.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"channel": "#dev-code-reviews",
"username": "Kestra GitLab Governance Gate",
"icon_emoji": ":mag:",
"attachments": [
{
"color": "#e24329",
"title": "GitLab Merge Requests Awaiting Review & Approvals",
"title_link": "https://gitlab.com/{{ inputs.gitlab_project_path }}/-/merge_requests",
"text": "{{ outputs.audit_mr_approvals.vars.summary }}",
"fields": [
{
"title": "Project",
"value": "`{{ inputs.gitlab_project_path }}`",
"short": true
},
{
"title": "Target Branch",
"value": "`{{ inputs.target_branch }}`",
"short": true
},
{
"title": "Pending MR Count",
"value": "{{ outputs.audit_mr_approvals.vars.pending_count }}",
"short": true
},
{
"title": "Unreviewed Merge Requests",
"value": "{{ outputs.audit_mr_approvals.vars.details_formatted }}",
"short": false
}
],
"footer": "Kestra GitLab Review Gate",
"ts": "{{ now() | date('X') }}"
}
]
}
errors:
- id: send_failure_alert
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Dispatch notification if GitLab GraphQL API audit encounters
authentication or network failure.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"channel": "#devops-alerts",
"username": "Kestra Sentinel Watchdog",
"icon_emoji": ":rotating_light:",
"attachments": [
{
"color": "#dc3545",
"title": "GitLab MR Approval Gate Execution Failed",
"text": "Execution *{{ execution.id }}* failed while evaluating merge request approvals for project *{{ inputs.gitlab_project_path }}*.",
"fields": [
{
"title": "Flow ID",
"value": "{{ flow.id }}",
"short": true
},
{
"title": "Namespace",
"value": "{{ flow.namespace }}",
"short": true
}
],
"footer": "Kestra Error Sentinel",
"ts": "{{ now() | date('X') }}"
}
]
}