diff --git a/bin/actions-status b/bin/actions-status new file mode 100755 index 0000000..e9eebfd --- /dev/null +++ b/bin/actions-status @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +####################################### +# Check GitHub Actions status across workspace repos and output JSON. +# +# Usage: actions-status [options] +# +# Options: +# -h, --help Show this help message +# -v, --verbose Enable verbose output +# +# Environment Variables: +# ACTIONS_STATUS_CONFIG Path to repos configuration file (default: bin/repos.yaml) +# +# Exit Codes: +# 0 Success +# 1 General error (missing dependency, file not found, etc.) +# 2 Invalid arguments +# +# Dependencies: +# gh - GitHub CLI (https://cli.github.com) +# yq - YAML query tool (https://github.com/mikefarah/yq) +# jq - JSON processor (https://stedolan.github.io/jq) +# +# Config File Format: +# repos: +# - owner/repo1 +# - owner/repo2 +####################################### + +# Colors for output (stderr only) +readonly RED='\033[0;31m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly NC='\033[0m' # No Color + +####################################### +# Print error message and exit +# Arguments: +# $@ - Error message +# Outputs: +# Writes error to stderr +# Returns: +# Exits with code 1 +####################################### +die() { + echo -e "${RED}ERROR:${NC} $*" >&2 + exit 1 +} + +####################################### +# Print warning message +# Arguments: +# $@ - Warning message +# Outputs: +# Writes warning to stderr +####################################### +warn() { + echo -e "${YELLOW}WARNING:${NC} $*" >&2 +} + +####################################### +# Print info message +# Arguments: +# $@ - Info message +# Outputs: +# Writes info to stderr +####################################### +info() { + echo -e "${BLUE}INFO:${NC} $*" >&2 +} + +####################################### +# Check if command exists +# Arguments: +# $1 - Command name +# Returns: +# 0 if command exists, 1 otherwise +####################################### +command_exists() { + command -v "$1" &>/dev/null +} + +####################################### +# Check all required dependencies +# Returns: +# 0 if all dependencies exist, exits with 1 otherwise +####################################### +check_dependencies() { + local missing=() + local -a deps=("gh" "yq" "jq") + + for cmd in "${deps[@]}"; do + if ! command_exists "$cmd"; then + missing+=("$cmd") + fi + done + + if [[ ${#missing[@]} -gt 0 ]]; then + die "Missing required dependencies: ${missing[*]}" + fi +} + +####################################### +# Show help message +# Outputs: +# Writes help to stdout +####################################### +show_help() { + head -n 31 "${BASH_SOURCE[0]}" | tail -n 30 +} + +####################################### +# Parse command line arguments +# Globals: +# VERBOSE - Set to 1 if verbose mode enabled +# Arguments: +# $@ - Command line arguments +# Returns: +# 0 on success, 2 on invalid arguments +####################################### +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + show_help + exit 0 + ;; + -v|--verbose) + VERBOSE=1 + shift + ;; + *) + echo "Unknown option: $1" >&2 + return 2 + ;; + esac + done +} + +####################################### +# Get the config file path +# Globals: +# SCRIPT_DIR +# Arguments: +# None +# Outputs: +# Writes config path to stdout +# Returns: +# 0 on success, exits with 1 if file not found +####################################### +get_config_file() { + local config_file="${ACTIONS_STATUS_CONFIG:-}" + + if [[ -z "$config_file" ]]; then + config_file="${SCRIPT_DIR}/repos.yaml" + fi + + # Ensure it's an absolute path + if [[ ! "$config_file" = /* ]]; then + config_file="$(cd "$(dirname "$config_file")" && pwd)/$(basename "$config_file")" + fi + + if [[ ! -f "$config_file" ]]; then + die "Config file not found: $config_file" + fi + + echo "$config_file" +} + +####################################### +# Read repos from YAML config file +# Arguments: +# $1 - Path to config file +# Outputs: +# Writes repo names to stdout (one per line) +# Returns: +# 0 on success, exits with 1 on error +####################################### +read_repos() { + local config_file="$1" + + if ! yq '.repos[]' "$config_file" 2>/dev/null; then + die "Failed to parse repos from config file: $config_file" + fi +} + +####################################### +# Fetch runs for a repo from GitHub Actions +# Globals: +# VERBOSE +# Arguments: +# $1 - Repository in format owner/name +# Outputs: +# Writes JSON array of runs to stdout +# Returns: +# 0 on success +####################################### +fetch_repo_runs() { + local repo="$1" + + [[ $VERBOSE -eq 1 ]] && info "Fetching runs for $repo..." >&2 + + gh run list \ + --repo "$repo" \ + --limit 50 \ + --json workflowName,databaseId,headBranch,conclusion,url,createdAt,updatedAt,headSha \ + 2>/dev/null || { + warn "Failed to fetch runs for $repo" >&2 + echo "[]" + return 0 + } +} + +####################################### +# Process runs into workflow objects with latest run per workflow +# Arguments: +# $1 - JSON array of runs +# Outputs: +# Writes JSON array of workflow objects to stdout +# Returns: +# 0 on success +####################################### +process_repo_workflows() { + local runs_json="$1" + + # Group by workflow, keep latest (first in list since gh sorts by date desc) + # Map conclusion to status, compute duration for completed runs + jq ' + group_by(.workflowName) | + map({ + name: .[0].workflowName, + run_id: .[0].databaseId, + status: ( + if .[0].conclusion == null then "in_progress" + elif .[0].conclusion == "success" then "success" + elif .[0].conclusion == "failure" then "failure" + elif .[0].conclusion == "cancelled" then "cancelled" + elif .[0].conclusion == "skipped" then "skipped" + else .[0].conclusion + end + ), + branch: .[0].headBranch, + head_sha: .[0].headSha, + created_at: .[0].createdAt, + duration_seconds: ( + if .[0].conclusion != null then + (.[0].updatedAt | fromdateiso8601) - (.[0].createdAt | fromdateiso8601) + else + null + end + ), + url: .[0].url + }) + ' <<< "$runs_json" +} + +####################################### +# Main function +# Globals: +# VERBOSE, SCRIPT_DIR +# Arguments: +# $@ - Command line arguments +# Returns: +# 0 on success, 1 on error, 2 on invalid arguments +####################################### +main() { + parse_args "$@" || return 2 + + check_dependencies + + # Set SCRIPT_DIR to directory containing this script + local SCRIPT_DIR + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + local config_file + config_file="$(get_config_file)" + + [[ $VERBOSE -eq 1 ]] && info "Using config file: $config_file" >&2 + + # Read repos from config into array (using while loop for macOS compatibility) + local repos=() + while IFS= read -r repo; do + [[ -n "$repo" ]] && repos+=("$repo") + done < <(read_repos "$config_file") + + [[ $VERBOSE -eq 1 ]] && info "Found ${#repos[@]} repos to check" >&2 + + # Create temp file for collecting repo data + local temp_repos + temp_repos=$(mktemp) + trap "rm -f '$temp_repos'" EXIT + + local total_repos=${#repos[@]} + local total_workflows=0 + local success_count=0 + local failure_count=0 + local in_progress_count=0 + local cancelled_count=0 + local skipped_count=0 + + # Fetch status for each repo + for repo in ${repos[@]+"${repos[@]}"}; do + # Skip empty lines + [[ -z "$repo" ]] && continue + + local runs_json + runs_json=$(fetch_repo_runs "$repo") + + # Process runs into workflow objects + local workflows_json + workflows_json=$(process_repo_workflows "$runs_json") + + # Count workflows and statuses + local workflow_count + workflow_count=$(echo "$workflows_json" | jq 'length') + total_workflows=$((total_workflows + workflow_count)) + + # Count each status + local s_count f_count ip_count c_count sk_count + s_count=$(echo "$workflows_json" | jq '[.[] | select(.status == "success")] | length') + f_count=$(echo "$workflows_json" | jq '[.[] | select(.status == "failure")] | length') + ip_count=$(echo "$workflows_json" | jq '[.[] | select(.status == "in_progress")] | length') + c_count=$(echo "$workflows_json" | jq '[.[] | select(.status == "cancelled")] | length') + sk_count=$(echo "$workflows_json" | jq '[.[] | select(.status == "skipped")] | length') + + success_count=$((success_count + s_count)) + failure_count=$((failure_count + f_count)) + in_progress_count=$((in_progress_count + ip_count)) + cancelled_count=$((cancelled_count + c_count)) + skipped_count=$((skipped_count + sk_count)) + + # Build repo object and append to temp file + jq \ + --arg repo "$repo" \ + '{repo: $repo, workflows: .}' \ + <<< "$workflows_json" >> "$temp_repos" + done + + # Generate timestamp in ISO 8601 format + local timestamp + timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Build and output final JSON + if [[ -s "$temp_repos" ]]; then + # File has content - slurp JSON objects and build output + jq \ + --arg timestamp "$timestamp" \ + --argjson total_repos "$total_repos" \ + --argjson total_workflows "$total_workflows" \ + --argjson success "$success_count" \ + --argjson failure "$failure_count" \ + --argjson in_progress "$in_progress_count" \ + --argjson cancelled "$cancelled_count" \ + --argjson skipped "$skipped_count" \ + -s \ + '{ + timestamp: $timestamp, + repos: ., + summary: { + total_repos: $total_repos, + total_workflows: $total_workflows, + success: $success, + failure: $failure, + in_progress: $in_progress, + cancelled: $cancelled, + skipped: $skipped + } + }' < "$temp_repos" + else + # No repos - output empty results + jq \ + --arg timestamp "$timestamp" \ + --argjson total_repos "$total_repos" \ + -n \ + '{ + timestamp: $timestamp, + repos: [], + summary: { + total_repos: $total_repos, + total_workflows: 0, + success: 0, + failure: 0, + in_progress: 0, + cancelled: 0, + skipped: 0 + } + }' + fi +} + +# Global variables +VERBOSE=0 + +# Run main function if script is executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/bin/dotfiles-fix-perms b/bin/dotfiles-fix-perms index fd49d3e..caa980e 100755 --- a/bin/dotfiles-fix-perms +++ b/bin/dotfiles-fix-perms @@ -13,7 +13,8 @@ chmod 700 "$DF" "$DF/ssh" "$DF/bin" "$DF/bin/tests" "$DF/claudescripts" 2>/dev/n # Scripts: 700 chmod 700 "$DF/bin/dotfiles-install" "$DF/bin/dotfiles-sync" "$DF/bin/dotfiles-fix-perms" 2>/dev/null -chmod 700 "$DF/bin/actions-fails" "$DF/bin/tests/test-actions-fails.bats" 2>/dev/null +chmod 700 "$DF/bin/actions-fails" "$DF/bin/actions-status" 2>/dev/null +chmod 700 "$DF/bin/tests/test-actions-fails.bats" "$DF/bin/tests/test-actions-status.bats" 2>/dev/null chmod 700 "$DF/claudescripts/push" "$DF/claudescripts/ghcli" "$DF/claudescripts/support" 2>/dev/null chmod 600 "$DF/claudescripts/profile" "$DF/bin/repos.yaml" 2>/dev/null diff --git a/bin/tests/test-actions-status.bats b/bin/tests/test-actions-status.bats new file mode 100755 index 0000000..bb0ef4f --- /dev/null +++ b/bin/tests/test-actions-status.bats @@ -0,0 +1,536 @@ +#!/usr/bin/env bats +# Behavior-Driven Tests for actions-status +# +# This script checks GitHub Actions status across configured repos and outputs JSON. +# Tests are designed from specification, NOT implementation. + +# ============================================================================= +# Test Setup and Helpers +# ============================================================================= + +setup() { + # Path to script under test + SCRIPT="$BATS_TEST_DIRNAME/../actions-status" + + # Default config path + DEFAULT_CONFIG="$BATS_TEST_DIRNAME/../repos.yaml" + + # Create temp directory for test fixtures + TEST_TEMP_DIR="$(mktemp -d)" +} + +teardown() { + # Clean up temp directory + if [[ -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + +# Helper to skip integration tests when not available +skip_if_no_integration() { + if [[ -n "${SKIP_INTEGRATION:-}" ]]; then + skip "Integration tests disabled via SKIP_INTEGRATION" + fi + if ! gh auth status &>/dev/null 2>&1; then + skip "GitHub CLI not authenticated" + fi +} + +# ============================================================================= +# Behavior: Script Existence and Executability +# ============================================================================= + +@test "actions-status script exists" { + # Given: the bin directory structure + # When: we check for the script + # Then: the file should exist + [[ -f "$SCRIPT" ]] +} + +@test "actions-status script is executable" { + # Given: the script file exists + # When: we check its permissions + # Then: it should have execute permission + [[ -x "$SCRIPT" ]] +} + +# ============================================================================= +# Behavior: Configuration File Handling +# ============================================================================= + +@test "repos.yaml config file exists at default location" { + # Given: the bin directory structure + # When: we check for the config file + # Then: repos.yaml should exist + [[ -f "$DEFAULT_CONFIG" ]] +} + +@test "exits non-zero when config file is missing" { + # Given: ACTIONS_STATUS_CONFIG points to a non-existent file + local nonexistent_config="$TEST_TEMP_DIR/does-not-exist.yaml" + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$nonexistent_config" "$SCRIPT" + + # Then: it should exit with a non-zero status + [[ "$status" -ne 0 ]] +} + +@test "outputs error message when config file is missing" { + # Given: ACTIONS_STATUS_CONFIG points to a non-existent file + local nonexistent_config="$TEST_TEMP_DIR/does-not-exist.yaml" + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$nonexistent_config" "$SCRIPT" + + # Then: stderr or stdout should contain an error message about the config + # (We check combined output since error could go to either stream) + [[ "$output" =~ config ]] || [[ "$output" =~ Config ]] || \ + [[ "$output" =~ not\ found ]] || [[ "$output" =~ missing ]] || \ + [[ "$output" =~ does\ not\ exist ]] || [[ "$output" =~ "No such file" ]] +} + +@test "uses ACTIONS_STATUS_CONFIG env var when set" { + # Given: a valid config file at a custom location + local custom_config="$TEST_TEMP_DIR/custom-repos.yaml" + cat > "$custom_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed with ACTIONS_STATUS_CONFIG set + run env ACTIONS_STATUS_CONFIG="$custom_config" "$SCRIPT" + + # Then: it should succeed (exit 0) since config exists + # Note: Empty repos array should still produce valid output + [[ "$status" -eq 0 ]] +} + +# ============================================================================= +# Behavior: Output Format - Valid JSON +# ============================================================================= + +@test "output is valid JSON" { + # Given: a valid config with empty repos (to avoid network calls in basic test) + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: output should be valid JSON + [[ "$status" -eq 0 ]] + echo "$output" | jq . > /dev/null 2>&1 +} + +@test "output JSON has 'timestamp' key" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: JSON should have a timestamp key + [[ "$status" -eq 0 ]] + local has_timestamp + has_timestamp=$(echo "$output" | jq 'has("timestamp")') + [[ "$has_timestamp" == "true" ]] +} + +@test "output JSON has 'repos' key" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: JSON should have a repos key + [[ "$status" -eq 0 ]] + local has_repos + has_repos=$(echo "$output" | jq 'has("repos")') + [[ "$has_repos" == "true" ]] +} + +@test "output JSON has 'summary' key" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: JSON should have a summary key + [[ "$status" -eq 0 ]] + local has_summary + has_summary=$(echo "$output" | jq 'has("summary")') + [[ "$has_summary" == "true" ]] +} + +@test "repos is an array" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: repos should be an array + [[ "$status" -eq 0 ]] + local is_array + is_array=$(echo "$output" | jq '.repos | type == "array"') + [[ "$is_array" == "true" ]] +} + +# ============================================================================= +# Behavior: Summary Structure +# ============================================================================= + +@test "summary has 'total_repos' key" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: summary should have total_repos + [[ "$status" -eq 0 ]] + local has_key + has_key=$(echo "$output" | jq '.summary | has("total_repos")') + [[ "$has_key" == "true" ]] +} + +@test "summary has 'total_workflows' key" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: summary should have total_workflows + [[ "$status" -eq 0 ]] + local has_key + has_key=$(echo "$output" | jq '.summary | has("total_workflows")') + [[ "$has_key" == "true" ]] +} + +@test "summary has 'success' count key" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: summary should have success count + [[ "$status" -eq 0 ]] + local has_key + has_key=$(echo "$output" | jq '.summary | has("success")') + [[ "$has_key" == "true" ]] +} + +@test "summary has 'failure' count key" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: summary should have failure count + [[ "$status" -eq 0 ]] + local has_key + has_key=$(echo "$output" | jq '.summary | has("failure")') + [[ "$has_key" == "true" ]] +} + +@test "summary has 'in_progress' count key" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: summary should have in_progress count + [[ "$status" -eq 0 ]] + local has_key + has_key=$(echo "$output" | jq '.summary | has("in_progress")') + [[ "$has_key" == "true" ]] +} + +@test "summary values are numbers" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: all summary values should be numbers + [[ "$status" -eq 0 ]] + local total_repos_type total_workflows_type success_type failure_type in_progress_type + total_repos_type=$(echo "$output" | jq '.summary.total_repos | type') + total_workflows_type=$(echo "$output" | jq '.summary.total_workflows | type') + success_type=$(echo "$output" | jq '.summary.success | type') + failure_type=$(echo "$output" | jq '.summary.failure | type') + in_progress_type=$(echo "$output" | jq '.summary.in_progress | type') + + [[ "$total_repos_type" == '"number"' ]] + [[ "$total_workflows_type" == '"number"' ]] + [[ "$success_type" == '"number"' ]] + [[ "$failure_type" == '"number"' ]] + [[ "$in_progress_type" == '"number"' ]] +} + +# ============================================================================= +# Behavior: Timestamp Format +# ============================================================================= + +@test "timestamp is in ISO-8601 format" { + # Given: a valid config + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: timestamp should match ISO-8601 pattern (YYYY-MM-DDTHH:MM:SSZ or with timezone) + [[ "$status" -eq 0 ]] + local timestamp + timestamp=$(echo "$output" | jq -r '.timestamp') + + # ISO-8601 patterns: 2024-01-15T10:30:00Z or 2024-01-15T10:30:00+00:00 + [[ "$timestamp" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2} ]] +} + +# ============================================================================= +# Behavior: Empty Repos Array +# ============================================================================= + +@test "empty repos array produces zero counts in summary" { + # Given: a config with empty repos array + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: summary should show zeros + [[ "$status" -eq 0 ]] + local total_repos total_workflows success failure in_progress + total_repos=$(echo "$output" | jq '.summary.total_repos') + total_workflows=$(echo "$output" | jq '.summary.total_workflows') + success=$(echo "$output" | jq '.summary.success') + failure=$(echo "$output" | jq '.summary.failure') + in_progress=$(echo "$output" | jq '.summary.in_progress') + + [[ "$total_repos" -eq 0 ]] + [[ "$total_workflows" -eq 0 ]] + [[ "$success" -eq 0 ]] + [[ "$failure" -eq 0 ]] + [[ "$in_progress" -eq 0 ]] +} + +@test "empty repos array produces empty repos array" { + # Given: a config with empty repos array + local test_config="$TEST_TEMP_DIR/test-repos.yaml" + cat > "$test_config" << 'EOF' +repos: [] +EOF + + # When: the script is executed + run env ACTIONS_STATUS_CONFIG="$test_config" "$SCRIPT" + + # Then: repos array should be empty + [[ "$status" -eq 0 ]] + local repos_count + repos_count=$(echo "$output" | jq '.repos | length') + [[ "$repos_count" -eq 0 ]] +} + +# ============================================================================= +# Behavior: Integration Tests (require GitHub CLI authentication) +# ============================================================================= + +# Helper to run script and capture only stdout (stderr may contain warnings) +run_script_stdout_only() { + local config="$1" + env ACTIONS_STATUS_CONFIG="$config" "$SCRIPT" 2>/dev/null +} + +@test "integration: fetches real repo and produces valid structure" { + skip_if_no_integration + + # Given: a config with a real repository + local test_config="$TEST_TEMP_DIR/integration-repos.yaml" + cat > "$test_config" << 'EOF' +repos: + - JamesPrial/dotfiles +EOF + + # When: the script is executed (capturing only stdout to avoid stderr warnings) + local json_output + json_output=$(run_script_stdout_only "$test_config") + local exit_status=$? + + # Then: should exit successfully and output valid JSON with expected structure + [[ "$exit_status" -eq 0 ]] + echo "$json_output" | jq . > /dev/null 2>&1 + + # Verify required top-level keys exist + local has_timestamp has_repos has_summary + has_timestamp=$(echo "$json_output" | jq 'has("timestamp")') + has_repos=$(echo "$json_output" | jq 'has("repos")') + has_summary=$(echo "$json_output" | jq 'has("summary")') + + [[ "$has_timestamp" == "true" ]] + [[ "$has_repos" == "true" ]] + [[ "$has_summary" == "true" ]] +} + +@test "integration: workflow objects have required fields" { + skip_if_no_integration + + # Given: a config with a real repository that has workflows + local test_config="$TEST_TEMP_DIR/integration-repos.yaml" + cat > "$test_config" << 'EOF' +repos: + - JamesPrial/dotfiles +EOF + + # When: the script is executed + local json_output + json_output=$(run_script_stdout_only "$test_config") + local exit_status=$? + + # Then: if there are workflows, they should have required fields + [[ "$exit_status" -eq 0 ]] + + local workflow_count + workflow_count=$(echo "$json_output" | jq '[.repos[].workflows[]] | length') + + if [[ "$workflow_count" -gt 0 ]]; then + # Check first workflow has required fields + local has_name has_status has_url has_run_id + has_name=$(echo "$json_output" | jq '.repos[0].workflows[0] | has("name")') + has_status=$(echo "$json_output" | jq '.repos[0].workflows[0] | has("status")') + has_url=$(echo "$json_output" | jq '.repos[0].workflows[0] | has("url")') + has_run_id=$(echo "$json_output" | jq '.repos[0].workflows[0] | has("run_id")') + + [[ "$has_name" == "true" ]] + [[ "$has_status" == "true" ]] + [[ "$has_url" == "true" ]] + [[ "$has_run_id" == "true" ]] + fi +} + +@test "integration: status values are valid" { + skip_if_no_integration + + # Given: a config with a real repository + local test_config="$TEST_TEMP_DIR/integration-repos.yaml" + cat > "$test_config" << 'EOF' +repos: + - JamesPrial/dotfiles +EOF + + # When: the script is executed + local json_output + json_output=$(run_script_stdout_only "$test_config") + local exit_status=$? + + # Then: all status values should be one of the valid statuses + [[ "$exit_status" -eq 0 ]] + + local workflow_count + workflow_count=$(echo "$json_output" | jq '[.repos[].workflows[]] | length') + + if [[ "$workflow_count" -gt 0 ]]; then + # Extract all unique status values and verify they are valid + local statuses + statuses=$(echo "$json_output" | jq -r '[.repos[].workflows[].status] | unique | .[]') + + for s in $statuses; do + case "$s" in + success|failure|in_progress|cancelled|skipped) + # Valid status + ;; + *) + # Invalid status found + echo "Invalid status found: $s" + return 1 + ;; + esac + done + fi +} + +@test "integration: summary counts match actual data" { + skip_if_no_integration + + # Given: a config with a real repository + local test_config="$TEST_TEMP_DIR/integration-repos.yaml" + cat > "$test_config" << 'EOF' +repos: + - JamesPrial/dotfiles +EOF + + # When: the script is executed + local json_output + json_output=$(run_script_stdout_only "$test_config") + local exit_status=$? + + # Then: summary counts should match the actual workflow data + [[ "$exit_status" -eq 0 ]] + + # Count workflows in repos array + local actual_workflow_count + actual_workflow_count=$(echo "$json_output" | jq '[.repos[].workflows[]] | length') + + # Get reported total_workflows from summary + local reported_total + reported_total=$(echo "$json_output" | jq '.summary.total_workflows') + + [[ "$actual_workflow_count" -eq "$reported_total" ]] + + # Verify status counts add up + local success failure in_progress cancelled skipped total_counted + success=$(echo "$json_output" | jq '.summary.success') + failure=$(echo "$json_output" | jq '.summary.failure') + in_progress=$(echo "$json_output" | jq '.summary.in_progress') + cancelled=$(echo "$json_output" | jq '.summary.cancelled // 0') + skipped=$(echo "$json_output" | jq '.summary.skipped // 0') + + total_counted=$((success + failure + in_progress + cancelled + skipped)) + + [[ "$total_counted" -eq "$reported_total" ]] +}