Add first script

Signed-off-by: Marcel Müller <neikos@neikos.email>
This commit is contained in:
Marcel Müller 2026-01-02 21:31:47 +01:00
commit caade47b4c
4 changed files with 543 additions and 0 deletions

484
gitlab-job-status Executable file
View file

@ -0,0 +1,484 @@
#!/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 "$@"