Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 77 additions & 21 deletions actions/setup/js/create_forecast_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,53 @@ function formatAIC(value) {
*/
function buildForecastIssueBody(report, options) {
const workflows = Array.isArray(report?.workflows) ? report.workflows : [];
const rows = workflows.map(workflow => {
const p50 = workflow?.monte_carlo?.p50_projected_aic ?? workflow?.projected_aic ?? workflow?.monte_carlo?.p50_projected_effective_tokens ?? workflow?.projected_effective_tokens ?? 0;
return [escapeCell(workflow.workflow_id), workflow.sampled_runs ?? 0, Number(p50)];

// Build the summary table with per-run P50/P95 and weekly/monthly projected totals.
const tableRows = workflows.map(workflow => {
const p50PerRun = workflow?.p50_aic_per_run ?? 0;
const p95PerRun = workflow?.p95_aic_per_run ?? 0;
const weeklyP50 = workflow?.weekly_monte_carlo?.p50_projected_aic ?? workflow?.weekly_projected_aic ?? 0;
const monthlyP50 = workflow?.monthly_monte_carlo?.p50_projected_aic ?? workflow?.monthly_projected_aic ?? 0;
return [escapeCell(workflow.workflow_id), workflow.sampled_runs ?? 0, Number(p50PerRun), Number(p95PerRun), Number(weeklyP50), Number(monthlyP50)];
});

const allProjectedZero = rows.length > 0 && rows.every(([, , p50]) => Number(p50) === 0);
const zeroProjectedWithSamples = rows.filter(([, sampledRuns, p50]) => Number(sampledRuns) > 0 && Number(p50) === 0).length;
const zeroWorkflowWord = zeroProjectedWithSamples === 1 ? "workflow" : "workflows";
const zeroWorkflowVerb = zeroProjectedWithSamples === 1 ? "has" : "have";
const reportTable =
rows.length > 0
? ["| Workflow | Sampled runs | Forecast AIC (P50) |", "| --- | ---: | ---: |", ...rows.map(([workflowID, sampledRuns, p50]) => `| ${workflowID} | ${sampledRuns} | ${formatAIC(p50)} |`)].join("\n")
: "_No forecast rows were produced._";
// Legacy fallback: derive weekly/monthly from the configured-period P50 when new fields are absent.
const hasNewFields = workflows.some(w => w?.p50_aic_per_run != null || w?.weekly_projected_aic != null);
const legacyRows = hasNewFields
? null
: workflows.map(workflow => {
const p50 = workflow?.monte_carlo?.p50_projected_aic ?? workflow?.projected_aic ?? workflow?.monte_carlo?.p50_projected_effective_tokens ?? workflow?.projected_effective_tokens ?? 0;
return [escapeCell(workflow.workflow_id), workflow.sampled_runs ?? 0, Number(p50)];
});
Comment on lines +50 to +57

const allWeeklyZero = tableRows.length > 0 && tableRows.every(([, , , , weekly]) => Number(weekly) === 0);
const allMonthlyZero = tableRows.length > 0 && tableRows.every(([, , , , , monthly]) => Number(monthly) === 0);
const allProjectedZero = legacyRows ? legacyRows.length > 0 && legacyRows.every(([, , p50]) => Number(p50) === 0) : allWeeklyZero && allMonthlyZero;

let reportTable;
if (legacyRows) {
reportTable =
legacyRows.length > 0
? ["| Workflow | Sampled runs | Forecast AIC (P50) |", "| --- | ---: | ---: |", ...legacyRows.map(([workflowID, sampledRuns, p50]) => `| ${workflowID} | ${sampledRuns} | ${formatAIC(p50)} |`)].join("\n")
: "_No forecast rows were produced._";
} else {
if (tableRows.length === 0) {
reportTable = "_No forecast rows were produced._";
} else {
const totalWeekly = tableRows.reduce((s, [, , , , w]) => s + Number(w), 0);
const totalMonthly = tableRows.reduce((s, [, , , , , m]) => s + Number(m), 0);
const dataRows = tableRows.map(([workflowID, sampledRuns, p50Run, p95Run, weekly, monthly]) =>
`| ${workflowID} | ${sampledRuns} | ${formatAIC(p50Run)} | ${formatAIC(p95Run)} | ${formatAIC(weekly)} | ${formatAIC(monthly)} |`
);
if (tableRows.length > 1) {
dataRows.push(`| **TOTAL** | | | | **${formatAIC(totalWeekly)}** | **${formatAIC(totalMonthly)}** |`);
}
reportTable = ["| Workflow | Runs | P50/Run | P95/Run | Weekly (P50) | Monthly (P50) |", "| --- | ---: | ---: | ---: | ---: | ---: |", ...dataRows].join("\n");
}
}

// Build the detailed run samples section.
const samplesSection = buildRunSamplesSection(workflows);

const repoSlug = `${options.owner}/${options.repo}`;
const period = report?.period || "month";
Expand All @@ -65,15 +99,6 @@ function buildForecastIssueBody(report, options) {
"",
].join("\n")
: "";
const zeroProjectedTip =
zeroProjectedWithSamples > 0
? [
"> [!TIP]",
`> ${zeroProjectedWithSamples} ${zeroWorkflowWord} ${zeroWorkflowVerb} sampled runs but forecast AIC is 0. This usually indicates missing token usage in cached run summaries for sampled runs.`,
"> Increase the warm-up scope with `gh aw logs --start-date -30d --count <larger value>` if this persists.",
"",
].join("\n")
: "";
const sourceRunLine = runURL ? `_Forecast source run: [#${runID}](${runURL})._` : "";
const errorSection = outcome === "success" ? "" : ["> [!WARNING]", `> Forecast outcome: ${outcome}.`, `> ${options.errorMessage || "Forecast computation did not complete successfully."}`].join("\n");

Expand All @@ -83,12 +108,42 @@ function buildForecastIssueBody(report, options) {
period,
report_table: reportTable,
all_projected_zero_note: allProjectedZeroNote,
zero_projected_tip: zeroProjectedTip,
run_samples_section: samplesSection,
error_section: errorSection,
source_run_line: sourceRunLine,
}).trim();
}

/**
* Builds a collapsed <details> block listing every sampled run used in the forecast.
* Returns an empty string when no workflow has run samples.
* @param {Array<Record<string, any>>} workflows
* @returns {string}
*/
function buildRunSamplesSection(workflows) {
const hasAny = workflows.some(w => Array.isArray(w?.run_samples) && w.run_samples.length > 0);
if (!hasAny) return "";

const lines = [
"<details>",
"<summary>Sampled runs used in computation</summary>",
"",
"| Workflow | Run ID | Date | AIC |",
"| --- | ---: | --- | ---: |",
];
for (const wf of workflows) {
const samples = Array.isArray(wf?.run_samples) ? wf.run_samples : [];
for (const s of samples) {
const runID = s?.run_id ?? "";
const date = s?.date ?? "";
const aic = formatAIC(s?.aic ?? 0);
lines.push(`| ${escapeCell(wf.workflow_id)} | #${runID} | ${date} | ${aic} |`);
}
}
Comment on lines +134 to +142
lines.push("", "</details>", "");
return lines.join("\n");
}

/**
* @returns {Promise<void>}
*/
Expand Down Expand Up @@ -183,6 +238,7 @@ async function main() {
module.exports = {
main,
buildForecastIssueBody,
buildRunSamplesSection,
formatAIC,
escapeCell,
FORECAST_REPORT_PATH,
Expand Down
87 changes: 80 additions & 7 deletions actions/setup/js/create_forecast_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,18 @@ describe("create_forecast_issue", () => {
{
workflow_id: "wf|a",
sampled_runs: 3,
monte_carlo: {
p50_projected_aic: 12345.6,
},
p50_aic_per_run: 4000,
p95_aic_per_run: 8000,
weekly_monte_carlo: { p50_projected_aic: 12345.6 },
monthly_monte_carlo: { p50_projected_aic: 52000 },
},
{
workflow_id: "wf-b",
sampled_runs: 5,
projected_aic: 0,
p50_aic_per_run: 0,
p95_aic_per_run: 0,
weekly_projected_aic: 0,
monthly_projected_aic: 0,
},
],
},
Expand All @@ -75,10 +79,10 @@ describe("create_forecast_issue", () => {
}
);

expect(body).toContain("| Workflow | Sampled runs | Forecast AIC (P50) |");
expect(body).toContain("| wf\\|a | 3 | 12,346 |");
expect(body).toContain("> 1 workflow has sampled runs but forecast AIC is 0. This usually indicates missing token usage in cached run summaries for sampled runs.");
expect(body).toContain("| Workflow | Runs | P50/Run | P95/Run | Weekly (P50) | Monthly (P50) |");
expect(body).toContain("| wf\\|a | 3 | 4,000 | 8,000 | 12,346 | 52,000 |");
expect(body).toContain("_Forecast source run: [#123456](https://github.com/octo/repo/actions/runs/123456)._");
expect(body).not.toContain("sampled runs but forecast AIC is 0");
});

it("adds all-projected-zero diagnostics when every projected AIC is zero", async () => {
Expand Down Expand Up @@ -120,6 +124,75 @@ describe("create_forecast_issue", () => {
expect(body).toContain("| wf-legacy | 2 | 9,999 |");
});

it("renders run samples section in a collapsed details block", async () => {
const module = await import("./create_forecast_issue.cjs");
const body = module.buildForecastIssueBody(
{
period: "month",
workflows: [
{
workflow_id: "wf-c",
sampled_runs: 2,
p50_aic_per_run: 1000,
p95_aic_per_run: 2000,
weekly_projected_aic: 5000,
monthly_projected_aic: 20000,
run_samples: [
{ run_id: 111, date: "2026-01-10", aic: 900 },
{ run_id: 222, date: "2026-01-11", aic: 1100 },
],
},
],
},
{
owner: "octo",
repo: "repo",
serverUrl: "https://github.com",
generatedAtISO: "2026-01-01T00:00:00.000Z",
}
);

expect(body).toContain("<details>");
expect(body).toContain("Sampled runs used in computation");
expect(body).toContain("| wf-c | #111 | 2026-01-10 | 900 |");
expect(body).toContain("| wf-c | #222 | 2026-01-11 | 1,100 |");
});

it("renders TOTAL row when multiple workflows are present", async () => {
const module = await import("./create_forecast_issue.cjs");
const body = module.buildForecastIssueBody(
{
period: "month",
workflows: [
{
workflow_id: "wf-1",
sampled_runs: 3,
p50_aic_per_run: 1000,
p95_aic_per_run: 2000,
weekly_projected_aic: 7000,
monthly_projected_aic: 30000,
},
{
workflow_id: "wf-2",
sampled_runs: 2,
p50_aic_per_run: 500,
p95_aic_per_run: 1000,
weekly_projected_aic: 3000,
monthly_projected_aic: 12000,
},
],
},
{
owner: "octo",
repo: "repo",
serverUrl: "https://github.com",
generatedAtISO: "2026-01-01T00:00:00.000Z",
}
);

expect(body).toContain("| **TOTAL** | | | | **10,000** | **42,000** |");
});

it("creates an error issue when report file is missing", async () => {
mockFs.existsSync.mockReturnValue(false);

Expand Down
2 changes: 1 addition & 1 deletion actions/setup/md/forecast_issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ Period: {period}
{report_table}

{all_projected_zero_note}
{zero_projected_tip}
{run_samples_section}
{error_section}
{source_run_line}
Loading
Loading