-
Notifications
You must be signed in to change notification settings - Fork 367
Add activity_report operation to agentic maintenance workflow #27212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
c18c480
feat: add maintenance activity report operation
Copilot 833a7c5
refactor: simplify activity report markdown assembly
Copilot f18edc4
fix: use dashed activity-report operation and new status issue title
Copilot bb16c88
fix: use activity_report operation naming for consistency
Copilot 4dacf4b
fix: improve activity report markdown structure and query depth
Copilot a039b67
test: strengthen heading normalization coverage for activity report
Copilot ba18793
feat: increase activity report depth and cache downloaded logs
Copilot 9868704
chore: plan timeout update for activity report job
Copilot 0bf0d88
feat: set 2-hour timeout for activity report jobs
Copilot ea685c6
chore: keep activity report cache pin stable in workflow yaml
Copilot 116ffb9
chore: plan cache key update for latest restore behavior
Copilot f74b406
feat: use per-run cache keys for activity report logs
Copilot aa64a46
chore: use github.run_id in activity report cache keys
Copilot 7b8183f
chore: remove 30 day activity report section
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
activity_reportjob checks out the repo and builds/runs tooling, but its job-levelpermissions:omitscontents: read. Whenpermissionsis set, unspecified scopes default tonone, which can causeactions/checkout(and any git operations) to fail on private repos. Addcontents: read(matching e.g. thecreate_labelsjob) to this job’s permissions.