From caade47b4ca0d1008efb01a8a9ff245875e0da5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Fri, 2 Jan 2026 21:31:47 +0100 Subject: [PATCH] Add first script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcel Müller --- .gitignore | 1 + flake.lock | 26 +++ flake.nix | 32 +++ gitlab-job-status | 484 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 543 insertions(+) create mode 100644 .gitignore create mode 100644 flake.lock create mode 100644 flake.nix create mode 100755 gitlab-job-status diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2be92b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +result diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3fcda22 --- /dev/null +++ b/flake.lock @@ -0,0 +1,26 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1767273430, + "narHash": "sha256-kDpoFwQ8GLrPiS3KL+sAwreXrph2KhdXuJzo5+vSLoo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "76eec3925eb9bbe193934987d3285473dbcfad50", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixpkgs-unstable", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5d261e5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,32 @@ +{ + inputs = { + nixpkgs.url = "nixpkgs/nixpkgs-unstable"; + }; + + outputs = + inputs: + let + forAllSystems = + f: + inputs.nixpkgs.lib.genAttrs [ + "x86_64-linux" + "aarch64-linux" + ] (system: f (import inputs.nixpkgs { inherit system; })); + in + { + packages = forAllSystems (pkgs: { + gitlab-job-status = pkgs.writeShellApplication { + name = "gitlab-job-status"; + runtimeInputs = [ + pkgs.curl + pkgs.jq + pkgs.git + pkgs.ncurses + pkgs.coreutils + ]; + + text = builtins.readFile ./gitlab-job-status; + }; + }); + }; +} diff --git a/gitlab-job-status b/gitlab-job-status new file mode 100755 index 0000000..71945db --- /dev/null +++ b/gitlab-job-status @@ -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 "$@"