Skip to content

Instantly share code, notes, and snippets.

@maschwenk
Last active April 1, 2025 16:05
Show Gist options
  • Save maschwenk/be1443052ddf4bef7d9235837b591c74 to your computer and use it in GitHub Desktop.
Save maschwenk/be1443052ddf4bef7d9235837b591c74 to your computer and use it in GitHub Desktop.
import { parse } from '[email protected]/sync';
import * as fs from 'fs';
const COST_PER_MINUTE = 0.0028;
interface ContainerBuild {
project_name: string;
build_count: number;
minutes_saved: number;
minutes_billed: number;
}
interface GitHubAction {
repo: string;
workflow: string;
runner: string;
job_count: number;
minutes_elapsed: number;
minutes_billed: number;
}
function processCSV(filePath: string): void {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const lines = fileContent.split('\n');
// Find the sections
const githubActionsStart = lines.indexOf('GitHub Actions Jobs') + 1;
// Process GitHub Actions section
const githubActionsHeader = lines[githubActionsStart];
// Data until you see a newline
const githubActionsData = lines.slice(githubActionsStart + 1);
const githubActionsEnd = githubActionsData.findIndex(line => line === '');
const githubActions = parse(
githubActionsHeader + '\n' + githubActionsData.slice(0, githubActionsEnd).join('\n'),
{
columns: true,
skip_empty_lines: true,
},
) as GitHubAction[];
// For workflows that contain a parentehesis in the name or contain the pattern Runner <number>
// combine the spend by the workflow name without the parenthesis
const workflowSpend = githubActions.reduce(
(acc, action) => {
let workflowName = action.workflow;
if (action.workflow.includes('Runner')) {
workflowName = action.workflow.split('Runner')[0].trim();
} else if (action.workflow.includes('(')) {
workflowName = action.workflow.split('(')[0].trim();
}
if (!acc[workflowName]) {
acc[workflowName] = {
workflow: workflowName,
minutes_billed: 0,
actual_spend: 0,
};
}
acc[workflowName].minutes_billed += Number(action.minutes_billed);
acc[workflowName].actual_spend += Number(action.minutes_billed) * COST_PER_MINUTE;
return acc;
},
{} as Record<string, { workflow: string; minutes_billed: number; actual_spend: number }>,
);
const calculateTotalSpend = (
actions: Record<string, { workflow: string; minutes_billed: number; actual_spend: number }>,
) => {
return Object.values(actions).reduce((acc, action) => acc + action.actual_spend, 0);
};
const totalSpend = calculateTotalSpend(workflowSpend);
const githubActionsWithSpend = Object.values(workflowSpend)
.map(action => ({
...action,
minutes_billed: Number(action.minutes_billed),
actual_spend: Number(action.minutes_billed) * COST_PER_MINUTE,
percentage_of_total: (Number(action.minutes_billed) * COST_PER_MINUTE) / totalSpend,
}))
.filter(action => action.minutes_billed > 50_000)
.sort((a, b) => Number(b.actual_spend) - Number(a.actual_spend))
.map(action => ({
...action,
// formatted as currency
minutes_billed: `${Number(action.minutes_billed).toLocaleString('en-US', {
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}`,
actual_spend: `${Number(action.actual_spend).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}`,
percentage_of_total: `${Number(action.percentage_of_total).toLocaleString('en-US', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`,
}));
// Write it as a table using console.table
console.table(githubActionsWithSpend);
console.log(
// strip off cents
`Total spend: ${totalSpend.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}`,
);
}
// Get the input file path from command line arguments
const inputFile = process.argv[2];
if (!inputFile) {
console.error('Please provide a CSV file path as an argument');
process.exit(1);
}
processCSV(inputFile);
@maschwenk
Copy link
Author

Run with bun calculate_spend.ts 2025-04-01.csv

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment