|
#!/bin/bash |
|
# |
|
# cci - CircleCI CLI wrapper for common API operations |
|
# |
|
# Environment variables: |
|
# CIRCLECI_PROJECT_SLUG Project slug (e.g., gh/myorg/myrepo) |
|
# CIRCLECI_TOKEN_FILE Path to CircleCI config file (default: ~/.circleci/cli.yml) |
|
# |
|
# Setup: Install CircleCI CLI and run 'circleci setup' to create the token file. |
|
# See: https://circleci.com/docs/local-cli/ |
|
# |
|
# Usage: |
|
# cci pipelines <branch> List recent pipelines for a branch |
|
# cci workflows <pipeline-id> List workflows for a pipeline |
|
# cci jobs <workflow-id> List jobs for a workflow |
|
# cci job <job-number> Get job details (status, parallel runs, web URL) |
|
# cci tests <job-number> Get failed tests for a job |
|
# cci steps <job-number> List all steps for a job with status and duration |
|
# cci logs <job-number> <step-index> Get logs for a specific step |
|
# cci pipeline-jobs <pipeline-id> [--status <status>] [--summary] |
|
# cci approve <workflow-id> <job-id> Approve a pending approval job |
|
# cci auto-approve [branch] Find and approve pending job for branch (default: current git branch) |
|
# List all jobs across all workflows in a pipeline |
|
# |
|
|
|
set -e |
|
|
|
# Configuration - can be overridden via environment variables |
|
PROJECT_SLUG="${CIRCLECI_PROJECT_SLUG:-gh/owner/repo}" |
|
TOKEN_FILE="${CIRCLECI_TOKEN_FILE:-$HOME/.circleci/cli.yml}" |
|
|
|
# Convert gh/owner/repo to github/owner/repo for v1 API |
|
PROJECT_PATH_V1="${PROJECT_SLUG/gh\//github/}" |
|
|
|
# Colors for output |
|
RED='\033[0;31m' |
|
GREEN='\033[0;32m' |
|
YELLOW='\033[1;33m' |
|
BLUE='\033[0;34m' |
|
NC='\033[0m' # No Color |
|
|
|
get_token() { |
|
if [[ ! -f "$TOKEN_FILE" ]]; then |
|
echo -e "${RED}Error: CircleCI config not found at $TOKEN_FILE${NC}" >&2 |
|
exit 1 |
|
fi |
|
grep token "$TOKEN_FILE" | awk '{print $2}' |
|
} |
|
|
|
api_call() { |
|
local method="${1:-GET}" |
|
local endpoint="$2" |
|
local token |
|
token=$(get_token) |
|
|
|
if [[ "$method" == "POST" ]]; then |
|
curl -s -X POST -H "Circle-Token: $token" "https://circleci.com/api/v2$endpoint" |
|
else |
|
curl -s -H "Circle-Token: $token" "https://circleci.com/api/v2$endpoint" |
|
fi |
|
} |
|
|
|
api_call_v1() { |
|
local method="${1:-GET}" |
|
local endpoint="$2" |
|
local token |
|
token=$(get_token) |
|
|
|
curl -s -H "Circle-Token: $token" "https://circleci.com/api/v1.1$endpoint" |
|
} |
|
|
|
cmd_pipelines() { |
|
local branch="$1" |
|
if [[ -z "$branch" ]]; then |
|
echo -e "${RED}Usage: cci pipelines <branch>${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
echo -e "${BLUE}Fetching pipelines for branch: $branch${NC}" >&2 |
|
api_call GET "/project/$PROJECT_SLUG/pipeline?branch=$branch" | jq '.items[:5] | .[] | {id, number, state, created_at: .created_at}' |
|
} |
|
|
|
cmd_workflows() { |
|
local pipeline_id="$1" |
|
if [[ -z "$pipeline_id" ]]; then |
|
echo -e "${RED}Usage: cci workflows <pipeline-id>${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
echo -e "${BLUE}Fetching workflows for pipeline: $pipeline_id${NC}" >&2 |
|
api_call GET "/pipeline/$pipeline_id/workflow" | jq '.items[] | {id, name, status}' |
|
} |
|
|
|
cmd_jobs() { |
|
local workflow_id="$1" |
|
if [[ -z "$workflow_id" ]]; then |
|
echo -e "${RED}Usage: cci jobs <workflow-id>${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
echo -e "${BLUE}Fetching jobs for workflow: $workflow_id${NC}" >&2 |
|
api_call GET "/workflow/$workflow_id/job" | jq '.items[] | {job_number, name, status, type, approval_request_id}' |
|
} |
|
|
|
cmd_job() { |
|
local job_number="$1" |
|
if [[ -z "$job_number" ]]; then |
|
echo -e "${RED}Usage: cci job <job-number>${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
echo -e "${BLUE}Fetching job details: $job_number${NC}" >&2 |
|
local job_data |
|
job_data=$(api_call GET "/project/$PROJECT_SLUG/job/$job_number") |
|
|
|
# Show basic info |
|
echo "$job_data" | jq '{name, status, duration: (.duration / 1000 | floor | "\(. / 60 | floor)m \(. % 60)s"), web_url}' |
|
|
|
# Show failed parallel runs if any |
|
local failed_runs |
|
failed_runs=$(echo "$job_data" | jq '[.parallel_runs[] | select(.status == "failed") | .index]') |
|
if [[ "$failed_runs" != "[]" ]]; then |
|
echo -e "${RED}Failed parallel runs: $failed_runs${NC}" >&2 |
|
fi |
|
} |
|
|
|
cmd_tests() { |
|
local job_number="$1" |
|
if [[ -z "$job_number" ]]; then |
|
echo -e "${RED}Usage: cci tests <job-number>${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
echo -e "${BLUE}Fetching failed tests for job: $job_number${NC}" >&2 |
|
api_call GET "/project/$PROJECT_SLUG/$job_number/tests" | jq '[.items[] | select(.result == "failure")] | if length == 0 then "No failed tests" else .[] | {file, name, message: (.message | split("\n")[0])} end' |
|
} |
|
|
|
cmd_steps() { |
|
local job_number="$1" |
|
if [[ -z "$job_number" ]]; then |
|
echo -e "${RED}Usage: cci steps <job-number>${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
echo -e "${BLUE}Fetching steps for job: $job_number${NC}" >&2 |
|
api_call_v1 GET "/project/$PROJECT_PATH_V1/$job_number" | jq '.steps | to_entries | .[] | {index: .key, name: .value.name, status: .value.actions[0].status, duration: ((.value.actions[0].run_time_millis // 0) / 1000 | floor | "\(. / 60 | floor)m \(. % 60)s")}' |
|
} |
|
|
|
cmd_logs() { |
|
local job_number="$1" |
|
local step_index="$2" |
|
if [[ -z "$job_number" || -z "$step_index" ]]; then |
|
echo -e "${RED}Usage: cci logs <job-number> <step-index>${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
echo -e "${BLUE}Fetching logs for job $job_number, step $step_index${NC}" >&2 |
|
local output_url |
|
output_url=$(api_call_v1 GET "/project/$PROJECT_PATH_V1/$job_number" | jq -r ".steps[$step_index].actions[0].output_url") |
|
|
|
if [[ -z "$output_url" || "$output_url" == "null" ]]; then |
|
echo -e "${RED}No output URL found for step $step_index${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
curl -s "$output_url" | jq -r '.[].message' |
|
} |
|
|
|
cmd_pipeline_jobs() { |
|
local pipeline_id="" |
|
local filter_status="" |
|
local show_summary=false |
|
local valid_statuses="success|failed|running|blocked|canceled|on_hold|not_run|infrastructure_fail|timedout|queued" |
|
|
|
# Parse arguments |
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
--status) |
|
if [[ -z "$2" ]]; then |
|
echo -e "${RED}Error: --status requires a value${NC}" >&2 |
|
exit 1 |
|
fi |
|
filter_status="$2" |
|
shift 2 |
|
;; |
|
--summary) |
|
show_summary=true |
|
shift |
|
;; |
|
*) |
|
if [[ -z "$pipeline_id" ]]; then |
|
pipeline_id="$1" |
|
fi |
|
shift |
|
;; |
|
esac |
|
done |
|
|
|
if [[ -z "$pipeline_id" ]]; then |
|
echo -e "${RED}Usage: cci pipeline-jobs <pipeline-id> [--status <status>] [--summary]${NC}" >&2 |
|
echo -e "${YELLOW}Valid statuses: success, failed, running, blocked, canceled, on_hold, not_run, infrastructure_fail, timedout, queued${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
# Validate status if provided |
|
if [[ -n "$filter_status" && ! "$filter_status" =~ ^($valid_statuses)$ ]]; then |
|
echo -e "${RED}Error: Invalid status '$filter_status'${NC}" >&2 |
|
echo -e "${YELLOW}Valid statuses: success, failed, running, blocked, canceled, on_hold, not_run, infrastructure_fail, timedout, queued${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
# Summary mode |
|
if [[ "$show_summary" == true ]]; then |
|
echo -e "${BLUE}Fetching summary for pipeline: $pipeline_id${NC}" >&2 |
|
|
|
# Get pipeline info for timing |
|
local pipeline_info |
|
pipeline_info=$(api_call GET "/pipeline/$pipeline_id") |
|
local pipeline_number created_at |
|
pipeline_number=$(echo "$pipeline_info" | jq -r '.number') |
|
created_at=$(echo "$pipeline_info" | jq -r '.created_at') |
|
|
|
# Calculate duration |
|
local created_ts now_ts duration_secs |
|
created_ts=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${created_at%%.*}" "+%s" 2>/dev/null || date -d "${created_at}" "+%s" 2>/dev/null) |
|
now_ts=$(date "+%s") |
|
duration_secs=$((now_ts - created_ts)) |
|
local duration_min=$((duration_secs / 60)) |
|
local duration_sec=$((duration_secs % 60)) |
|
|
|
# Collect all jobs |
|
local all_jobs="" |
|
local workflows |
|
workflows=$(api_call GET "/pipeline/$pipeline_id/workflow" | jq -c '.items[] | {id, name, status}') |
|
|
|
while read -r workflow; do |
|
[[ -z "$workflow" ]] && continue |
|
local workflow_id |
|
workflow_id=$(echo "$workflow" | jq -r '.id') |
|
local jobs |
|
jobs=$(api_call GET "/workflow/$workflow_id/job" | jq -c '.items[]') |
|
all_jobs+="$jobs"$'\n' |
|
done <<< "$workflows" |
|
|
|
# Count by status using jq for reliability |
|
local total success failed running blocked canceled on_hold other |
|
total=$(echo "$all_jobs" | jq -s 'length') |
|
success=$(echo "$all_jobs" | jq -s '[.[] | select(.status == "success")] | length') |
|
failed=$(echo "$all_jobs" | jq -s '[.[] | select(.status == "failed")] | length') |
|
running=$(echo "$all_jobs" | jq -s '[.[] | select(.status == "running")] | length') |
|
blocked=$(echo "$all_jobs" | jq -s '[.[] | select(.status == "blocked")] | length') |
|
canceled=$(echo "$all_jobs" | jq -s '[.[] | select(.status == "canceled")] | length') |
|
on_hold=$(echo "$all_jobs" | jq -s '[.[] | select(.status == "on_hold")] | length') |
|
other=$((total - success - failed - running - blocked - canceled - on_hold)) |
|
|
|
# Display summary |
|
echo "" |
|
echo -e "Pipeline #${pipeline_number}" |
|
echo -e "Started: ${created_at}" |
|
echo -e "Duration: ${duration_min}m ${duration_sec}s" |
|
echo "" |
|
echo -e "Jobs: ${total} total" |
|
[[ $success -gt 0 ]] && echo -e " ${GREEN}✓ success: ${success}${NC}" |
|
[[ $failed -gt 0 ]] && echo -e " ${RED}✗ failed: ${failed}${NC}" |
|
[[ $running -gt 0 ]] && echo -e " ${BLUE}● running: ${running}${NC}" |
|
[[ $blocked -gt 0 ]] && echo -e " ${YELLOW}◌ blocked: ${blocked}${NC}" |
|
[[ $canceled -gt 0 ]] && echo -e " ○ canceled: ${canceled}" |
|
[[ $on_hold -gt 0 ]] && echo -e " ${YELLOW}⏸ on_hold: ${on_hold}${NC}" |
|
[[ $other -gt 0 ]] && echo -e " ? other: ${other}" |
|
echo "" |
|
return |
|
fi |
|
|
|
echo -e "${BLUE}Fetching all jobs for pipeline: $pipeline_id${NC}" >&2 |
|
if [[ -n "$filter_status" ]]; then |
|
echo -e "${BLUE}Filtering by status: $filter_status${NC}" >&2 |
|
fi |
|
|
|
# Get all workflows for the pipeline |
|
local workflows |
|
workflows=$(api_call GET "/pipeline/$pipeline_id/workflow" | jq -c '.items[] | {id, name, status}') |
|
|
|
# For each workflow, get jobs and add workflow info |
|
echo "$workflows" | while read -r workflow; do |
|
local workflow_id workflow_name workflow_status |
|
workflow_id=$(echo "$workflow" | jq -r '.id') |
|
workflow_name=$(echo "$workflow" | jq -r '.name') |
|
workflow_status=$(echo "$workflow" | jq -r '.status') |
|
|
|
# Get jobs for this workflow and add workflow info, optionally filter by status |
|
if [[ -n "$filter_status" ]]; then |
|
api_call GET "/workflow/$workflow_id/job" | jq --arg wid "$workflow_id" --arg wname "$workflow_name" --arg wstatus "$workflow_status" --arg fstatus "$filter_status" \ |
|
'.items[] | select(.status == $fstatus) | {workflow_id: $wid, workflow_name: $wname, workflow_status: $wstatus, job_number, name, status, type}' |
|
else |
|
api_call GET "/workflow/$workflow_id/job" | jq --arg wid "$workflow_id" --arg wname "$workflow_name" --arg wstatus "$workflow_status" \ |
|
'.items[] | {workflow_id: $wid, workflow_name: $wname, workflow_status: $wstatus, job_number, name, status, type}' |
|
fi |
|
done |
|
} |
|
|
|
cmd_approve() { |
|
local workflow_id="$1" |
|
local approval_request_id="$2" |
|
|
|
if [[ -z "$workflow_id" || -z "$approval_request_id" ]]; then |
|
echo -e "${RED}Usage: cci approve <workflow-id> <approval-request-id>${NC}" >&2 |
|
exit 1 |
|
fi |
|
|
|
echo -e "${YELLOW}Approving job...${NC}" >&2 |
|
local result |
|
result=$(api_call POST "/workflow/$workflow_id/approve/$approval_request_id") |
|
|
|
if echo "$result" | jq -e '.message' > /dev/null 2>&1; then |
|
echo -e "${GREEN}Approved successfully${NC}" >&2 |
|
echo "$result" | jq '.' |
|
else |
|
echo -e "${RED}Failed to approve${NC}" >&2 |
|
echo "$result" | jq '.' |
|
exit 1 |
|
fi |
|
} |
|
|
|
cmd_auto_approve() { |
|
local branch="$1" |
|
|
|
# Default to current git branch if not provided |
|
if [[ -z "$branch" ]]; then |
|
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") |
|
if [[ -z "$branch" ]]; then |
|
echo -e "${RED}Error: No branch provided and not in a git repository${NC}" >&2 |
|
exit 1 |
|
fi |
|
echo -e "${BLUE}Using current branch: $branch${NC}" >&2 |
|
fi |
|
|
|
# Get latest pipeline |
|
echo -e "${BLUE}Finding latest pipeline for: $branch${NC}" >&2 |
|
local pipeline_id |
|
pipeline_id=$(api_call GET "/project/$PROJECT_SLUG/pipeline?branch=$branch" | jq -r '.items[0].id // empty') |
|
|
|
if [[ -z "$pipeline_id" ]]; then |
|
echo -e "${RED}No pipeline found for branch: $branch${NC}" >&2 |
|
exit 1 |
|
fi |
|
echo -e "${GREEN}Found pipeline: $pipeline_id${NC}" >&2 |
|
|
|
# Get workflows |
|
echo -e "${BLUE}Finding workflows...${NC}" >&2 |
|
local workflows |
|
workflows=$(api_call GET "/pipeline/$pipeline_id/workflow") |
|
|
|
# Find workflow with on_hold status (has pending approval) |
|
local workflow_id |
|
workflow_id=$(echo "$workflows" | jq -r '.items[] | select(.status == "on_hold") | .id' | head -1) |
|
|
|
if [[ -z "$workflow_id" ]]; then |
|
# Check if there's a running workflow |
|
local running |
|
running=$(echo "$workflows" | jq -r '.items[] | select(.status == "running") | .id' | head -1) |
|
if [[ -n "$running" ]]; then |
|
echo -e "${YELLOW}Workflow is already running (no approval needed)${NC}" >&2 |
|
echo "$workflows" | jq '.items[] | {id, name, status}' |
|
exit 0 |
|
fi |
|
|
|
echo -e "${YELLOW}No workflow with pending approval found${NC}" >&2 |
|
echo "$workflows" | jq '.items[] | {id, name, status}' |
|
exit 0 |
|
fi |
|
echo -e "${GREEN}Found workflow with pending approval: $workflow_id${NC}" >&2 |
|
|
|
# Get jobs and find approval job |
|
echo -e "${BLUE}Finding approval job...${NC}" >&2 |
|
local jobs |
|
jobs=$(api_call GET "/workflow/$workflow_id/job") |
|
|
|
local approval_info |
|
approval_info=$(echo "$jobs" | jq -r '.items[] | select(.type == "approval" and .status == "on_hold") | "\(.name)|\(.approval_request_id)"' | head -1) |
|
|
|
if [[ -z "$approval_info" ]]; then |
|
echo -e "${YELLOW}No pending approval job found${NC}" >&2 |
|
echo "$jobs" | jq '.items[] | {name, status, type}' |
|
exit 0 |
|
fi |
|
|
|
local job_name approval_request_id |
|
job_name=$(echo "$approval_info" | cut -d'|' -f1) |
|
approval_request_id=$(echo "$approval_info" | cut -d'|' -f2) |
|
|
|
echo -e "${GREEN}Found approval job: $job_name ($approval_request_id)${NC}" >&2 |
|
|
|
# Approve |
|
cmd_approve "$workflow_id" "$approval_request_id" |
|
|
|
echo -e "${GREEN}Done! CI pipeline should now be running.${NC}" >&2 |
|
} |
|
|
|
cmd_help() { |
|
cat << 'EOF' |
|
cci - CircleCI CLI wrapper for common API operations |
|
|
|
Environment variables: |
|
CIRCLECI_PROJECT_SLUG Project slug (default: gh/owner/repo) |
|
Example: gh/myorg/myrepo |
|
CIRCLECI_TOKEN_FILE Path to CircleCI config with token |
|
(default: ~/.circleci/cli.yml) |
|
|
|
Setup: |
|
Install CircleCI CLI and run 'circleci setup' to create the token file. |
|
See: https://circleci.com/docs/local-cli/ |
|
|
|
Usage: |
|
cci pipelines <branch> List recent pipelines for a branch |
|
cci workflows <pipeline-id> List workflows for a pipeline |
|
cci jobs <workflow-id> List jobs for a workflow |
|
cci job <job-number> Get job details (status, parallel runs, web URL) |
|
cci tests <job-number> Get failed tests for a job |
|
cci steps <job-number> List all steps for a job with status and duration |
|
cci logs <job-number> <step-index> Get logs for a specific step |
|
cci pipeline-jobs <pipeline-id> [--status <status>] [--summary] |
|
List all jobs across all workflows in a pipeline |
|
--status: filter by status (success, failed, running, |
|
blocked, canceled, on_hold, not_run, infrastructure_fail, |
|
timedout, queued) |
|
--summary: show counts by status and timing info |
|
cci approve <workflow-id> <job-id> Approve a pending approval job |
|
cci auto-approve [branch] Find and approve pending job for branch |
|
(defaults to current git branch) |
|
|
|
Examples: |
|
cci pipelines sc-12345/my-feature |
|
cci workflows 3186a68f-7417-4dd2-81d6-95fec5ce2ba7 |
|
cci jobs cd3ca155-27e0-4ab9-af62-6c407bcfbe60 |
|
cci job 1110581 |
|
cci tests 1110581 |
|
cci steps 1110581 # List all steps for job |
|
cci logs 1110581 5 # Get logs for step index 5 |
|
cci pipeline-jobs 3186a68f-... # List all jobs in pipeline |
|
cci pipeline-jobs 3186a68f-... --status failed # Only failed jobs |
|
cci pipeline-jobs 3186a68f-... --summary # Show summary with counts |
|
cci approve cd3ca155-27e0-4ab9-af62-6c407bcfbe60 687c2d58-eaf9-480c-b812-c8b09c57c40b |
|
cci auto-approve # Uses current git branch |
|
cci auto-approve sc-12345/feature # Specify branch explicitly |
|
EOF |
|
} |
|
|
|
# Main command dispatcher |
|
case "${1:-}" in |
|
pipelines) |
|
cmd_pipelines "$2" |
|
;; |
|
workflows) |
|
cmd_workflows "$2" |
|
;; |
|
jobs) |
|
cmd_jobs "$2" |
|
;; |
|
job) |
|
cmd_job "$2" |
|
;; |
|
tests) |
|
cmd_tests "$2" |
|
;; |
|
steps) |
|
cmd_steps "$2" |
|
;; |
|
logs) |
|
cmd_logs "$2" "$3" |
|
;; |
|
pipeline-jobs) |
|
shift |
|
cmd_pipeline_jobs "$@" |
|
;; |
|
approve) |
|
cmd_approve "$2" "$3" |
|
;; |
|
auto-approve) |
|
cmd_auto_approve "$2" |
|
;; |
|
help|--help|-h) |
|
cmd_help |
|
;; |
|
*) |
|
cmd_help |
|
exit 1 |
|
;; |
|
esac |