utility-scripts/gitlab-job-status
Marcel Müller caade47b4c Add first script
Signed-off-by: Marcel Müller <neikos@neikos.email>
2026-01-02 21:31:47 +01:00

484 lines
14 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -euo pipefail
# Configuration
POLL_INTERVAL="${POLL_INTERVAL:-10}"
GITLAB_TOKEN="${GITLAB_TOKEN:-}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
DIM='\033[2m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# Status symbols
SYM_SUCCESS="✓"
SYM_FAILED="✗"
SYM_RUNNING="●"
SYM_PENDING="○"
SYM_SKIPPED="⊘"
SYM_CANCELED="⊗"
SYM_MANUAL="▶"
log_info() { echo -e "${BLUE}${NC} $*"; }
log_success() { echo -e "${GREEN}${NC} $*"; }
log_error() { echo -e "${RED}${NC} $*" >&2; }
# Track how many lines we printed for the job display
DISPLAY_LINES=0
# Extract GitLab host and project path from git remote
parse_git_remote() {
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null) || {
log_error "Failed to get git remote 'origin'"
exit 1
}
local gitlab_host project_path
if [[ "$remote_url" =~ ^https?://([^/]+)/(.+?)(\.git)?$ ]]; then
# https://gitlab.example.com/group/project.git
gitlab_host="${BASH_REMATCH[1]}"
project_path="${BASH_REMATCH[2]}"
elif [[ "$remote_url" =~ ^ssh://[^@]+@([^:/]+)(:[0-9]+)?/(.+?)(\.git)?$ ]]; then
# ssh://git@gitlab.example.com:8080/group/project.git
# Port is for SSH, not the HTTPS API, so we discard it
gitlab_host="${BASH_REMATCH[1]}"
project_path="${BASH_REMATCH[3]}"
elif [[ "$remote_url" =~ ^git@([^:]+):(.+?)(\.git)?$ ]]; then
# git@gitlab.example.com:group/project.git
gitlab_host="${BASH_REMATCH[1]}"
project_path="${BASH_REMATCH[2]}"
else
log_error "Could not parse git remote URL: $remote_url"
exit 1
fi
# Remove trailing .git if present
project_path="${project_path%.git}"
echo "$gitlab_host"
echo "$project_path"
}
# URL-encode a string
urlencode() {
jq -rn --arg s "$1" '$s | @uri'
}
# Make GitLab API request
gitlab_api() {
local endpoint="$1"
local args=(-s -f)
if [[ -n "$GITLAB_TOKEN" ]]; then
args+=(-H "PRIVATE-TOKEN: $GITLAB_TOKEN")
fi
curl "${args[@]}" "$endpoint"
}
# Get current branch name
get_current_branch() {
git symbolic-ref --short HEAD 2>/dev/null || {
log_error "Not on a branch (detached HEAD?)"
exit 1
}
}
# Find MR for the current branch
find_mr() {
local api_base="$1"
local project_encoded="$2"
local branch="$3"
local response
response=$(gitlab_api "${api_base}/projects/${project_encoded}/merge_requests?source_branch=${branch}&state=opened") || {
log_error "Failed to fetch merge requests. Do you need to set GITLAB_TOKEN?"
exit 1
}
local mr_count
mr_count=$(echo "$response" | jq 'length')
if [[ "$mr_count" -eq 0 ]]; then
log_error "No open MR found for branch '$branch'"
exit 1
fi
echo "$response" | jq '.[0]'
}
# Get pipeline status for an MR
get_pipeline() {
local api_base="$1"
local project_encoded="$2"
local mr_iid="$3"
local response
response=$(gitlab_api "${api_base}/projects/${project_encoded}/merge_requests/${mr_iid}/pipelines") || {
log_error "Failed to fetch pipelines"
exit 1
}
# Get the most recent pipeline
echo "$response" | jq '.[0] // empty'
}
# Get detailed pipeline info
get_pipeline_details() {
local api_base="$1"
local project_encoded="$2"
local pipeline_id="$3"
gitlab_api "${api_base}/projects/${project_encoded}/pipelines/${pipeline_id}"
}
# Get jobs for a pipeline
get_pipeline_jobs() {
local api_base="$1"
local project_encoded="$2"
local pipeline_id="$3"
gitlab_api "${api_base}/projects/${project_encoded}/pipelines/${pipeline_id}/jobs?per_page=100"
}
# Check if pipeline is still running
is_pipeline_running() {
local status="$1"
[[ "$status" == "running" || "$status" == "pending" || "$status" == "created" || "$status" == "waiting_for_resource" || "$status" == "preparing" ]]
}
# Print failed jobs
print_failed_jobs() {
local gitlab_host="$1"
local project_path="$2"
local jobs_json="$3"
local failed_jobs
failed_jobs=$(echo "$jobs_json" | jq -r '.[] | select(.status == "failed")')
if [[ -z "$failed_jobs" ]]; then
return
fi
echo
log_error "Failed jobs:"
echo "$jobs_json" | jq -r '.[] | select(.status == "failed") | " - \(.name) (stage: \(.stage))"'
echo
echo "Job links:"
echo "$jobs_json" | jq -r --arg host "$gitlab_host" --arg project "$project_path" \
'.[] | select(.status == "failed") | " \(.name): https://\($host)/\($project)/-/jobs/\(.id)"'
}
# Get status symbol and color for a job status
get_status_display() {
local status="$1"
case "$status" in
success)
echo -e "${GREEN}${SYM_SUCCESS}${NC}"
;;
failed)
echo -e "${RED}${SYM_FAILED}${NC}"
;;
running)
echo -e "${YELLOW}${SYM_RUNNING}${NC}"
;;
pending|waiting_for_resource|created)
echo -e "${DIM}${SYM_PENDING}${NC}"
;;
skipped)
echo -e "${DIM}${SYM_SKIPPED}${NC}"
;;
canceled)
echo -e "${RED}${SYM_CANCELED}${NC}"
;;
manual)
echo -e "${CYAN}${SYM_MANUAL}${NC}"
;;
preparing)
echo -e "${BLUE}${SYM_RUNNING}${NC}"
;;
*)
echo -e "${DIM}?${NC}"
;;
esac
}
# Get color for job name based on status
get_name_color() {
local status="$1"
case "$status" in
success)
echo -e "${GREEN}"
;;
failed)
echo -e "${RED}"
;;
running|preparing)
echo -e "${YELLOW}${BOLD}"
;;
pending|waiting_for_resource|created)
echo -e "${DIM}"
;;
skipped|canceled)
echo -e "${DIM}"
;;
manual)
echo -e "${CYAN}"
;;
*)
echo -e "${NC}"
;;
esac
}
# Display jobs grouped by stage
display_jobs() {
local jobs_json="$1"
local pipeline_status="$2"
local gitlab_host="$3"
local project_path="$4"
local lines=()
# Get stages in the order they appear (preserving pipeline order, not alphabetical)
# Reverse so earliest stages appear at top
local stages
stages=$(echo "$jobs_json" | jq -r '
reduce .[].stage as $s ([]; if IN(.[]; $s) then . else . + [$s] end) | reverse | .[]
')
while IFS= read -r stage; do
[[ -z "$stage" ]] && continue
# Determine stage status based on its jobs
local stage_status
stage_status=$(echo "$jobs_json" | jq -r --arg stage "$stage" '
[.[] | select(.stage == $stage) | .status] |
if any(. == "running" or . == "preparing") then "running"
elif any(. == "failed") then "failed"
elif any(. == "pending" or . == "created" or . == "waiting_for_resource") then "pending"
elif all(. == "success") then "success"
elif all(. == "skipped" or . == "success") then "success"
else "other"
end
')
local stage_color
case "$stage_status" in
success) stage_color="${GREEN}" ;;
failed) stage_color="${RED}" ;;
running) stage_color="${YELLOW}" ;;
*) stage_color="${DIM}" ;;
esac
lines+=("${stage_color}${BOLD}━━━ ${stage} ━━━${NC}")
# Get jobs for this stage
local stage_jobs
stage_jobs=$(echo "$jobs_json" | jq -c --arg stage "$stage" '[.[] | select(.stage == $stage)] | sort_by(.name)')
local job_count
job_count=$(echo "$stage_jobs" | jq 'length')
for ((j = 0; j < job_count; j++)); do
local job
job=$(echo "$stage_jobs" | jq -c ".[$j]")
local job_name job_status job_id
job_name=$(echo "$job" | jq -r '.name')
job_status=$(echo "$job" | jq -r '.status')
job_id=$(echo "$job" | jq -r '.id')
local job_url="https://${gitlab_host}/${project_path}/-/jobs/${job_id}"
local status_sym name_color
status_sym=$(get_status_display "$job_status")
name_color=$(get_name_color "$job_status")
# OSC 8 hyperlink: \e]8;;URL\aTEXT\e]8;;\a
local linked_name="\e]8;;${job_url}\a${name_color}${job_name}${NC}\e]8;;\a"
local duration_str=""
if [[ "$job_status" == "success" || "$job_status" == "failed" ]]; then
local duration
duration=$(echo "$job" | jq -r '.duration // 0 | floor')
if [[ "$duration" -gt 0 ]]; then
local mins=$((duration / 60))
local secs=$((duration % 60))
if [[ $mins -gt 0 ]]; then
duration_str=" ${DIM}(${mins}m ${secs}s)${NC}"
else
duration_str=" ${DIM}(${secs}s)${NC}"
fi
fi
elif [[ "$job_status" == "running" ]]; then
local started_at
started_at=$(echo "$job" | jq -r '.started_at // empty')
if [[ -n "$started_at" ]]; then
local start_epoch now_epoch elapsed
start_epoch=$(date -d "$started_at" +%s 2>/dev/null) || start_epoch=""
if [[ -n "$start_epoch" ]]; then
now_epoch=$(date +%s)
elapsed=$((now_epoch - start_epoch))
local mins=$((elapsed / 60))
local secs=$((elapsed % 60))
if [[ $mins -gt 0 ]]; then
duration_str=" ${DIM}(${mins}m ${secs}s)${NC}"
else
duration_str=" ${DIM}(${secs}s)${NC}"
fi
else
duration_str=" ${DIM}(running...)${NC}"
fi
else
duration_str=" ${DIM}(running...)${NC}"
fi
fi
lines+=(" ${status_sym} ${linked_name}${duration_str}")
done
lines+=("") # Empty line between stages
done <<< "$stages"
# Add pipeline status line
local status_line
case "$pipeline_status" in
running|pending|created|waiting_for_resource|preparing)
status_line="${YELLOW}⏳ Pipeline ${pipeline_status}...${NC}"
;;
success)
status_line="${GREEN}✓ Pipeline succeeded${NC}"
;;
failed)
status_line="${RED}✗ Pipeline failed${NC}"
;;
*)
status_line="Pipeline: ${pipeline_status}"
;;
esac
lines+=("$status_line")
# Build complete output string
local output=""
local new_line_count=0
for line in "${lines[@]}"; do
output+="$line"$'\n'
new_line_count=$((new_line_count + 1))
done
# Atomic clear and redraw: cursor up + clear to end + new content
local clear_seq=""
if [[ $DISPLAY_LINES -gt 0 ]]; then
clear_seq="$(tput cuu "$DISPLAY_LINES")$(tput ed)"
fi
echo -en "${clear_seq}${output}"
DISPLAY_LINES=$new_line_count
}
main() {
log_info "Parsing git remote..."
local remote_info
remote_info=$(parse_git_remote)
local gitlab_host project_path
gitlab_host=$(echo "$remote_info" | sed -n '1p')
project_path=$(echo "$remote_info" | sed -n '2p')
local api_base="https://${gitlab_host}/api/v4"
local project_encoded
project_encoded=$(urlencode "$project_path")
log_info "GitLab: ${gitlab_host}"
log_info "Project: ${project_path}"
local branch
branch=$(get_current_branch)
log_info "Branch: ${branch}"
log_info "Finding merge request..."
local mr
mr=$(find_mr "$api_base" "$project_encoded" "$branch")
local mr_iid mr_title mr_url
mr_iid=$(echo "$mr" | jq -r '.iid')
mr_title=$(echo "$mr" | jq -r '.title')
mr_url=$(echo "$mr" | jq -r '.web_url')
log_success "Found MR !${mr_iid}: ${mr_title}"
log_info "URL: ${mr_url}"
log_info "Checking pipeline status..."
local pipeline
pipeline=$(get_pipeline "$api_base" "$project_encoded" "$mr_iid")
if [[ -z "$pipeline" || "$pipeline" == "null" ]]; then
log_error "No pipeline found for this MR"
exit 1
fi
local pipeline_id pipeline_status
pipeline_id=$(echo "$pipeline" | jq -r '.id')
pipeline_status=$(echo "$pipeline" | jq -r '.status')
log_info "Pipeline #${pipeline_id} - Status: ${pipeline_status}"
echo
# Initial job display
local jobs
jobs=$(get_pipeline_jobs "$api_base" "$project_encoded" "$pipeline_id")
display_jobs "$jobs" "$pipeline_status" "$gitlab_host" "$project_path"
# Poll until pipeline completes
while is_pipeline_running "$pipeline_status"; do
sleep "$POLL_INTERVAL"
local pipeline_details
pipeline_details=$(get_pipeline_details "$api_base" "$project_encoded" "$pipeline_id")
pipeline_status=$(echo "$pipeline_details" | jq -r '.status')
jobs=$(get_pipeline_jobs "$api_base" "$project_encoded" "$pipeline_id")
display_jobs "$jobs" "$pipeline_status" "$gitlab_host" "$project_path"
done
echo # New line after the status updates
# Get final pipeline info
local pipeline_details pipeline_url
pipeline_details=$(get_pipeline_details "$api_base" "$project_encoded" "$pipeline_id")
pipeline_url=$(echo "$pipeline_details" | jq -r '.web_url')
echo
log_info "Pipeline URL: ${pipeline_url}"
# Report final status
case "$pipeline_status" in
success)
exit 0
;;
failed)
print_failed_jobs "$gitlab_host" "$project_path" "$jobs"
exit 1
;;
canceled)
exit 2
;;
skipped)
exit 0
;;
*)
exit 3
;;
esac
}
main "$@"