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
65 changes: 63 additions & 2 deletions .github/workflows/agentics-maintenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ on:
- 'upgrade'
- 'safe_outputs'
- 'create_labels'
- 'activity_report'
- 'close_agentic_workflows_issues'
- 'clean_cache_memories'
- 'validate'
Expand All @@ -61,7 +62,7 @@ on:
workflow_call:
inputs:
operation:
description: 'Optional maintenance operation to run (disable, enable, update, upgrade, safe_outputs, create_labels, close_agentic_workflows_issues, clean_cache_memories, validate)'
description: 'Optional maintenance operation to run (disable, enable, update, upgrade, safe_outputs, create_labels, activity_report, close_agentic_workflows_issues, clean_cache_memories, validate)'
required: false
type: string
default: ''
Expand Down Expand Up @@ -156,7 +157,7 @@ jobs:
await main();

run_operation:
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation != '' && inputs.operation != 'safe_outputs' && inputs.operation != 'create_labels' && inputs.operation != 'close_agentic_workflows_issues' && inputs.operation != 'clean_cache_memories' && inputs.operation != 'validate' && (!(github.event.repository.fork)) }}
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation != '' && inputs.operation != 'safe_outputs' && inputs.operation != 'create_labels' && inputs.operation != 'activity_report' && inputs.operation != 'close_agentic_workflows_issues' && inputs.operation != 'clean_cache_memories' && inputs.operation != 'validate' && (!(github.event.repository.fork)) }}
runs-on: ubuntu-slim
permissions:
actions: write
Expand Down Expand Up @@ -311,6 +312,66 @@ jobs:
const { main } = require('${{ runner.temp }}/gh-aw/actions/create_labels.cjs');
await main();

activity_report:
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'activity_report' && (!(github.event.repository.fork)) }}
runs-on: ubuntu-slim
timeout-minutes: 120
permissions:
actions: read
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The activity_report job checks out the repo and builds/runs tooling, but its job-level permissions: omits contents: read. When permissions is set, unspecified scopes default to none, which can cause actions/checkout (and any git operations) to fail on private repos. Add contents: read (matching e.g. the create_labels job) to this job’s permissions.

Suggested change
actions: read
actions: read
contents: read

Copilot uses AI. Check for mistakes.
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Setup Scripts
uses: ./actions/setup
with:
destination: ${{ runner.temp }}/gh-aw/actions

- name: Check admin/maintainer permissions
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io, getOctokit);
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs');
await main();

- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true

- name: Build gh-aw
run: make build

- name: Cache activity report logs
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ./.cache/gh-aw/activity-report-logs
key: ${{ runner.os }}-activity-report-logs-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-activity-report-logs-${{ github.repository }}-
${{ runner.os }}-activity-report-logs-
- name: Generate agentic workflow activity report
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_AW_CMD_PREFIX: ./gh-aw
GH_AW_ACTIVITY_REPORT_OUTPUT_DIR: ./.cache/gh-aw/activity-report-logs
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io, getOctokit);
const { main } = require('${{ runner.temp }}/gh-aw/actions/run_activity_report.cjs');
await main();

close_agentic_workflows_issues:
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'close_agentic_workflows_issues' && (!(github.event.repository.fork)) }}
runs-on: ubuntu-slim
Expand Down
148 changes: 148 additions & 0 deletions actions/setup/js/run_activity_report.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { getErrorMessage, isRateLimitError } = require("./error_helpers.cjs");
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";

/** @typedef {{ key: string, heading: string, startDate: string, optionalOnRateLimit: boolean }} 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 },
];

/**
* @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.
*
* @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(" ")}`);

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._",
};
}

return {
heading: range.heading,
body: `_Report command failed (exit code ${result.exitCode})._\n\n\`\`\`\n${sanitizeContent(output || "No command output was captured.")}\n\`\`\``,
};
} 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._",
};
}

return {
heading: range.heading,
body: `_Report command failed: ${sanitizeContent(errorMessage)}_`,
};
}
}

/**
* Normalize report markdown for issue rendering.
* Demotes headings so top-level report headings start at H3.
*
* @param {string} markdown
* @returns {string}
*/
function normalizeReportMarkdown(markdown) {
return markdown.replace(/^(#{1,6})\s+/gm, (_, hashes) => {
const headingLevel = hashes.length;
const demotedHeadingLevel = Math.min(6, headingLevel + HEADING_DEMOTION_LEVELS);
return `${"#".repeat(demotedHeadingLevel)} `;
});
}

/**
* Generate an agentic workflow activity report issue.
* @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}`);

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

const headerLines = ["### Agentic workflow activity report", "", `Repository: \`${repoSlug}\``, `Generated at: ${new Date().toISOString()}`, ""];
const sectionLines = sections.flatMap(section => ["<details>", `<summary>${section.heading}</summary>`, "", section.body, "", "</details>", ""]);
const body = [...headerLines, ...sectionLines].join("\n");

const createdIssue = await github.rest.issues.create({
owner,
repo,
title: ISSUE_TITLE,
body,
labels: ["agentic-workflows"],
});

core.info(`Created issue #${createdIssue.data.number}: ${createdIssue.data.html_url}`);
}

module.exports = { main, hasRateLimitText, runRangeReport, normalizeReportMarkdown };
112 changes: 112 additions & 0 deletions actions/setup/js/run_activity_report.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @ts-check
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

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

beforeEach(() => {
originalEnv = { ...process.env };
process.env.GH_AW_CMD_PREFIX = "gh aw";

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

mockCore = {
info: vi.fn(),
warning: vi.fn(),
};
mockGithub = {
rest: {
issues: {
create: vi.fn().mockResolvedValue({
data: { number: 42, html_url: "https://github.com/testowner/testrepo/issues/42" },
}),
},
},
};
mockContext = {
repo: {
owner: "testowner",
repo: "testrepo",
},
};
mockExec = {
getExecOutput: vi.fn(),
};

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

afterEach(() => {
process.env = originalEnv;
global.core = originalGlobals.core;
global.github = originalGlobals.github;
global.context = originalGlobals.context;
global.exec = originalGlobals.exec;
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 });

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",
repo: "testrepo",
title: "[aw] agentic status report",
labels: ["agentic-workflows"],
})
);

const issueBody = mockGithub.rest.issues.create.mock.calls[0][0].body;
expect(issueBody).toContain("### Agentic workflow activity report");
expect(issueBody).toContain("<details>");
expect(issueBody).toContain("<summary>Last 24 hours</summary>");
expect(issueBody).toContain("<summary>Last 7 days</summary>");
expect(issueBody).not.toContain("<summary>Last 30 days</summary>");
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("demotes report headings by two levels", async () => {
const { normalizeReportMarkdown } = await import("./run_activity_report.cjs");
const transformed = normalizeReportMarkdown("# H1\n## H2\n### H3");
expect(transformed).toContain("### H1");
expect(transformed).toContain("#### H2");
expect(transformed).toContain("##### H3");
});
});
Loading