#!/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 "$@"