id: bitbucket-pipelines-failure-sentinel
namespace: company.team
description: |
Every hour, read recent pipelines of a Bitbucket Cloud repository from the
Pipelines API (read-only: no pipeline is run, stopped or re-run) and
classify CI as HEALTHY, WARNING, CRITICAL, LOW_VOLUME or UNREACHABLE. Log
quiet runs and alert Slack when builds start failing or get stuck, with the
branch that fails most.
inputs:
- id: workspace
type: STRING
defaults: "my-workspace"
displayName: "Bitbucket Workspace"
description: "Workspace ID (slug) that owns the repository, as shown in its URL."
- id: repo_slug
type: STRING
defaults: "my-repo"
displayName: "Repository Slug"
description: "Repository slug, as shown in its URL."
- id: lookback_hours
type: INT
defaults: 24
displayName: "Lookback Window (hours)"
description: "Judge pipelines created in the last this many hours."
- id: warning_failure_rate
type: FLOAT
defaults: 15.0
displayName: "Warning Failure Rate (%)"
description: "At or above this share of FAILED or ERROR pipelines among
completed ones, the check is WARNING."
- id: critical_failure_rate
type: FLOAT
defaults: 40.0
displayName: "Critical Failure Rate (%)"
description: "At or above this share of FAILED or ERROR pipelines among
completed ones, the check is CRITICAL."
- id: min_pipelines
type: INT
defaults: 5
displayName: "Minimum Pipelines"
description: "Below this many completed pipelines, the rate is too noisy to
judge and the run is LOW_VOLUME (stuck pipelines still alert)."
- id: stuck_minutes
type: INT
defaults: 90
displayName: "Stuck After (minutes)"
description: "A pipeline still PENDING or IN_PROGRESS this long after it was
created counts as stuck."
triggers:
- id: hourly_check
type: io.kestra.plugin.core.trigger.Schedule
description: Check CI health every hour. Shipped disabled; enable it once the
Bitbucket secrets are set and a manual run has been verified.
cron: "20 * * * *"
disabled: true
tasks:
- id: list_pipelines
type: io.kestra.plugin.core.http.Request
description: Read the 100 most recent pipelines of the repository from the
Bitbucket Cloud Pipelines API, newest first. This is a GET; no pipeline is
run, stopped or changed.
uri: "https://api.bitbucket.org/2.0/repositories/{{ inputs.workspace }}/{{
inputs.repo_slug }}/pipelines?sort=-created_on&pagelen=100"
method: GET
options:
auth:
type: BASIC
username: "{{ secret('BITBUCKET_USERNAME') }}"
password: "{{ secret('BITBUCKET_API_TOKEN') }}"
allowFailed: true
timeout:
connectTimeout: PT10S
readIdleTimeout: PT60S
retry:
type: constant
interval: PT10S
maxAttempts: 3
allowFailure: true
- id: summarize_pipelines
type: io.kestra.plugin.core.debug.Return
description: Keep pipelines created in the lookback window, count completed ones
by result, compute the failure share (FAILED plus ERROR), find the branch
that fails most and pipelines stuck in PENDING or IN_PROGRESS, and
classify in one jq query. A missing or non-200 response becomes
UNREACHABLE.
format: >-
{{ (outputs.list_pipelines.code is not defined or
outputs.list_pipelines.code != 200) ?
'{"status":"UNREACHABLE","completed":0,"successful":0,"failed":0,"stopped":0,"rate":0,"stuck":0,"running":0,"top_branch":"none","top_branch_failures":0,"build_minutes":0}'
: (fromJson(outputs.list_pipelines.body) | jq(' [(.values // [])[] |
select(.created_on >= "' ~ (now() | dateAdd(-inputs.lookback_hours,
'HOURS') | date("yyyy-MM-dd'T'HH:mm:ss", timeZone='UTC')) ~ '")] as $p |
[$p[] | select(.state.name == "COMPLETED")] as $done | ($done | length) as
$n | ([$done[] | select(.state.result.name == "FAILED" or
.state.result.name == "ERROR")]) as $bad | ($bad | length) as $nbad | (if
$n == 0 then 0 else (($nbad * 1000 / $n) | floor) / 10 end) as $r | ([$p[]
| select((.state.name == "PENDING" or .state.name == "IN_PROGRESS") and
.created_on < "' ~ (now() | dateAdd(-inputs.stuck_minutes, 'MINUTES') |
date("yyyy-MM-dd'T'HH:mm:ss", timeZone='UTC')) ~ '")] | length) as $stuck
| ([$bad[] | (.target.ref_name // "unknown") | tostring | split("\"") |
join("") | .[0:60]] | group_by(.) | map({branch: .[0], count: length}) |
sort_by(-.count)) as $branches | {completed: $n, successful: ([$done[] |
select(.state.result.name == "SUCCESSFUL")] | length), failed: $nbad,
stopped: ([$done[] | select(.state.result.name == "STOPPED" or
.state.result.name == "EXPIRED")] | length), rate: $r, stuck: $stuck,
running: ([$p[] | select(.state.name == "PENDING" or .state.name ==
"IN_PROGRESS")] | length), top_branch: (if ($branches | length) > 0 then
$branches[0].branch else "none" end), top_branch_failures: (if ($branches
| length) > 0 then $branches[0].count else 0 end), build_minutes:
(([$done[] | (.build_seconds_used // 0)] | add // 0) / 60 | floor),
status: (if $n >= ' ~ inputs.min_pipelines ~ ' and $r >= ' ~
inputs.critical_failure_rate ~ ' then "CRITICAL" elif $stuck > 0 or ($n >=
' ~ inputs.min_pipelines ~ ' and $r >= ' ~ inputs.warning_failure_rate ~
') then "WARNING" elif $n < ' ~ inputs.min_pipelines ~ ' then "LOW_VOLUME"
else "HEALTHY" end)} ') | first | toJson) }}
- id: route_by_failures
type: io.kestra.plugin.core.flow.Switch
description: Route to a quiet log when CI is healthy or volume is too low to
judge, a Slack message when failures cross the warning line or a pipeline
is stuck, or a Slack page when failures cross the critical line or
Bitbucket could not be read.
value: "{{ fromJson(outputs.summarize_pipelines.value).status }}"
cases:
"HEALTHY":
- id: log_healthy
type: io.kestra.plugin.core.log.Log
description: Log a quiet line when CI is healthy; no Slack message is sent.
message: "Bitbucket CI healthy for {{ inputs.workspace }}/{{ inputs.repo_slug
}}: {{ fromJson(outputs.summarize_pipelines.value).failed }} of {{
fromJson(outputs.summarize_pipelines.value).completed }} completed
pipelines failed ({{
fromJson(outputs.summarize_pipelines.value).rate }}%) in the last {{
inputs.lookback_hours }} hour(s); {{
fromJson(outputs.summarize_pipelines.value).build_minutes }} build
minutes used."
"LOW_VOLUME":
- id: log_low_volume
type: io.kestra.plugin.core.log.Log
description: Log a quiet line when too few pipelines completed in the window to
judge; no Slack message is sent.
message: "Bitbucket CI for {{ inputs.workspace }}/{{ inputs.repo_slug }}: only
{{ fromJson(outputs.summarize_pipelines.value).completed }}
completed pipeline(s) in the last {{ inputs.lookback_hours }}
hour(s), below the minimum of {{ inputs.min_pipelines }}; rate not
judged this run."
"WARNING":
- id: alert_warning_ci
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post a Slack message when the failure rate is at or above the
warning line or a pipeline is stuck.
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{
"text": "Bitbucket CI WARNING for {{ inputs.workspace }}/{{ inputs.repo_slug }}: {{ fromJson(outputs.summarize_pipelines.value).failed }} of {{ fromJson(outputs.summarize_pipelines.value).completed }} completed pipelines failed ({{ fromJson(outputs.summarize_pipelines.value).rate }}%) in the last {{ inputs.lookback_hours }} hour(s); {{ fromJson(outputs.summarize_pipelines.value).stuck }} stuck for over {{ inputs.stuck_minutes }} min. Most failures on branch {{ fromJson(outputs.summarize_pipelines.value).top_branch }} (x{{ fromJson(outputs.summarize_pipelines.value).top_branch_failures }}). Execution {{ execution.id }}."
}
"CRITICAL":
- id: alert_critical_ci
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Page Slack when the failure rate is at or above the critical line,
which usually means a broken main branch, an expired credential or a
runner problem.
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{
"text": "Bitbucket CI CRITICAL for {{ inputs.workspace }}/{{ inputs.repo_slug }}: {{ fromJson(outputs.summarize_pipelines.value).failed }} of {{ fromJson(outputs.summarize_pipelines.value).completed }} completed pipelines failed ({{ fromJson(outputs.summarize_pipelines.value).rate }}%) in the last {{ inputs.lookback_hours }} hour(s). Most failures on branch {{ fromJson(outputs.summarize_pipelines.value).top_branch }} (x{{ fromJson(outputs.summarize_pipelines.value).top_branch_failures }}). Merges are likely blocked; check the latest failing pipeline now. Execution {{ execution.id }}."
}
"UNREACHABLE":
- id: alert_unreachable_api
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Page Slack when the Pipelines API could not be read, indicating an
outage, a wrong workspace or repository, or invalid credentials.
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{
"text": "Bitbucket CI sentinel UNREACHABLE for {{ inputs.workspace }}/{{ inputs.repo_slug }}: the Pipelines API did not return a usable response (HTTP {{ outputs.list_pipelines.code ?? 'no response' }}). Check the username, API token and repository. Execution {{ execution.id }}."
}
defaults:
- id: log_unclassified
type: io.kestra.plugin.core.log.Log
description: Fallback for any classification the cases above do not cover, so an
unexpected value is visible in the logs.
message: "Bitbucket CI sentinel completed with an unexpected classification: {{
outputs.summarize_pipelines.value }}."
- id: log_audit_result
type: io.kestra.plugin.core.log.Log
description: Record one audit line for every run, whichever branch fired, so the
CI history stays queryable even if Slack delivery fails.
message: "Bitbucket CI sentinel audit: repo={{ inputs.workspace }}/{{
inputs.repo_slug }} lookback_hours={{ inputs.lookback_hours }} http={{
outputs.list_pipelines.code ?? 'n/a' }} summary={{
outputs.summarize_pipelines.value }} execution={{ execution.id }}."
errors:
- id: alert_flow_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert Slack when the sentinel flow itself fails, so a broken check
is never mistaken for healthy CI.
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{
"text": "Bitbucket CI sentinel flow {{ flow.id }} FAILED (execution {{ execution.id }}). CI health is now unknown; check the task logs and the repository's Pipelines page."
}
outputs:
- id: status
type: STRING
value: "{{ fromJson(outputs.summarize_pipelines.value).status }}"