Skip to content

Commit 5285e62

Browse files
authored
Add activity_report operation to agentic maintenance workflow (#27212)
1 parent e9a50ed commit 5285e62

7 files changed

Lines changed: 508 additions & 14 deletions

.github/workflows/agentics-maintenance.yml

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ on:
5050
- 'upgrade'
5151
- 'safe_outputs'
5252
- 'create_labels'
53+
- 'activity_report'
5354
- 'close_agentic_workflows_issues'
5455
- 'clean_cache_memories'
5556
- 'validate'
@@ -61,7 +62,7 @@ on:
6162
workflow_call:
6263
inputs:
6364
operation:
64-
description: 'Optional maintenance operation to run (disable, enable, update, upgrade, safe_outputs, create_labels, close_agentic_workflows_issues, clean_cache_memories, validate)'
65+
description: 'Optional maintenance operation to run (disable, enable, update, upgrade, safe_outputs, create_labels, activity_report, close_agentic_workflows_issues, clean_cache_memories, validate)'
6566
required: false
6667
type: string
6768
default: ''
@@ -156,7 +157,7 @@ jobs:
156157
await main();
157158
158159
run_operation:
159-
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)) }}
160+
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)) }}
160161
runs-on: ubuntu-slim
161162
permissions:
162163
actions: write
@@ -311,6 +312,66 @@ jobs:
311312
const { main } = require('${{ runner.temp }}/gh-aw/actions/create_labels.cjs');
312313
await main();
313314
315+
activity_report:
316+
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'activity_report' && (!(github.event.repository.fork)) }}
317+
runs-on: ubuntu-slim
318+
timeout-minutes: 120
319+
permissions:
320+
actions: read
321+
contents: read
322+
issues: write
323+
steps:
324+
- name: Checkout repository
325+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
326+
with:
327+
persist-credentials: false
328+
329+
- name: Setup Scripts
330+
uses: ./actions/setup
331+
with:
332+
destination: ${{ runner.temp }}/gh-aw/actions
333+
334+
- name: Check admin/maintainer permissions
335+
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
336+
with:
337+
github-token: ${{ secrets.GITHUB_TOKEN }}
338+
script: |
339+
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
340+
setupGlobals(core, github, context, exec, io, getOctokit);
341+
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs');
342+
await main();
343+
344+
- name: Setup Go
345+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
346+
with:
347+
go-version-file: go.mod
348+
cache: true
349+
350+
- name: Build gh-aw
351+
run: make build
352+
353+
- name: Cache activity report logs
354+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
355+
with:
356+
path: ./.cache/gh-aw/activity-report-logs
357+
key: ${{ runner.os }}-activity-report-logs-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }}
358+
restore-keys: |
359+
${{ runner.os }}-activity-report-logs-${{ github.repository }}-
360+
${{ runner.os }}-activity-report-logs-
361+
- name: Generate agentic workflow activity report
362+
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
363+
env:
364+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
365+
GH_AW_CMD_PREFIX: ./gh-aw
366+
GH_AW_ACTIVITY_REPORT_OUTPUT_DIR: ./.cache/gh-aw/activity-report-logs
367+
with:
368+
github-token: ${{ secrets.GITHUB_TOKEN }}
369+
script: |
370+
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
371+
setupGlobals(core, github, context, exec, io, getOctokit);
372+
const { main } = require('${{ runner.temp }}/gh-aw/actions/run_activity_report.cjs');
373+
await main();
374+
314375
close_agentic_workflows_issues:
315376
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'close_agentic_workflows_issues' && (!(github.event.repository.fork)) }}
316377
runs-on: ubuntu-slim
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// @ts-check
2+
/// <reference types="@actions/github-script" />
3+
4+
const { getErrorMessage, isRateLimitError } = require("./error_helpers.cjs");
5+
const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs");
6+
const { sanitizeContent } = require("./sanitize_content.cjs");
7+
8+
const ISSUE_TITLE = "[aw] agentic status report";
9+
const REPORT_COUNT = 1000;
10+
const HEADING_DEMOTION_LEVELS = 2;
11+
const DEFAULT_REPORT_OUTPUT_DIR = "./.cache/gh-aw/activity-report-logs";
12+
13+
/** @typedef {{ key: string, heading: string, startDate: string, optionalOnRateLimit: boolean }} ActivityRange */
14+
15+
/** @type {ActivityRange[]} */
16+
const REPORT_RANGES = [
17+
{ key: "24h", heading: "Last 24 hours", startDate: "-1d", optionalOnRateLimit: false },
18+
{ key: "7d", heading: "Last 7 days", startDate: "-1w", optionalOnRateLimit: false },
19+
];
20+
21+
/**
22+
* @param {string} text
23+
* @returns {boolean}
24+
*/
25+
function hasRateLimitText(text) {
26+
return /\bapi rate limit\b|\brate limit exceeded\b|\bsecondary rate limit\b|\b429\b/i.test(text);
27+
}
28+
29+
/**
30+
* Run the logs command for a configured report range.
31+
*
32+
* @param {string} bin
33+
* @param {string[]} prefixArgs
34+
* @param {string} repoSlug
35+
* @param {ActivityRange} range
36+
* @param {string} outputDir
37+
* @returns {Promise<{ heading: string, body: string }>}
38+
*/
39+
async function runRangeReport(bin, prefixArgs, repoSlug, range, outputDir) {
40+
const args = [...prefixArgs, "logs", "--repo", repoSlug, "--start-date", range.startDate, "--count", String(REPORT_COUNT), "--output", outputDir, "--format", "markdown"];
41+
core.info(`Running: ${bin} ${args.join(" ")}`);
42+
43+
try {
44+
const result = await exec.getExecOutput(bin, args, { ignoreReturnCode: true });
45+
const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
46+
const rateLimited = hasRateLimitText(output);
47+
48+
if (result.exitCode === 0 && result.stdout.trim()) {
49+
return {
50+
heading: range.heading,
51+
body: normalizeReportMarkdown(sanitizeContent(result.stdout.trim())),
52+
};
53+
}
54+
55+
if (rateLimited && range.optionalOnRateLimit) {
56+
core.warning(`Skipping ${range.heading} report due to GitHub API rate limiting`);
57+
return {
58+
heading: range.heading,
59+
body: "_Skipped due to GitHub API rate limiting._",
60+
};
61+
}
62+
63+
if (rateLimited) {
64+
return {
65+
heading: range.heading,
66+
body: "_Could not generate this section due to GitHub API rate limiting._",
67+
};
68+
}
69+
70+
return {
71+
heading: range.heading,
72+
body: `_Report command failed (exit code ${result.exitCode})._\n\n\`\`\`\n${sanitizeContent(output || "No command output was captured.")}\n\`\`\``,
73+
};
74+
} catch (error) {
75+
const errorMessage = getErrorMessage(error);
76+
const rateLimited = isRateLimitError(error) || hasRateLimitText(errorMessage);
77+
78+
if (rateLimited && range.optionalOnRateLimit) {
79+
core.warning(`Skipping ${range.heading} report due to GitHub API rate limiting`);
80+
return {
81+
heading: range.heading,
82+
body: "_Skipped due to GitHub API rate limiting._",
83+
};
84+
}
85+
86+
if (rateLimited) {
87+
return {
88+
heading: range.heading,
89+
body: "_Could not generate this section due to GitHub API rate limiting._",
90+
};
91+
}
92+
93+
return {
94+
heading: range.heading,
95+
body: `_Report command failed: ${sanitizeContent(errorMessage)}_`,
96+
};
97+
}
98+
}
99+
100+
/**
101+
* Normalize report markdown for issue rendering.
102+
* Demotes headings so top-level report headings start at H3.
103+
*
104+
* @param {string} markdown
105+
* @returns {string}
106+
*/
107+
function normalizeReportMarkdown(markdown) {
108+
return markdown.replace(/^(#{1,6})\s+/gm, (_, hashes) => {
109+
const headingLevel = hashes.length;
110+
const demotedHeadingLevel = Math.min(6, headingLevel + HEADING_DEMOTION_LEVELS);
111+
return `${"#".repeat(demotedHeadingLevel)} `;
112+
});
113+
}
114+
115+
/**
116+
* Generate an agentic workflow activity report issue.
117+
* @returns {Promise<void>}
118+
*/
119+
async function main() {
120+
const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw";
121+
const reportOutputDir = process.env.GH_AW_ACTIVITY_REPORT_OUTPUT_DIR || DEFAULT_REPORT_OUTPUT_DIR;
122+
const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean);
123+
const { owner, repo } = resolveExecutionOwnerRepo();
124+
const repoSlug = `${owner}/${repo}`;
125+
126+
core.info(`Generating agentic workflow activity report for ${repoSlug}`);
127+
128+
const sections = [];
129+
for (const range of REPORT_RANGES) {
130+
sections.push(await runRangeReport(bin, prefixArgs, repoSlug, range, reportOutputDir));
131+
}
132+
133+
const headerLines = ["### Agentic workflow activity report", "", `Repository: \`${repoSlug}\``, `Generated at: ${new Date().toISOString()}`, ""];
134+
const sectionLines = sections.flatMap(section => ["<details>", `<summary>${section.heading}</summary>`, "", section.body, "", "</details>", ""]);
135+
const body = [...headerLines, ...sectionLines].join("\n");
136+
137+
const createdIssue = await github.rest.issues.create({
138+
owner,
139+
repo,
140+
title: ISSUE_TITLE,
141+
body,
142+
labels: ["agentic-workflows"],
143+
});
144+
145+
core.info(`Created issue #${createdIssue.data.number}: ${createdIssue.data.html_url}`);
146+
}
147+
148+
module.exports = { main, hasRateLimitText, runRangeReport, normalizeReportMarkdown };
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// @ts-check
2+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
3+
4+
describe("run_activity_report", () => {
5+
let originalGlobals;
6+
let originalEnv;
7+
let mockCore;
8+
let mockGithub;
9+
let mockContext;
10+
let mockExec;
11+
12+
beforeEach(() => {
13+
originalEnv = { ...process.env };
14+
process.env.GH_AW_CMD_PREFIX = "gh aw";
15+
16+
originalGlobals = {
17+
core: global.core,
18+
github: global.github,
19+
context: global.context,
20+
exec: global.exec,
21+
};
22+
23+
mockCore = {
24+
info: vi.fn(),
25+
warning: vi.fn(),
26+
};
27+
mockGithub = {
28+
rest: {
29+
issues: {
30+
create: vi.fn().mockResolvedValue({
31+
data: { number: 42, html_url: "https://github.com/testowner/testrepo/issues/42" },
32+
}),
33+
},
34+
},
35+
};
36+
mockContext = {
37+
repo: {
38+
owner: "testowner",
39+
repo: "testrepo",
40+
},
41+
};
42+
mockExec = {
43+
getExecOutput: vi.fn(),
44+
};
45+
46+
global.core = mockCore;
47+
global.github = mockGithub;
48+
global.context = mockContext;
49+
global.exec = mockExec;
50+
});
51+
52+
afterEach(() => {
53+
process.env = originalEnv;
54+
global.core = originalGlobals.core;
55+
global.github = originalGlobals.github;
56+
global.context = originalGlobals.context;
57+
global.exec = originalGlobals.exec;
58+
vi.clearAllMocks();
59+
});
60+
61+
it("creates an activity report issue with 24h and 7d time ranges", async () => {
62+
mockExec.getExecOutput.mockResolvedValueOnce({ stdout: "## 24h report\nok", stderr: "", exitCode: 0 }).mockResolvedValueOnce({ stdout: "## 7d report\nok", stderr: "", exitCode: 0 });
63+
64+
const { main } = await import("./run_activity_report.cjs");
65+
await main();
66+
67+
expect(mockExec.getExecOutput).toHaveBeenCalledTimes(2);
68+
expect(mockExec.getExecOutput).toHaveBeenNthCalledWith(
69+
1,
70+
"gh",
71+
expect.arrayContaining(["aw", "logs", "--repo", "testowner/testrepo", "--start-date", "-1d", "--count", "1000", "--output", "./.cache/gh-aw/activity-report-logs", "--format", "markdown"]),
72+
expect.objectContaining({ ignoreReturnCode: true })
73+
);
74+
expect(mockExec.getExecOutput).toHaveBeenNthCalledWith(
75+
2,
76+
"gh",
77+
expect.arrayContaining(["aw", "logs", "--repo", "testowner/testrepo", "--start-date", "-1w", "--count", "1000", "--output", "./.cache/gh-aw/activity-report-logs", "--format", "markdown"]),
78+
expect.objectContaining({ ignoreReturnCode: true })
79+
);
80+
expect(mockGithub.rest.issues.create).toHaveBeenCalledWith(
81+
expect.objectContaining({
82+
owner: "testowner",
83+
repo: "testrepo",
84+
title: "[aw] agentic status report",
85+
labels: ["agentic-workflows"],
86+
})
87+
);
88+
89+
const issueBody = mockGithub.rest.issues.create.mock.calls[0][0].body;
90+
expect(issueBody).toContain("### Agentic workflow activity report");
91+
expect(issueBody).toContain("<details>");
92+
expect(issueBody).toContain("<summary>Last 24 hours</summary>");
93+
expect(issueBody).toContain("<summary>Last 7 days</summary>");
94+
expect(issueBody).not.toContain("<summary>Last 30 days</summary>");
95+
expect(issueBody).toContain("#### 24h report");
96+
});
97+
98+
it("detects rate limit text helper", async () => {
99+
const { hasRateLimitText } = await import("./run_activity_report.cjs");
100+
expect(hasRateLimitText("API rate limit exceeded")).toBe(true);
101+
expect(hasRateLimitText("secondary rate limit")).toBe(true);
102+
expect(hasRateLimitText("normal output")).toBe(false);
103+
});
104+
105+
it("demotes report headings by two levels", async () => {
106+
const { normalizeReportMarkdown } = await import("./run_activity_report.cjs");
107+
const transformed = normalizeReportMarkdown("# H1\n## H2\n### H3");
108+
expect(transformed).toContain("### H1");
109+
expect(transformed).toContain("#### H2");
110+
expect(transformed).toContain("##### H3");
111+
});
112+
});

0 commit comments

Comments
 (0)