Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .github/workflows/smoke-project.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions .github/workflows/test-project-url-default.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

92 changes: 18 additions & 74 deletions actions/setup/js/run_activity_report.cjs
Original file line number Diff line number Diff line change
@@ -1,98 +1,44 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { getErrorMessage, isRateLimitError } = require("./error_helpers.cjs");
const fs = require("node:fs/promises");
const path = require("node:path");
const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

const ISSUE_TITLE = "[aw] agentic status report";
const REPORT_COUNT = 1000;
const HEADING_DEMOTION_LEVELS = 2;
const DEFAULT_REPORT_OUTPUT_DIR = "./.cache/gh-aw/activity-report-logs";
const DEFAULT_REPORT_OUTPUT_DIR = "./.cache/gh-aw/agentic-workflow-logs";
const REPORT_SECTION_DIR = "activity-report";

/** @typedef {{ key: string, heading: string, startDate: string, optionalOnRateLimit: boolean }} ActivityRange */
/** @typedef {{ key: string, heading: string }} ActivityRange */

/** @type {ActivityRange[]} */
const REPORT_RANGES = [
{ key: "24h", heading: "Last 24 hours", startDate: "-1d", optionalOnRateLimit: false },
{ key: "7d", heading: "Last 7 days", startDate: "-1w", optionalOnRateLimit: false },
{ key: "24h", heading: "Last 24 hours" },
{ key: "7d", heading: "Last 7 days" },
];

/**
* @param {string} text
* @returns {boolean}
*/
function hasRateLimitText(text) {
return /\bapi rate limit\b|\brate limit exceeded\b|\bsecondary rate limit\b|\b429\b/i.test(text);
}

/**
* Run the logs command for a configured report range.
* Read pre-indexed report markdown from the cache directory.
*
* @param {string} bin
* @param {string[]} prefixArgs
* @param {string} repoSlug
* @param {ActivityRange} range
* @param {string} outputDir
* @returns {Promise<{ heading: string, body: string }>}
*/
async function runRangeReport(bin, prefixArgs, repoSlug, range, outputDir) {
const args = [...prefixArgs, "logs", "--repo", repoSlug, "--start-date", range.startDate, "--count", String(REPORT_COUNT), "--output", outputDir, "--format", "markdown"];
core.info(`Running: ${bin} ${args.join(" ")}`);

async function readCachedRangeReport(range, outputDir) {
const rangeReportPath = path.join(outputDir, REPORT_SECTION_DIR, `${range.key}.md`);
try {
const result = await exec.getExecOutput(bin, args, { ignoreReturnCode: true });
const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
const rateLimited = hasRateLimitText(output);

if (result.exitCode === 0 && result.stdout.trim()) {
return {
heading: range.heading,
body: normalizeReportMarkdown(sanitizeContent(result.stdout.trim())),
};
}

if (rateLimited && range.optionalOnRateLimit) {
core.warning(`Skipping ${range.heading} report due to GitHub API rate limiting`);
return {
heading: range.heading,
body: "_Skipped due to GitHub API rate limiting._",
};
}

if (rateLimited) {
return {
heading: range.heading,
body: "_Could not generate this section due to GitHub API rate limiting._",
};
}

const markdown = await fs.readFile(rangeReportPath, "utf8");
return {
heading: range.heading,
body: `_Report command failed (exit code ${result.exitCode})._\n\n\`\`\`\n${sanitizeContent(output || "No command output was captured.")}\n\`\`\``,
body: normalizeReportMarkdown(sanitizeContent(markdown.trim())),
};
} catch (error) {
const errorMessage = getErrorMessage(error);
const rateLimited = isRateLimitError(error) || hasRateLimitText(errorMessage);

if (rateLimited && range.optionalOnRateLimit) {
core.warning(`Skipping ${range.heading} report due to GitHub API rate limiting`);
return {
heading: range.heading,
body: "_Skipped due to GitHub API rate limiting._",
};
}

if (rateLimited) {
return {
heading: range.heading,
body: "_Could not generate this section due to GitHub API rate limiting._",
};
}

} catch {
core.warning(`Missing cached report for ${range.heading}: ${rangeReportPath}`);
return {
heading: range.heading,
body: `_Report command failed: ${sanitizeContent(errorMessage)}_`,
body: "_No cached trace index is available for this range yet._",
};
}
}
Expand All @@ -117,17 +63,15 @@ function normalizeReportMarkdown(markdown) {
* @returns {Promise<void>}
*/
async function main() {
const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw";
const reportOutputDir = process.env.GH_AW_ACTIVITY_REPORT_OUTPUT_DIR || DEFAULT_REPORT_OUTPUT_DIR;
const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean);
const { owner, repo } = resolveExecutionOwnerRepo();
const repoSlug = `${owner}/${repo}`;

core.info(`Generating agentic workflow activity report for ${repoSlug}`);
core.info(`Generating agentic workflow activity report for ${repoSlug} from cached trace index data`);

const sections = [];
for (const range of REPORT_RANGES) {
sections.push(await runRangeReport(bin, prefixArgs, repoSlug, range, reportOutputDir));
sections.push(await readCachedRangeReport(range, reportOutputDir));
}

const headerLines = ["### Agentic workflow activity report", "", `Repository: \`${repoSlug}\``, `Generated at: ${new Date().toISOString()}`, ""];
Expand All @@ -145,4 +89,4 @@ async function main() {
core.info(`Created issue #${createdIssue.data.number}: ${createdIssue.data.html_url}`);
}

module.exports = { main, hasRateLimitText, runRangeReport, normalizeReportMarkdown };
module.exports = { main, readCachedRangeReport, normalizeReportMarkdown };
49 changes: 20 additions & 29 deletions actions/setup/js/run_activity_report.test.cjs
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
// @ts-check
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";

describe("run_activity_report", () => {
let originalGlobals;
let originalEnv;
let mockCore;
let mockGithub;
let mockContext;
let mockExec;
let tempOutputDir;

beforeEach(() => {
originalEnv = { ...process.env };
process.env.GH_AW_CMD_PREFIX = "gh aw";
tempOutputDir = path.join(os.tmpdir(), `run-activity-report-${Date.now()}-${Math.random().toString(36).slice(2)}`);
process.env.GH_AW_ACTIVITY_REPORT_OUTPUT_DIR = tempOutputDir;

originalGlobals = {
core: global.core,
github: global.github,
context: global.context,
exec: global.exec,
};

mockCore = {
Expand All @@ -39,44 +42,29 @@ describe("run_activity_report", () => {
repo: "testrepo",
},
};
mockExec = {
getExecOutput: vi.fn(),
};

global.core = mockCore;
global.github = mockGithub;
global.context = mockContext;
global.exec = mockExec;
});

afterEach(() => {
afterEach(async () => {
process.env = originalEnv;
global.core = originalGlobals.core;
global.github = originalGlobals.github;
global.context = originalGlobals.context;
global.exec = originalGlobals.exec;
await fs.rm(tempOutputDir, { recursive: true, force: true });
vi.clearAllMocks();
});

it("creates an activity report issue with 24h and 7d time ranges", async () => {
mockExec.getExecOutput.mockResolvedValueOnce({ stdout: "## 24h report\nok", stderr: "", exitCode: 0 }).mockResolvedValueOnce({ stdout: "## 7d report\nok", stderr: "", exitCode: 0 });
it("creates an activity report issue using cached 24h and 7d reports", async () => {
await fs.mkdir(path.join(tempOutputDir, "activity-report"), { recursive: true });
await fs.writeFile(path.join(tempOutputDir, "activity-report", "24h.md"), "## 24h report\nok\n", "utf8");
await fs.writeFile(path.join(tempOutputDir, "activity-report", "7d.md"), "## 7d report\nok\n", "utf8");

const { main } = await import("./run_activity_report.cjs");
await main();

expect(mockExec.getExecOutput).toHaveBeenCalledTimes(2);
expect(mockExec.getExecOutput).toHaveBeenNthCalledWith(
1,
"gh",
expect.arrayContaining(["aw", "logs", "--repo", "testowner/testrepo", "--start-date", "-1d", "--count", "1000", "--output", "./.cache/gh-aw/activity-report-logs", "--format", "markdown"]),
expect.objectContaining({ ignoreReturnCode: true })
);
expect(mockExec.getExecOutput).toHaveBeenNthCalledWith(
2,
"gh",
expect.arrayContaining(["aw", "logs", "--repo", "testowner/testrepo", "--start-date", "-1w", "--count", "1000", "--output", "./.cache/gh-aw/activity-report-logs", "--format", "markdown"]),
expect.objectContaining({ ignoreReturnCode: true })
);
expect(mockGithub.rest.issues.create).toHaveBeenCalledWith(
expect.objectContaining({
owner: "testowner",
Expand All @@ -95,11 +83,14 @@ describe("run_activity_report", () => {
expect(issueBody).toContain("#### 24h report");
});

it("detects rate limit text helper", async () => {
const { hasRateLimitText } = await import("./run_activity_report.cjs");
expect(hasRateLimitText("API rate limit exceeded")).toBe(true);
expect(hasRateLimitText("secondary rate limit")).toBe(true);
expect(hasRateLimitText("normal output")).toBe(false);
it("uses fallback text when cached range reports are missing", async () => {
const { main } = await import("./run_activity_report.cjs");
await main();

const issueBody = mockGithub.rest.issues.create.mock.calls[0][0].body;
expect(issueBody).toContain("<summary>Last 24 hours</summary>");
expect(issueBody).toContain("_No cached trace index is available for this range yet._");
expect(mockCore.warning).toHaveBeenCalled();
});

it("demotes report headings by two levels", async () => {
Expand Down
111 changes: 111 additions & 0 deletions actions/setup/js/run_trace_indexer.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @ts-check
/// <reference types="@actions/github-script" />

const fs = require("node:fs/promises");
const path = require("node:path");

const { getErrorMessage, isRateLimitError } = require("./error_helpers.cjs");
const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

const REPORT_COUNT = 1000;
const DEFAULT_TRACE_OUTPUT_DIR = "./.cache/gh-aw/agentic-workflow-logs";
const REPORT_SECTION_DIR = "activity-report";

/** @typedef {{ key: string, heading: string, startDate: string }} TraceRange */

/** @type {TraceRange[]} */
const TRACE_RANGES = [
{ key: "24h", heading: "Last 24 hours", startDate: "-1d" },
{ key: "7d", heading: "Last 7 days", startDate: "-1w" },
];

/**
* @param {string} text
* @returns {boolean}
*/
function hasRateLimitText(text) {
return /\bapi rate limit\b|\brate limit exceeded\b|\bsecondary rate limit\b|\b429\b/i.test(text);
}

/**
* @param {string} filePath
* @param {string} content
* @returns {Promise<void>}
*/
async function writeReportSection(filePath, content) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, `${content.trim()}\n`, "utf8");
}

/**
* @param {string} bin
* @param {string[]} prefixArgs
* @param {string} repoSlug
* @param {string} outputDir
* @param {TraceRange} range
* @returns {Promise<boolean>}
*/
async function runTraceRange(bin, prefixArgs, repoSlug, outputDir, range) {
const rangeReportPath = path.join(outputDir, REPORT_SECTION_DIR, `${range.key}.md`);
const args = [...prefixArgs, "logs", "--repo", repoSlug, "--start-date", range.startDate, "--count", String(REPORT_COUNT), "--output", outputDir, "--format", "markdown"];
core.info(`Running trace indexer: ${bin} ${args.join(" ")}`);

try {
const result = await exec.getExecOutput(bin, args, { ignoreReturnCode: true });
const stdout = (result.stdout || "").trim();
const stderr = (result.stderr || "").trim();
const output = `${stdout}\n${stderr}`.trim();
const rateLimited = hasRateLimitText(output);

if (result.exitCode === 0 && stdout) {
await writeReportSection(rangeReportPath, sanitizeContent(stdout));
return true;
}

if (rateLimited) {
await writeReportSection(rangeReportPath, "_Could not refresh this range due to GitHub API rate limiting._");
return false;
}

await writeReportSection(rangeReportPath, `_Trace indexing failed (exit code ${result.exitCode})._\n\n\`\`\`\n${sanitizeContent(output || "No command output was captured.")}\n\`\`\``);
return false;
} catch (error) {
const errorMessage = getErrorMessage(error);
const rateLimited = isRateLimitError(error) || hasRateLimitText(errorMessage);
if (rateLimited) {
await writeReportSection(rangeReportPath, "_Could not refresh this range due to GitHub API rate limiting._");
return false;
}
await writeReportSection(rangeReportPath, `_Trace indexing failed: ${sanitizeContent(errorMessage)}_`);
return false;
}
}

/**
* Refresh cached logs and report sections for activity reporting.
* @returns {Promise<void>}
*/
async function main() {
const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw";
const traceOutputDir = process.env.GH_AW_TRACE_INDEX_OUTPUT_DIR || DEFAULT_TRACE_OUTPUT_DIR;
const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean);
const { owner, repo } = resolveExecutionOwnerRepo();
const repoSlug = `${owner}/${repo}`;

core.info(`Refreshing agentic workflow logs cache for ${repoSlug}`);

let allRangesSucceeded = true;
for (const range of TRACE_RANGES) {
const ok = await runTraceRange(bin, prefixArgs, repoSlug, traceOutputDir, range);
if (!ok) {
allRangesSucceeded = false;
}
}

if (!allRangesSucceeded) {
throw new Error("Trace indexing completed with one or more range failures");
}
}

module.exports = { main, hasRateLimitText, runTraceRange };
Loading