diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 59d99c41a74..226acf666f4 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -50,6 +50,7 @@ on: - 'upgrade' - 'safe_outputs' - 'create_labels' + - 'activity_report' - 'close_agentic_workflows_issues' - 'clean_cache_memories' - 'validate' @@ -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: '' @@ -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 @@ -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 + 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 diff --git a/actions/setup/js/run_activity_report.cjs b/actions/setup/js/run_activity_report.cjs new file mode 100644 index 00000000000..27cd0cb920a --- /dev/null +++ b/actions/setup/js/run_activity_report.cjs @@ -0,0 +1,148 @@ +// @ts-check +/// + +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} + */ +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 => ["
", `${section.heading}`, "", section.body, "", "
", ""]); + 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 }; diff --git a/actions/setup/js/run_activity_report.test.cjs b/actions/setup/js/run_activity_report.test.cjs new file mode 100644 index 00000000000..e858ee64d48 --- /dev/null +++ b/actions/setup/js/run_activity_report.test.cjs @@ -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("
"); + expect(issueBody).toContain("Last 24 hours"); + expect(issueBody).toContain("Last 7 days"); + expect(issueBody).not.toContain("Last 30 days"); + 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"); + }); +}); diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 985d9b5a813..a8174de8eb0 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -282,9 +282,10 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { yaml := string(content) operationSkipCondition := `github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == ''` - operationRunCondition := `(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'` + operationRunCondition := `(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'` applySafeOutputsCondition := `(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'safe_outputs'` createLabelsCondition := `(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'create_labels'` + activityReportCondition := `(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'activity_report'` closeAgenticWorkflowIssuesCondition := `(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'close_agentic_workflows_issues'` cleanCacheMemoriesCondition := `github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '' || inputs.operation == 'clean_cache_memories'` @@ -367,6 +368,33 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } + // activity_report job should be triggered when operation == 'activity_report' + activityReportIdx := strings.Index(yaml, "\n activity_report:") + if activityReportIdx == -1 { + t.Errorf("Job activity_report not found in generated workflow") + } else { + activityReportSection := yaml[activityReportIdx : activityReportIdx+runOpSectionSearchRange] + if !strings.Contains(activityReportSection, activityReportCondition) { + t.Errorf("Job activity_report should have the activation condition %q in:\n%s", activityReportCondition, activityReportSection) + } + if !strings.Contains(activityReportSection, "contents: read") { + t.Errorf("Job activity_report should include contents: read permission in:\n%s", activityReportSection) + } + if !strings.Contains(activityReportSection, "timeout-minutes: 120") { + t.Errorf("Job activity_report should set timeout-minutes: 120 in:\n%s", activityReportSection) + } + } + if !strings.Contains(yaml, "Cache activity report logs") { + t.Errorf("Job activity_report should include a cache step in:\n%s", yaml) + } + if !strings.Contains(yaml, "${{ github.run_id }}") { + t.Errorf("Job activity_report cache key should include run_id for latest-cache resolution in:\n%s", yaml) + } + + if !strings.Contains(yaml, "GH_AW_ACTIVITY_REPORT_OUTPUT_DIR: ./.cache/gh-aw/activity-report-logs") { + t.Errorf("Job activity_report should set GH_AW_ACTIVITY_REPORT_OUTPUT_DIR in:\n%s", yaml) + } + // close_agentic_workflows_issues job should be triggered when operation == 'close_agentic_workflows_issues' closeAgenticWorkflowIssuesIdx := strings.Index(yaml, "\n close_agentic_workflows_issues:") if closeAgenticWorkflowIssuesIdx == -1 { @@ -398,6 +426,11 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { t.Error("workflow_dispatch operation choices should include 'validate'") } + // Verify activity_report is an option in the operation choices + if !strings.Contains(yaml, "- 'activity_report'") { + t.Error("workflow_dispatch operation choices should include 'activity_report'") + } + // Verify close_agentic_workflows_issues is an option in the operation choices if !strings.Contains(yaml, "- 'close_agentic_workflows_issues'") { t.Error("workflow_dispatch operation choices should include 'close_agentic_workflows_issues'") @@ -430,9 +463,10 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { // Verify run_operation job exposes outputs runOpIdx2 := strings.Index(yaml, "\n run_operation:") if runOpIdx2 != -1 { - runOpSection2 := yaml[runOpIdx2 : runOpIdx2+600] + runOpEnd := min(runOpIdx2+1200, len(yaml)) + runOpSection2 := yaml[runOpIdx2:runOpEnd] if !strings.Contains(runOpSection2, "outputs:\n operation: ${{ steps.record.outputs.operation }}") { - t.Errorf("run_operation job should declare operation output, got:\n%s", runOpSection2[:300]) + t.Errorf("run_operation job should declare operation output, got:\n%s", runOpSection2[:min(300, len(runOpSection2))]) } } @@ -678,12 +712,12 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Fatalf("Expected maintenance workflow to be generated: %v", err) } yaml := string(content) - // run_operation, create_labels, validate_workflows, and compile_workflows should use the same setup-go version - // (all use getActionPin, not hardcoded pins). Exactly 4 occurrences expected. + // run_operation, create_labels, activity_report, validate_workflows, and compile_workflows should use the same setup-go version + // (all use getActionPin, not hardcoded pins). Exactly 5 occurrences expected. setupGoPin := getActionPin("actions/setup-go") occurrences := strings.Count(yaml, setupGoPin) - if occurrences != 4 { - t.Errorf("Expected exactly 4 occurrences of pinned setup-go ref %q (run_operation + create_labels + validate_workflows + compile_workflows), got %d in:\n%s", + if occurrences != 5 { + t.Errorf("Expected exactly 5 occurrences of pinned setup-go ref %q (run_operation + create_labels + activity_report + validate_workflows + compile_workflows), got %d in:\n%s", setupGoPin, occurrences, yaml) } }) @@ -1143,6 +1177,9 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { if !strings.Contains(contentStr, "create_labels") { t.Errorf("Side-repo maintenance should include create_labels job, got content length %d", len(contentStr)) } + if !strings.Contains(contentStr, "activity_report") { + t.Errorf("Side-repo maintenance should include activity_report job, got content length %d", len(contentStr)) + } }) t.Run("no side-repo file generated when no current checkout", func(t *testing.T) { @@ -1172,7 +1209,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { } }) - t.Run("side-repo generated without expires uses safe_outputs and create_labels only", func(t *testing.T) { + t.Run("side-repo generated without expires uses safe_outputs, create_labels, and activity_report", func(t *testing.T) { tmpDir := t.TempDir() workflowDataList := []*WorkflowData{ { @@ -1213,6 +1250,9 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { if strings.Contains(contentStr, "close-expired-entities") { t.Errorf("Side-repo maintenance should NOT include close-expired-entities when no expires, got content length %d", len(contentStr)) } + if !strings.Contains(contentStr, "activity_report") { + t.Errorf("Side-repo maintenance should include activity_report when no expires, got content length %d", len(contentStr)) + } }) t.Run("expression-based repository does not generate side-repo maintenance", func(t *testing.T) { diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 0415c3de54f..91a701fb911 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -59,6 +59,7 @@ on: - 'upgrade' - 'safe_outputs' - 'create_labels' + - 'activity_report' - 'close_agentic_workflows_issues' - 'clean_cache_memories' - 'validate' @@ -70,7 +71,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: '' @@ -195,8 +196,8 @@ jobs: `) // Add unified run_operation job for all dispatch operations except those with dedicated jobs - // (safe_outputs, create_labels, close_agentic_workflows_issues, clean_cache_memories, validate) - runOperationCondition := buildRunOperationCondition("safe_outputs", "create_labels", "close_agentic_workflows_issues", "clean_cache_memories", "validate") + // (safe_outputs, create_labels, activity_report, close_agentic_workflows_issues, clean_cache_memories, validate) + runOperationCondition := buildRunOperationCondition("safe_outputs", "create_labels", "activity_report", "close_agentic_workflows_issues", "clean_cache_memories", "validate") yaml.WriteString(` run_operation: if: ${{ ` + RenderCondition(runOperationCondition) + ` }} @@ -349,6 +350,64 @@ jobs: await main(); `) + // Add activity_report job for workflow_dispatch with operation == 'activity_report' + yaml.WriteString(` + activity_report: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("activity_report")) + ` }} + runs-on: ` + runsOnValue + ` + timeout-minutes: 120 + permissions: + actions: read + contents: read + issues: write + steps: + - name: Checkout repository + uses: ` + getActionPin("actions/checkout") + ` + with: + persist-credentials: false + + - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + 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(); + +`) + + yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(` - name: Cache activity report logs + uses: ` + getActionPin("actions/cache") + ` + 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- +`) + yaml.WriteString(` - name: Generate agentic workflow activity report + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` + 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(); +`) + // Add close_agentic_workflows_issues job for workflow_dispatch with operation == 'close_agentic_workflows_issues' yaml.WriteString(` close_agentic_workflows_issues: diff --git a/pkg/workflow/side_repo_maintenance.go b/pkg/workflow/side_repo_maintenance.go index 4e8aa64082a..a490ee7a171 100644 --- a/pkg/workflow/side_repo_maintenance.go +++ b/pkg/workflow/side_repo_maintenance.go @@ -216,6 +216,7 @@ on: - '' - 'safe_outputs' - 'create_labels' + - 'activity_report' - 'validate' run_url: description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345). Required when operation is safe_outputs.' @@ -225,7 +226,7 @@ on: workflow_call: inputs: operation: - description: 'Optional maintenance operation to run (safe_outputs, create_labels, validate)' + description: 'Optional maintenance operation to run (safe_outputs, create_labels, activity_report, validate)' required: false type: string default: '' @@ -424,6 +425,65 @@ jobs: await main(); `) + // Add activity_report job for workflow_dispatch/workflow_call with operation == 'activity_report' + yaml.WriteString(` + activity_report: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("activity_report")) + ` }} + runs-on: ` + runsOnValue + ` + timeout-minutes: 120 + permissions: + actions: read + contents: read + issues: write + steps: + - name: Checkout repository + uses: ` + getActionPin("actions/checkout") + ` + with: + persist-credentials: false + + - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + 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(); + +`) + + yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(` - name: Cache activity report logs + uses: ` + getActionPin("actions/cache") + ` + with: + path: ./.cache/gh-aw/activity-report-logs + key: ${{ runner.os }}-activity-report-logs-` + repoSlug + `-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-activity-report-logs-` + repoSlug + `- + ${{ runner.os }}-activity-report-logs- +`) + yaml.WriteString(` - name: Generate agentic workflow activity report in target repository + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ` + token + ` + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` + GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" + GH_AW_ACTIVITY_REPORT_OUTPUT_DIR: ./.cache/gh-aw/activity-report-logs + with: + github-token: ` + 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(); +`) + // Add validate_workflows job for workflow_dispatch/workflow_call with operation == 'validate' validateRunsOnValue := FormatRunsOn(nil, "ubuntu-latest") yaml.WriteString(` diff --git a/pkg/workflow/side_repo_maintenance_integration_test.go b/pkg/workflow/side_repo_maintenance_integration_test.go index dda9d21e4ef..9cdfcb3cec6 100644 --- a/pkg/workflow/side_repo_maintenance_integration_test.go +++ b/pkg/workflow/side_repo_maintenance_integration_test.go @@ -87,6 +87,20 @@ This workflow operates on a separate repository. assert.Contains(t, contentStr, "create_labels:", "generated workflow should include create_labels job") + // Must have activity_report job. + assert.Contains(t, contentStr, "activity_report:", + "generated workflow should include activity_report job") + assert.Contains(t, contentStr, "Cache activity report logs", + "generated workflow should include cache step for activity_report logs") + assert.Contains(t, contentStr, "GH_AW_ACTIVITY_REPORT_OUTPUT_DIR: ./.cache/gh-aw/activity-report-logs", + "generated workflow should set GH_AW_ACTIVITY_REPORT_OUTPUT_DIR for activity_report logs") + assert.Contains(t, contentStr, "actions: read\n contents: read\n issues: write", + "activity_report job should include contents: read with explicit permissions") + assert.Contains(t, contentStr, "timeout-minutes: 120", + "activity_report job should include a 2 hour timeout") + assert.Contains(t, contentStr, "${{ github.run_id }}", + "activity_report cache key should include run id for latest-cache resolution") + // GH_AW_TARGET_REPO_SLUG must be wired with the correct slug. assert.Contains(t, contentStr, `GH_AW_TARGET_REPO_SLUG: "my-org/target-repo"`, "GH_AW_TARGET_REPO_SLUG should be set to the target repo slug")