Skip to content

Instantly share code, notes, and snippets.

@dobladov
Last active May 22, 2025 12:03
Show Gist options
  • Select an option

  • Save dobladov/0f70fdbd0eaae3e897dab40e7d4aeceb to your computer and use it in GitHub Desktop.

Select an option

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
#!/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