Last active
May 22, 2025 12:03
-
-
Save dobladov/0f70fdbd0eaae3e897dab40e7d4aeceb to your computer and use it in GitHub Desktop.
Calculates the average running time of a GitHub Action workflow using Node.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| // gh-action-avg-runtime.js | |
| // Description: Calculates the average running time of a GitHub Action workflow using Node.js. | |
| // Dependencies: gh cli, jq | |
| import {exec} from 'node:child_process'; | |
| import { | |
| promisify, | |
| parseArgs, | |
| } from 'node:util'; | |
| const execPromise = promisify(exec); | |
| const DEFAULT_LIMIT = 10; // Default number of recent successful runs to consider | |
| /** | |
| * Converts milliseconds to a human-readable string (M m S s, S s, or MSms). | |
| * | |
| * @param {number} totalMs - The total milliseconds. | |
| * @returns {string} Formatted duration string. | |
| */ | |
| const formatDurationMs = (totalMs) => { | |
| if (totalMs === null || totalMs === undefined || totalMs < 0) { | |
| return 'N/A'; | |
| } | |
| const totalSeconds = Math.floor(totalMs / 1000); | |
| const minutes = Math.floor(totalSeconds / 60); | |
| const seconds = totalSeconds % 60; | |
| if (minutes > 0) { | |
| return `${minutes}m ${seconds}s`; | |
| } else if (totalSeconds > 0) { | |
| return `${seconds}s`; | |
| } else { | |
| return `${totalMs}ms`; // Show ms if less than 1 second | |
| } | |
| }; | |
| /** | |
| * Executes a shell command and returns its stdout. | |
| * Throws an error if the command fails. | |
| * | |
| * @param {string} command - The command to execute. | |
| * @returns {Promise<string>} The stdout of the command. | |
| */ | |
| const runCommand = async (command) => { | |
| try { | |
| const { | |
| stderr, stdout, | |
| } = await execPromise(command); | |
| if (stderr) { | |
| // gh often outputs informational messages to stderr, so only treat it as an error if stdout is empty | |
| // or if it seems like a genuine error message. | |
| // For this script, we'll log stderr for info but rely on gh exit codes primarily. | |
| if (process.env.DEBUG) { | |
| console.warn(`Command STDERR: ${stderr}`); | |
| } | |
| } | |
| return stdout.trim(); | |
| } catch (error) { | |
| console.error(`Error executing command: ${command}`); | |
| console.error(`STDERR: ${error.stderr}`); | |
| console.error(`STDOUT: ${error.stdout}`); | |
| throw error; // Re-throw to be caught by the main execution block | |
| } | |
| }; | |
| /** | |
| * Parses command line arguments. | |
| * | |
| * @returns {Record<string, string|number>} Parsed arguments with repo, workflow, and limit properties. | |
| */ | |
| const parseArguments = () => { | |
| // Define command-line options | |
| const options = { | |
| help: { | |
| type: 'boolean', | |
| description: 'Show help', | |
| short: 'h', | |
| }, | |
| limit: { | |
| type: 'string', | |
| default: String(DEFAULT_LIMIT), | |
| description: 'The maximum number of recent runs to examine (default: 10)', | |
| short: 'l', | |
| }, | |
| repo: { | |
| type: 'string', | |
| description: 'The repository (e.g., \'octocat/Spoon-Knife\')', | |
| short: 'r', | |
| }, | |
| workflow: { | |
| type: 'string', | |
| description: 'The name of the workflow (e.g., \'CI\') or its filename (e.g., \'main.yml\')', | |
| short: 'w', | |
| }, | |
| }; | |
| // Parse args | |
| const {values} = parseArgs({ | |
| allowPositionals: true, | |
| options, | |
| strict: false, | |
| }); | |
| // Show help if requested or no args provided | |
| const showHelp = () => { | |
| console.log('Usage: gh-action-avg-runtime.js --repo <owner/repo> --workflow <workflow_name_or_id> [--limit <number_of_runs>]'); | |
| console.log(''); | |
| console.log('Options:'); | |
| // Generate help text from options | |
| Object.entries(options).forEach(([name, opt]) => { | |
| const shortFlag = opt.short ? `-${opt.short}` : ' '; | |
| const defaultValue = opt.default ? ` (default: ${opt.default})` : ''; | |
| console.log(` --${name.padEnd(10)} ${shortFlag} ${opt.description}${defaultValue}`); | |
| }); | |
| process.exit(0); | |
| }; | |
| if (values.help || process.argv.length <= 2) { | |
| showHelp(); | |
| } | |
| // Get limit as a number | |
| const limit = parseInt(values.limit, 10); | |
| // Validate limit | |
| if (isNaN(limit) || limit <= 0) { | |
| console.error('Error: --limit must be a positive integer.'); | |
| process.exit(1); | |
| } | |
| // Validate required arguments | |
| if (!values.repo) { | |
| console.error('Error: --repo is required'); | |
| showHelp(); | |
| } | |
| if (!values.workflow) { | |
| console.error('Error: --workflow is required'); | |
| showHelp(); | |
| } | |
| return { | |
| limit, | |
| repo: values.repo, | |
| workflow: values.workflow, | |
| }; | |
| }; | |
| // --- Main Application Logic --- | |
| const main = async () => { | |
| const { | |
| limit, | |
| repo, | |
| workflow: workflowId, | |
| } = parseArguments(); | |
| console.log(`Fetching recent successful runs for workflow '${workflowId}' in repo '${repo}' (examining last ${limit} runs)...`); | |
| // Fetch run IDs of successful, completed runs | |
| // gh run list returns runs in reverse chronological order (most recent first). | |
| // We request JSON output of an array of databaseIds. | |
| const listRunsCommand = `gh run list --workflow "${workflowId}" --repo "${repo}" --limit "${limit}" --json databaseId,status,conclusion --jq '[.[] | select(.status=="completed" and .conclusion=="success") | .databaseId]'`; | |
| let runIds; | |
| try { | |
| const runIdsJson = await runCommand(listRunsCommand); | |
| if (!runIdsJson) { | |
| console.log(`No successful completed runs found for workflow '${workflowId}' in repo '${repo}' within the last ${limit} runs examined.`); | |
| process.exit(0); | |
| } | |
| runIds = JSON.parse(runIdsJson); | |
| } catch (error) { | |
| console.error('Failed to fetch or parse run IDs.', error.message); | |
| process.exit(1); | |
| } | |
| if (!runIds || runIds.length === 0) { | |
| console.log(`No successful completed runs found for workflow '${workflowId}' in repo '${repo}' within the last ${limit} runs examined (after filtering).`); | |
| process.exit(0); | |
| } | |
| console.log(`Found ${runIds.length} successful run(s) to analyze. Fetching durations...`); | |
| let totalDurationMs = 0; | |
| let processedRunsCount = 0; | |
| for await (const runId of runIds) { | |
| process.stdout.write(` Fetching duration for run ID ${runId}... `); // No newline for inline update | |
| const viewRunCommand = `gh run view "${runId}" --repo "${repo}" --json startedAt,updatedAt`; | |
| let durationMs = null; | |
| try { | |
| const durationDataJson = await runCommand(viewRunCommand); | |
| const durationData = JSON.parse(durationDataJson); | |
| if (durationData && durationData.startedAt && durationData.updatedAt) { | |
| const startTime = new Date(durationData.startedAt).getTime(); | |
| const endTime = new Date(durationData.updatedAt).getTime(); | |
| durationMs = endTime - startTime; | |
| } | |
| } catch (error) { | |
| console.log(`Failed to fetch details for run ${runId}. Skipping. (Error: ${error.message})`); | |
| } | |
| if (durationMs === null || durationMs < 0) { | |
| console.log(`Could not retrieve a valid duration for run ${runId}. Skipping.`); | |
| } | |
| const formattedRunDuration = formatDurationMs(durationMs); | |
| console.log(`${formattedRunDuration} (${durationMs}ms)`); // Newline after duration | |
| totalDurationMs += durationMs; | |
| processedRunsCount++; | |
| } | |
| if (processedRunsCount === 0) { | |
| console.log('No runs with valid durations were processed. Cannot calculate an average.'); | |
| process.exit(0); | |
| } | |
| const averageDurationMs = Math.round(totalDurationMs / processedRunsCount); // Round to nearest ms | |
| const formattedAverageDuration = formatDurationMs(averageDurationMs); | |
| // --- Output Results --- | |
| console.log(''); | |
| console.log('--------------------------------------------------------------------'); | |
| console.log('GitHub Action Workflow Average Run Time (Node.js)'); | |
| console.log('--------------------------------------------------------------------'); | |
| console.log(`Repository: ${repo}`); | |
| console.log(`Workflow: ${workflowId}`); | |
| console.log(`Runs Analyzed: ${processedRunsCount} (from ${runIds.length} successful runs, up to ${limit} examined)`); | |
| console.log(`Total Duration: ${formatDurationMs(totalDurationMs)} (${totalDurationMs}ms)`); | |
| console.log(`Average Run Time: ${formattedAverageDuration} (${averageDurationMs}ms)`); | |
| console.log('--------------------------------------------------------------------'); | |
| }; | |
| main().catch((error) => { | |
| console.error('An unexpected error occurred:', error.message); | |
| if (process.env.DEBUG) { | |
| console.error(error.stack); | |
| } | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment