From c18c4803c5f898e9b579a48e494db001803a1a07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:01:23 +0000 Subject: [PATCH 01/10] feat: add maintenance activity report operation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/267bf5c1-a299-43c5-be20-17cdb0ab2a0c Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 54 +++++++- actions/setup/js/run_activity_report.cjs | 129 ++++++++++++++++++ actions/setup/js/run_activity_report.test.cjs | 115 ++++++++++++++++ pkg/workflow/maintenance_workflow_test.go | 40 ++++-- pkg/workflow/maintenance_workflow_yaml.go | 53 ++++++- pkg/workflow/side_repo_maintenance.go | 50 ++++++- .../side_repo_maintenance_integration_test.go | 4 + 7 files changed, 431 insertions(+), 14 deletions(-) create mode 100644 actions/setup/js/run_activity_report.cjs create mode 100644 actions/setup/js/run_activity_report.test.cjs diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 59d99c41a74..1d63f82e62b 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,55 @@ 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 + permissions: + actions: 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: Generate agentic workflow activity report + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_CMD_PREFIX: ./gh-aw + 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..85c4ea15639 --- /dev/null +++ b/actions/setup/js/run_activity_report.cjs @@ -0,0 +1,129 @@ +// @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 activity report]"; + +/** @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 }, + { key: "30d", heading: "Last 30 days", startDate: "-1mo", optionalOnRateLimit: true }, +]; + +/** + * @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 + * @returns {Promise<{ heading: string, body: string }>} + */ +async function runRangeReport(bin, prefixArgs, repoSlug, range) { + const args = [...prefixArgs, "logs", "--repo", repoSlug, "--start-date", range.startDate, "--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: 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)}_`, + }; + } +} + +/** + * Generate an agentic workflow activity report issue. + * @returns {Promise} + */ +async function main() { + const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw"; + 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)); + } + + const body = ["## Agentic workflow activity report", "", `Repository: \`${repoSlug}\``, `Generated at: ${new Date().toISOString()}`, "", ...sections.flatMap(section => ["---", "", `## ${section.heading}`, "", section.body, ""])].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 }; 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..2514e40161e --- /dev/null +++ b/actions/setup/js/run_activity_report.test.cjs @@ -0,0 +1,115 @@ +// @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 all three time ranges", async () => { + mockExec.getExecOutput + .mockResolvedValueOnce({ stdout: "## 24h report\nok", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ stdout: "## 7d report\nok", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ stdout: "## 30d report\nok", stderr: "", exitCode: 0 }); + + const { main } = await import("./run_activity_report.cjs"); + await main(); + + expect(mockExec.getExecOutput).toHaveBeenCalledTimes(3); + expect(mockExec.getExecOutput).toHaveBeenNthCalledWith(1, "gh", expect.arrayContaining(["aw", "logs", "--repo", "testowner/testrepo", "--start-date", "-1d", "--format", "markdown"]), expect.objectContaining({ ignoreReturnCode: true })); + expect(mockExec.getExecOutput).toHaveBeenNthCalledWith(2, "gh", expect.arrayContaining(["aw", "logs", "--repo", "testowner/testrepo", "--start-date", "-1w", "--format", "markdown"]), expect.objectContaining({ ignoreReturnCode: true })); + expect(mockExec.getExecOutput).toHaveBeenNthCalledWith( + 3, + "gh", + expect.arrayContaining(["aw", "logs", "--repo", "testowner/testrepo", "--start-date", "-1mo", "--format", "markdown"]), + expect.objectContaining({ ignoreReturnCode: true }) + ); + + expect(mockGithub.rest.issues.create).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "testowner", + repo: "testrepo", + title: "[AW activity report]", + labels: ["agentic-workflows"], + }) + ); + + const issueBody = mockGithub.rest.issues.create.mock.calls[0][0].body; + expect(issueBody).toContain("## Last 24 hours"); + expect(issueBody).toContain("## Last 7 days"); + expect(issueBody).toContain("## Last 30 days"); + }); + + it("skips the 30-day query when rate limited", async () => { + mockExec.getExecOutput + .mockResolvedValueOnce({ stdout: "24h", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ stdout: "7d", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ stdout: "", stderr: "API rate limit exceeded", exitCode: 1 }); + + const { main } = await import("./run_activity_report.cjs"); + await main(); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Skipping Last 30 days report")); + const issueBody = mockGithub.rest.issues.create.mock.calls[0][0].body; + expect(issueBody).toContain("Skipped due to GitHub API rate limiting."); + }); + + 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); + }); +}); diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 985d9b5a813..fcdcb1a60d5 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,17 @@ 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) + } + } + // 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 +410,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 +447,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 +696,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 +1161,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 +1193,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 +1234,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..d80023fb915 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,52 @@ 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 + ` + permissions: + actions: 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: Generate agentic workflow activity report + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` + 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..36a81474ceb 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,53 @@ 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 + ` + permissions: + actions: 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: 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 + `" + 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..64c4fc94952 100644 --- a/pkg/workflow/side_repo_maintenance_integration_test.go +++ b/pkg/workflow/side_repo_maintenance_integration_test.go @@ -87,6 +87,10 @@ 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") + // 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") From 833a7c59209f8502d39afcb1f8725db5eab998c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:06:51 +0000 Subject: [PATCH 02/10] refactor: simplify activity report markdown assembly Agent-Logs-Url: https://github.com/github/gh-aw/sessions/267bf5c1-a299-43c5-be20-17cdb0ab2a0c Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/run_activity_report.cjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/run_activity_report.cjs b/actions/setup/js/run_activity_report.cjs index 85c4ea15639..2fd1eccde36 100644 --- a/actions/setup/js/run_activity_report.cjs +++ b/actions/setup/js/run_activity_report.cjs @@ -111,9 +111,9 @@ async function main() { sections.push(await runRangeReport(bin, prefixArgs, repoSlug, range)); } - const body = ["## Agentic workflow activity report", "", `Repository: \`${repoSlug}\``, `Generated at: ${new Date().toISOString()}`, "", ...sections.flatMap(section => ["---", "", `## ${section.heading}`, "", section.body, ""])].join( - "\n" - ); + 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, From f18edc42d3b2f7a0f7ad33afe75aeaf3f58ae3f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:24:50 +0000 Subject: [PATCH 03/10] fix: use dashed activity-report operation and new status issue title Agent-Logs-Url: https://github.com/github/gh-aw/sessions/37d80ed2-4179-4462-ac6e-bbe23dc36ad5 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 8 ++++---- actions/setup/js/run_activity_report.cjs | 2 +- actions/setup/js/run_activity_report.test.cjs | 2 +- pkg/workflow/maintenance_workflow_test.go | 12 ++++++------ pkg/workflow/maintenance_workflow_yaml.go | 12 ++++++------ pkg/workflow/side_repo_maintenance.go | 8 ++++---- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 1d63f82e62b..fc2007d174d 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -50,7 +50,7 @@ on: - 'upgrade' - 'safe_outputs' - 'create_labels' - - 'activity_report' + - 'activity-report' - 'close_agentic_workflows_issues' - 'clean_cache_memories' - 'validate' @@ -62,7 +62,7 @@ on: workflow_call: inputs: operation: - description: 'Optional maintenance operation to run (disable, enable, update, upgrade, safe_outputs, create_labels, activity_report, 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: '' @@ -157,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 != 'activity_report' && 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 @@ -313,7 +313,7 @@ jobs: await main(); activity_report: - if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'activity_report' && (!(github.event.repository.fork)) }} + if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'activity-report' && (!(github.event.repository.fork)) }} runs-on: ubuntu-slim permissions: actions: read diff --git a/actions/setup/js/run_activity_report.cjs b/actions/setup/js/run_activity_report.cjs index 2fd1eccde36..09a5766fc49 100644 --- a/actions/setup/js/run_activity_report.cjs +++ b/actions/setup/js/run_activity_report.cjs @@ -5,7 +5,7 @@ const { getErrorMessage, isRateLimitError } = require("./error_helpers.cjs"); const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); -const ISSUE_TITLE = "[AW activity report]"; +const ISSUE_TITLE = "[aw] agentic status report"; /** @typedef {{ key: string, heading: string, startDate: string, optionalOnRateLimit: boolean }} ActivityRange */ diff --git a/actions/setup/js/run_activity_report.test.cjs b/actions/setup/js/run_activity_report.test.cjs index 2514e40161e..857357f9380 100644 --- a/actions/setup/js/run_activity_report.test.cjs +++ b/actions/setup/js/run_activity_report.test.cjs @@ -81,7 +81,7 @@ describe("run_activity_report", () => { expect.objectContaining({ owner: "testowner", repo: "testrepo", - title: "[AW activity report]", + title: "[aw] agentic status report", labels: ["agentic-workflows"], }) ); diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index fcdcb1a60d5..b1b33b02a34 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -282,10 +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 != 'activity_report' && 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'` + 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'` @@ -368,7 +368,7 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } - // activity_report job should be triggered when operation == 'activity_report' + // 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") @@ -410,9 +410,9 @@ 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 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 diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index d80023fb915..ae529a96275 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -59,7 +59,7 @@ on: - 'upgrade' - 'safe_outputs' - 'create_labels' - - 'activity_report' + - 'activity-report' - 'close_agentic_workflows_issues' - 'clean_cache_memories' - 'validate' @@ -71,7 +71,7 @@ on: workflow_call: inputs: operation: - description: 'Optional maintenance operation to run (disable, enable, update, upgrade, safe_outputs, create_labels, activity_report, 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: '' @@ -196,8 +196,8 @@ jobs: `) // Add unified run_operation job for all dispatch operations except those with dedicated jobs - // (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") + // (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) + ` }} @@ -350,10 +350,10 @@ jobs: await main(); `) - // Add activity_report job for workflow_dispatch with operation == 'activity_report' + // Add activity_report job for workflow_dispatch with operation == 'activity-report' yaml.WriteString(` activity_report: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("activity_report")) + ` }} + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("activity-report")) + ` }} runs-on: ` + runsOnValue + ` permissions: actions: read diff --git a/pkg/workflow/side_repo_maintenance.go b/pkg/workflow/side_repo_maintenance.go index 36a81474ceb..06007252ebc 100644 --- a/pkg/workflow/side_repo_maintenance.go +++ b/pkg/workflow/side_repo_maintenance.go @@ -216,7 +216,7 @@ on: - '' - 'safe_outputs' - 'create_labels' - - 'activity_report' + - '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.' @@ -226,7 +226,7 @@ on: workflow_call: inputs: operation: - description: 'Optional maintenance operation to run (safe_outputs, create_labels, activity_report, validate)' + description: 'Optional maintenance operation to run (safe_outputs, create_labels, activity-report, validate)' required: false type: string default: '' @@ -425,10 +425,10 @@ jobs: await main(); `) - // Add activity_report job for workflow_dispatch/workflow_call with operation == 'activity_report' + // Add activity_report job for workflow_dispatch/workflow_call with operation == 'activity-report' yaml.WriteString(` activity_report: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("activity_report")) + ` }} + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("activity-report")) + ` }} runs-on: ` + runsOnValue + ` permissions: actions: read From 3552a55ae8cf52d734b207f227505be2c679b76a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:51:47 +0000 Subject: [PATCH 04/10] feat: add fallback pull request path for push-to-pr-branch non-fast-forward failures Agent-Logs-Url: https://github.com/github/gh-aw/sessions/9f0ad012-0f23-4022-ad35-c955d90fc310 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/push_to_pull_request_branch.cjs | 54 +++++++++++++++++++ .../js/push_to_pull_request_branch.test.cjs | 41 +++++++++++++- .../setup/js/safe_output_handler_manager.cjs | 30 ++++++++--- .../js/safe_output_handler_manager.test.cjs | 28 ++++++++++ actions/setup/js/safe_output_summary.cjs | 29 ++++++---- actions/setup/js/safe_output_summary.test.cjs | 26 +++++++++ actions/setup/js/types/handler-factory.d.ts | 2 + .../docs/reference/frontmatter-full.md | 7 +++ .../reference/safe-outputs-pull-requests.md | 3 ++ pkg/parser/schemas/main_workflow_schema.json | 5 ++ pkg/workflow/push_to_pull_request_branch.go | 8 +++ .../push_to_pull_request_branch_test.go | 37 +++++++++++++ pkg/workflow/safe_outputs_permissions.go | 17 +++++- pkg/workflow/safe_outputs_permissions_test.go | 14 ++++- 14 files changed, 281 insertions(+), 20 deletions(-) diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 7869cf329f7..2337566f7c4 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -56,6 +56,7 @@ async function main(config = {}) { const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : []; const ifNoChanges = config.if_no_changes || "warn"; const ignoreMissingBranchFailure = config.ignore_missing_branch_failure === true; + const fallbackAsPullRequest = config.fallback_as_pull_request !== false; const commitTitleSuffix = config.commit_title_suffix || ""; const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024; const maxCount = config.max || 0; // 0 means no limit @@ -91,6 +92,7 @@ async function main(config = {}) { } core.info(`If no changes: ${ifNoChanges}`); core.info(`Ignore missing branch failure: ${ignoreMissingBranchFailure}`); + core.info(`Fallback as pull request: ${fallbackAsPullRequest}`); if (commitTitleSuffix) { core.info(`Commit title suffix: ${commitTitleSuffix}`); } @@ -770,6 +772,58 @@ async function main(config = {}) { core.warning(`Push failed and branch existence re-check errored for ${branchName}: ${getErrorMessage(diagnosisError)}`); } + // Fallback path for diverged branches: create a new pull request so changes + // can still be reviewed and merged into the original PR branch. + if (isNonFastForward && fallbackAsPullRequest) { + const fallbackBranchName = normalizeBranchName(`${branchName}-fallback`, String(Date.now())); + core.warning(`Non-fast-forward push detected; creating fallback pull request from '${fallbackBranchName}' to '${branchName}'`); + try { + await exec.exec("git", ["checkout", "-b", fallbackBranchName]); + await exec.exec("git", ["push", "origin", fallbackBranchName], { + env: { ...process.env, ...gitAuthEnv }, + }); + + const fallbackBody = [ + "> [!NOTE]", + "> Direct push to the original pull request branch failed because the branch diverged (non-fast-forward).", + `> Original PR branch: \`${branchName}\``, + "", + `This fallback PR contains the prepared changes for PR #${pullNumber}.`, + "Merge this fallback PR into the original PR branch to apply them.", + "", + `Workflow run: ${buildWorkflowRunUrl(context, context.repo)}`, + ].join("\n"); + + const { data: fallbackPR } = await githubClient.rest.pulls.create({ + owner: repoParts.owner, + repo: repoParts.repo, + title: `[fallback] ${prTitle || `Changes for #${pullNumber}`}`, + body: fallbackBody, + head: fallbackBranchName, + base: branchName, + }); + + core.info(`Created fallback pull request #${fallbackPR.number}: ${fallbackPR.html_url}`); + await updateActivationComment(github, context, core, fallbackPR.html_url, fallbackPR.number, "pull_request"); + + return { + success: true, + fallback_used: true, + fallback_type: "pull_request", + pull_request_number: fallbackPR.number, + pull_request_url: fallbackPR.html_url, + branch_name: fallbackBranchName, + repo: itemRepo, + number: fallbackPR.number, + url: fallbackPR.html_url, + }; + } catch (fallbackError) { + const fallbackErrorMessage = getErrorMessage(fallbackError); + core.error(`Failed to create fallback pull request: ${fallbackErrorMessage}`); + userMessage = `${userMessage} Fallback pull request creation also failed: ${fallbackErrorMessage}`; + } + } + return { success: false, error_type: "push_failed", error: userMessage }; } diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index 4fdc66c4d4d..664451be648 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -144,6 +144,12 @@ describe("push_to_pull_request_branch.cjs", () => { labels: [], }, }), + create: vi.fn().mockResolvedValue({ + data: { + number: 999, + html_url: "https://github.com/test-owner/test-repo/pull/999", + }, + }), }, repos: { get: vi.fn().mockResolvedValue({ @@ -767,7 +773,7 @@ index 0000000..abc1234 expect(mockCore.info).toHaveBeenCalledWith("Investigating patch failure..."); }); - it("should handle git push rejection (concurrent changes)", async () => { + it("should create fallback pull request on non-fast-forward push rejection by default", async () => { const patchPath = createPatchFile(); // Set up successful operations until push @@ -798,8 +804,39 @@ index 0000000..abc1234 const handler = await module.main({}); const result = await handler({ patch_path: patchPath }, {}); - // The error happens during push + expect(result.success).toBe(true); + expect(result.fallback_used).toBe(true); + expect(result.fallback_type).toBe("pull_request"); + expect(result.pull_request_number).toBe(999); + expect(mockGithub.rest.pulls.create).toHaveBeenCalled(); + }); + + it("should not create fallback pull request when fallback-as-pull-request is disabled", async () => { + const patchPath = createPatchFile(); + + mockExec.exec.mockResolvedValueOnce(0); // fetch + mockExec.exec.mockResolvedValueOnce(0); // rev-parse + mockExec.exec.mockResolvedValueOnce(0); // checkout + + mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "before-sha\n", stderr: "" }); // git rev-parse HEAD (before patch) + + mockExec.exec.mockResolvedValueOnce(0); // git am + + mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "abc123\n", stderr: "" }); // git rev-list + mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "remote-oid\trefs/heads/feature-branch\n", stderr: "" }); // git ls-remote + mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "Test commit\n", stderr: "" }); // git log -1 + mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); // git diff --name-status + + mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection")); + mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)")); + + const module = await loadModule(); + const handler = await module.main({ fallback_as_pull_request: false }); + const result = await handler({ patch_path: patchPath }, {}); + expect(result.success).toBe(false); + expect(result.error_type).toBe("push_failed"); + expect(mockGithub.rest.pulls.create).not.toHaveBeenCalled(); }); it("should diagnose deleted branch when push fails", async () => { diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 52bfc6417de..5d4bab0f453 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -363,7 +363,7 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) // Track when a code-push operation falls back to creating a review issue instead. // When set, subsequent add_comment messages will receive a correction note prepended // to their body so the posted comment accurately reflects the actual outcome. - /** @type {{type: string, issueNumber: number, issueUrl: string}|null} */ + /** @type {{type: string, fallbackTargetType: "issue" | "pull_request", number: number, url: string}|null} */ let codePushFallbackInfo = null; // Load custom safe output job types (from GH_AW_SAFE_OUTPUT_JOBS env var) @@ -481,9 +481,12 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) // If a previous code-push operation fell back to a review issue, prepend a correction note // so the posted comment accurately reflects the outcome. if (codePushFallbackInfo) { - const fallbackNote = `\n\n---\n> [!NOTE]\n> The pull request was not created — a fallback review issue was created instead due to protected file changes: [#${codePushFallbackInfo.issueNumber}](${codePushFallbackInfo.issueUrl})\n\n`; + const fallbackNote = + codePushFallbackInfo.fallbackTargetType === "pull_request" + ? `\n\n---\n> [!NOTE]\n> Direct push to the original pull request branch was not possible (diverged/non-fast-forward). A fallback pull request was created instead: [#${codePushFallbackInfo.number}](${codePushFallbackInfo.url})\n\n` + : `\n\n---\n> [!NOTE]\n> The pull request was not created — a fallback review issue was created instead due to protected file changes: [#${codePushFallbackInfo.number}](${codePushFallbackInfo.url})\n\n`; effectiveMessage = { ...effectiveMessage, body: fallbackNote + (effectiveMessage.body || "") }; - core.info(`Prepending fallback correction note to add_comment body (fallback issue: #${codePushFallbackInfo.issueNumber})`); + core.info(`Prepending fallback correction note to add_comment body (fallback ${codePushFallbackInfo.fallbackTargetType}: #${codePushFallbackInfo.number})`); } // If a previous code-push operation failed outright (e.g. patch application error), // prepend a failure warning so the status comment accurately reflects that the @@ -587,9 +590,24 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) // Track when a code-push operation falls back to a review issue so subsequent // add_comment messages can include a correction note. - if (CODE_PUSH_TYPES.has(messageType) && result && result.fallback_used === true && result.issue_number != null && result.issue_url) { - codePushFallbackInfo = { type: messageType, issueNumber: result.issue_number, issueUrl: result.issue_url }; - core.info(`Code push '${messageType}' fell back to review issue #${result.issue_number} — add_comment messages will be annotated`); + if (CODE_PUSH_TYPES.has(messageType) && result && result.fallback_used === true) { + if (result.issue_number != null && result.issue_url) { + codePushFallbackInfo = { + type: messageType, + fallbackTargetType: "issue", + number: result.issue_number, + url: result.issue_url, + }; + core.info(`Code push '${messageType}' fell back to review issue #${result.issue_number} — add_comment messages will be annotated`); + } else if (result.pull_request_number != null && result.pull_request_url) { + codePushFallbackInfo = { + type: messageType, + fallbackTargetType: "pull_request", + number: result.pull_request_number, + url: result.pull_request_url, + }; + core.info(`Code push '${messageType}' fell back to pull request #${result.pull_request_number} — add_comment messages will be annotated`); + } } // Check if this output was created with unresolved temporary IDs diff --git a/actions/setup/js/safe_output_handler_manager.test.cjs b/actions/setup/js/safe_output_handler_manager.test.cjs index 50b85111096..89e7b73b386 100644 --- a/actions/setup/js/safe_output_handler_manager.test.cjs +++ b/actions/setup/js/safe_output_handler_manager.test.cjs @@ -1333,6 +1333,34 @@ describe("Safe Output Handler Manager", () => { expect(calledMessage.body).toContain("#7"); }); + it("should prepend fallback note to add_comment body when push_to_pull_request_branch falls back to pull request", async () => { + const messages = [ + { type: "push_to_pull_request_branch", branch: "fix-branch" }, + { type: "add_comment", body: "Changes pushed." }, + ]; + + const pushHandler = vi.fn().mockResolvedValue({ + success: true, + fallback_used: true, + fallback_type: "pull_request", + pull_request_number: 71, + pull_request_url: "https://github.com/owner/repo/pull/71", + }); + const commentHandler = vi.fn().mockResolvedValue([{ _tracking: null }]); + + const handlers = new Map([ + ["push_to_pull_request_branch", pushHandler], + ["add_comment", commentHandler], + ]); + + await processMessages(handlers, messages); + + const calledMessage = commentHandler.mock.calls[0][0]; + expect(calledMessage.body).toContain("Direct push to the original pull request branch was not possible"); + expect(calledMessage.body).toContain("#71"); + expect(calledMessage.body).toContain("https://github.com/owner/repo/pull/71"); + }); + it("should NOT prepend fallback note when create_pull_request succeeds normally", async () => { const messages = [ { type: "create_pull_request", title: "My Fix PR" }, diff --git a/actions/setup/js/safe_output_summary.cjs b/actions/setup/js/safe_output_summary.cjs index 94b0ef776e7..fa2d2143c43 100644 --- a/actions/setup/js/safe_output_summary.cjs +++ b/actions/setup/js/safe_output_summary.cjs @@ -30,12 +30,13 @@ function generateSafeOutputSummary(options) { .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); - // Detect fallback-to-issue outcome for code-push types + // Detect fallback outcomes for code-push types const isFallback = success && result && result.fallback_used === true; + const fallbackType = isFallback && (result.pull_request_url || result.pull_request_number != null) ? "pull_request" : "issue"; // Choose emoji and status based on success and fallback const emoji = isFallback ? "⚠️" : success ? "✅" : "❌"; - const status = isFallback ? "Fallback Issue Created" : success ? "Success" : "Failed"; + const status = isFallback ? (fallbackType === "pull_request" ? "Fallback Pull Request Created" : "Fallback Issue Created") : success ? "Success" : "Failed"; // Start building the summary let summary = `
\n${emoji} ${displayType} - ${status} (Message ${messageIndex})\n\n`; @@ -45,13 +46,23 @@ function generateSafeOutputSummary(options) { summary += sectionTitle; if (isFallback) { - // Explain why the fallback occurred and show the created issue - summary += `> ℹ️ Pull request creation was blocked due to protected file changes. A review issue was created instead.\n\n`; - if (result.issue_url) { - summary += `**Fallback Issue:** ${result.issue_url}\n\n`; - } - if (result.issue_number != null && result.repo) { - summary += `**Location:** ${result.repo}#${result.issue_number}\n\n`; + // Explain why the fallback occurred and show the created fallback target + if (fallbackType === "pull_request") { + summary += `> ℹ️ Direct push to the original pull request branch was not possible (diverged/non-fast-forward). A fallback pull request was created instead.\n\n`; + if (result.pull_request_url) { + summary += `**Fallback Pull Request:** ${result.pull_request_url}\n\n`; + } + if (result.pull_request_number != null && result.repo) { + summary += `**Location:** ${result.repo}#${result.pull_request_number}\n\n`; + } + } else { + summary += `> ℹ️ Pull request creation was blocked due to protected file changes. A review issue was created instead.\n\n`; + if (result.issue_url) { + summary += `**Fallback Issue:** ${result.issue_url}\n\n`; + } + if (result.issue_number != null && result.repo) { + summary += `**Location:** ${result.repo}#${result.issue_number}\n\n`; + } } if (result.branch_name) { summary += `**Branch:** \`${result.branch_name}\`\n\n`; diff --git a/actions/setup/js/safe_output_summary.test.cjs b/actions/setup/js/safe_output_summary.test.cjs index bce462f9e43..5abfd1cb354 100644 --- a/actions/setup/js/safe_output_summary.test.cjs +++ b/actions/setup/js/safe_output_summary.test.cjs @@ -360,6 +360,32 @@ describe("safe_output_summary", () => { expect(summary).not.toContain("⚠️"); expect(summary).not.toContain("Fallback"); }); + + it("should show fallback pull request status when push_to_pull_request_branch falls back to pull request", () => { + const options = { + type: "push_to_pull_request_branch", + messageIndex: 3, + success: true, + result: { + fallback_used: true, + fallback_type: "pull_request", + pull_request_number: 71, + pull_request_url: "https://github.com/owner/repo/pull/71", + repo: "owner/repo", + }, + message: { + body: "Pushing to PR branch.", + }, + }; + + const summary = generateSafeOutputSummary(options); + + expect(summary).toContain("⚠️"); + expect(summary).toContain("Fallback Pull Request Created"); + expect(summary).toContain("https://github.com/owner/repo/pull/71"); + expect(summary).toContain("owner/repo#71"); + expect(summary).toContain("non-fast-forward"); + }); }); describe("writeSafeOutputSummaries", () => { diff --git a/actions/setup/js/types/handler-factory.d.ts b/actions/setup/js/types/handler-factory.d.ts index 848817b2a20..f5ef66af9f3 100644 --- a/actions/setup/js/types/handler-factory.d.ts +++ b/actions/setup/js/types/handler-factory.d.ts @@ -18,6 +18,8 @@ interface HandlerConfig { protected_path_prefixes?: string[]; /** Policy for how protected file matches are handled: "blocked" (default), "fallback-to-issue", or "allowed" */ protected_files_policy?: string; + /** When true (default), create a fallback pull request if direct push to PR branch fails with non-fast-forward/diverged branch. */ + fallback_as_pull_request?: boolean; /** Additional handler-specific configuration properties */ [key: string]: any; } diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index e29e729d7a6..69b3930eb56 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -4599,6 +4599,13 @@ safe-outputs: # (optional) github-token-for-extra-empty-commit: "example-value" + # When true (default), if pushing to the PR branch fails due to a + # non-fast-forward/diverged branch, create a fallback pull request that targets + # the original PR branch. Set to false to disable this behavior and avoid + # requiring pull-requests: write permission. + # (optional) + fallback-as-pull-request: true + # Target repository in format 'owner/repo' for cross-repository push to pull # request branch. Takes precedence over trial target repo settings. # (optional) diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index 6d339a5a146..fcd57dd4350 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -234,6 +234,7 @@ safe-outputs: - "**/*.lock" github-token: ${{ secrets.SOME_CUSTOM_TOKEN }} # optional custom token for permissions github-token-for-extra-empty-commit: ${{ secrets.CI_TOKEN }} # optional token to push empty commit triggering CI + fallback-as-pull-request: true # default: on non-fast-forward push failure, create fallback PR to original PR branch protected-files: fallback-to-issue # create review issue if protected files modified ``` @@ -283,6 +284,8 @@ checkout: If `push-to-pull-request-branch` (or `create-pull-request`) fails, the safe-output pipeline cancels all remaining non-code-push outputs. Each cancelled output is marked with an explicit reason such as "Cancelled: code push operation failed". The failure details appear in the agent failure issue or comment generated by the conclusion job. +When `fallback-as-pull-request` is enabled (default), non-fast-forward push failures trigger a fallback pull request that targets the original PR branch. Set `fallback-as-pull-request: false` to disable this fallback behavior. + ## Protected Files Both `create-pull-request` and `push-to-pull-request-branch` enforce protected file protection by default. Patches that modify package manifests, agent instruction files, or repository security configuration are refused unless you explicitly configure a policy. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 23994195e59..c41bd8294c6 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -7096,6 +7096,11 @@ "type": "string", "description": "Token used to push an empty commit after pushing changes to trigger CI events. Works around the GITHUB_TOKEN limitation where pushes don't trigger workflow runs. Defaults to the magic secret GH_AW_CI_TRIGGER_TOKEN if set in the repository. Use a secret expression (e.g. '${{ secrets.CI_TOKEN }}') for a custom token, or 'app' for GitHub App auth." }, + "fallback-as-pull-request": { + "type": "boolean", + "description": "When true (default), if pushing to the PR branch fails due to a non-fast-forward/diverged branch, create a fallback pull request that targets the original PR branch. Set to false to disable this behavior and avoid requiring pull-requests: write permission.", + "default": true + }, "target-repo": { "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository push to pull request branch. Takes precedence over trial target repo settings." diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index e3a31c1538d..4530a332a20 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -26,6 +26,7 @@ type PushToPullRequestBranchConfig struct { AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for push. Checked independently of protected-files; both checks must pass. ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "am" (default, uses git format-patch) or "bundle" (uses git bundle, preserves merge topology and per-commit metadata). + FallbackAsPullRequest *bool `yaml:"fallback-as-pull-request,omitempty"` // When true (default), creates a fallback pull request if direct push fails due to diverged/non-fast-forward branch. When false, fallback is disabled and pull-requests: write is not requested. AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. } @@ -173,6 +174,13 @@ func (c *Compiler) parsePushToPullRequestBranchConfig(outputMap map[string]any) } } + // Parse fallback-as-pull-request (optional, defaults to true) + if fallbackAsPullRequest, exists := configMap["fallback-as-pull-request"]; exists { + if fallbackAsPullRequestBool, ok := fallbackAsPullRequest.(bool); ok { + pushToBranchConfig.FallbackAsPullRequest = &fallbackAsPullRequestBool + } + } + // Parse allow-workflows: when true, adds workflows: write to the GitHub App token if allowWorkflows, exists := configMap["allow-workflows"]; exists { if allowWorkflowsBool, ok := allowWorkflows.(bool); ok { diff --git a/pkg/workflow/push_to_pull_request_branch_test.go b/pkg/workflow/push_to_pull_request_branch_test.go index d42c6ef7397..0487036e7dc 100644 --- a/pkg/workflow/push_to_pull_request_branch_test.go +++ b/pkg/workflow/push_to_pull_request_branch_test.go @@ -130,6 +130,43 @@ safe-outputs: } } +func TestPushToPullRequestBranchFallbackAsPullRequestDisabled(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + + testMarkdown := `--- +on: + pull_request: + types: [opened, synchronize] +safe-outputs: + push-to-pull-request-branch: + fallback-as-pull-request: false +--- + +# Test Push to PR Branch Fallback Disabled +` + + mdFile := filepath.Join(tmpDir, "test-push-to-pull-request-branch-fallback-disabled.md") + if err := os.WriteFile(mdFile, []byte(testMarkdown), 0644); err != nil { + t.Fatalf("Failed to write test markdown file: %v", err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(mdFile); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(mdFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + if strings.Contains(lockContentStr, "pull-requests: write") { + t.Errorf("Generated workflow should NOT have pull-requests: write permission when fallback-as-pull-request is false") + } +} + func TestPushToPullRequestBranchWithTargetAsterisk(t *testing.T) { // Create a temporary directory for the test tmpDir := testutil.TempDir(t, "test-*") diff --git a/pkg/workflow/safe_outputs_permissions.go b/pkg/workflow/safe_outputs_permissions.go index 74ff34d27cc..940ea72f2e6 100644 --- a/pkg/workflow/safe_outputs_permissions.go +++ b/pkg/workflow/safe_outputs_permissions.go @@ -51,6 +51,14 @@ func isHandlerStaged(globalStaged, handlerStaged bool) bool { return globalStaged || handlerStaged } +// getPushFallbackAsPullRequest returns the effective fallback-as-pull-request setting (defaults to true). +func getPushFallbackAsPullRequest(config *PushToPullRequestBranchConfig) bool { + if config == nil || config.FallbackAsPullRequest == nil { + return true // Default + } + return *config.FallbackAsPullRequest +} + // ComputePermissionsForSafeOutputs computes the minimal required permissions // based on the configured safe-outputs. This function is used by both the // consolidated safe outputs job and the conclusion job to ensure they only @@ -137,8 +145,13 @@ func ComputePermissionsForSafeOutputs(safeOutputs *SafeOutputsConfig) *Permissio } } if safeOutputs.PushToPullRequestBranch != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.PushToPullRequestBranch.Staged) { - safeOutputsPermissionsLog.Print("Adding permissions for push-to-pull-request-branch") - permissions.Merge(NewPermissionsContentsWritePRWrite()) + if getPushFallbackAsPullRequest(safeOutputs.PushToPullRequestBranch) { + safeOutputsPermissionsLog.Print("Adding permissions for push-to-pull-request-branch with fallback-as-pull-request") + permissions.Merge(NewPermissionsContentsWritePRWrite()) + } else { + safeOutputsPermissionsLog.Print("Adding permissions for push-to-pull-request-branch without fallback-as-pull-request") + permissions.Merge(NewPermissionsContentsWrite()) + } // Add workflows: write when allow-workflows is true (GitHub App-only permission) if safeOutputs.PushToPullRequestBranch.AllowWorkflows { safeOutputsPermissionsLog.Print("Adding workflows: write for push-to-pull-request-branch (allow-workflows: true)") diff --git a/pkg/workflow/safe_outputs_permissions_test.go b/pkg/workflow/safe_outputs_permissions_test.go index f1ffc6bb765..a4533a2856b 100644 --- a/pkg/workflow/safe_outputs_permissions_test.go +++ b/pkg/workflow/safe_outputs_permissions_test.go @@ -245,7 +245,7 @@ func TestComputePermissionsForSafeOutputs(t *testing.T) { }, }, { - name: "push-to-pull-request-branch - no issues permission", + name: "push-to-pull-request-branch default fallback - requires pull-requests write", safeOutputs: &SafeOutputsConfig{ PushToPullRequestBranch: &PushToPullRequestBranchConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{}, @@ -256,6 +256,18 @@ func TestComputePermissionsForSafeOutputs(t *testing.T) { PermissionPullRequests: PermissionWrite, }, }, + { + name: "push-to-pull-request-branch with fallback-as-pull-request false - no pull-requests permission", + safeOutputs: &SafeOutputsConfig{ + PushToPullRequestBranch: &PushToPullRequestBranchConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{}, + FallbackAsPullRequest: boolPtr(false), + }, + }, + expected: map[PermissionScope]PermissionLevel{ + PermissionContents: PermissionWrite, + }, + }, { name: "multiple safe outputs without discussions - no discussions permission", safeOutputs: &SafeOutputsConfig{ From 86cf23f714b5a3a8893d0492c26ceabe55524eba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:54:00 +0000 Subject: [PATCH 05/10] test: assert non-fast-forward error when fallback PR is disabled Agent-Logs-Url: https://github.com/github/gh-aw/sessions/9f0ad012-0f23-4022-ad35-c955d90fc310 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_to_pull_request_branch.test.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index 664451be648..b7f94059c63 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -836,6 +836,7 @@ index 0000000..abc1234 expect(result.success).toBe(false); expect(result.error_type).toBe("push_failed"); + expect(result.error).toContain("non-fast-forward"); expect(mockGithub.rest.pulls.create).not.toHaveBeenCalled(); }); From 198379571a4c0ed729dcd20f012b2211d17c5b0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:51:39 +0000 Subject: [PATCH 06/10] fix: address PR review comments for fallback type and handler config propagation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f35b039c-a302-4ce9-a90d-a41ec5468b27 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/safe_output_handler_manager.cjs | 6 ++--- actions/setup/js/safe_output_summary.cjs | 13 ++++++--- actions/setup/js/safe_output_summary.test.cjs | 27 +++++++++++++++++++ .../compiler_safe_outputs_handlers.go | 1 + .../push_to_pull_request_branch_test.go | 3 +++ 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 5d4bab0f453..96226a8c6fe 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -360,9 +360,9 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) /** @type {Array<{type: string, error: string}>} */ const codePushFailures = []; - // Track when a code-push operation falls back to creating a review issue instead. + // Track when a code-push operation falls back to creating an issue or pull request instead. // When set, subsequent add_comment messages will receive a correction note prepended - // to their body so the posted comment accurately reflects the actual outcome. + // to their body so the posted comment accurately reflects the actual fallback target. /** @type {{type: string, fallbackTargetType: "issue" | "pull_request", number: number, url: string}|null} */ let codePushFallbackInfo = null; @@ -588,7 +588,7 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) } } - // Track when a code-push operation falls back to a review issue so subsequent + // Track when a code-push operation falls back to an issue or pull request so subsequent // add_comment messages can include a correction note. if (CODE_PUSH_TYPES.has(messageType) && result && result.fallback_used === true) { if (result.issue_number != null && result.issue_url) { diff --git a/actions/setup/js/safe_output_summary.cjs b/actions/setup/js/safe_output_summary.cjs index fa2d2143c43..86b3ffcf5e6 100644 --- a/actions/setup/js/safe_output_summary.cjs +++ b/actions/setup/js/safe_output_summary.cjs @@ -30,9 +30,16 @@ function generateSafeOutputSummary(options) { .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); - // Detect fallback outcomes for code-push types + // Detect fallback outcomes for code-push types. + // Prefer explicit fallback_type when available; infer only for backward compatibility. const isFallback = success && result && result.fallback_used === true; - const fallbackType = isFallback && (result.pull_request_url || result.pull_request_number != null) ? "pull_request" : "issue"; + const inferredFallbackType = isFallback && (result.pull_request_url || result.pull_request_number != null) ? "pull_request" : "issue"; + let fallbackType = inferredFallbackType; + if (isFallback && result?.fallback_type === "pull_request") { + fallbackType = "pull_request"; + } else if (isFallback && result?.fallback_type === "issue") { + fallbackType = "issue"; + } // Choose emoji and status based on success and fallback const emoji = isFallback ? "⚠️" : success ? "✅" : "❌"; @@ -42,7 +49,7 @@ function generateSafeOutputSummary(options) { let summary = `
\n${emoji} ${displayType} - ${status} (Message ${messageIndex})\n\n`; // Add message details - const sectionTitle = isFallback ? `### ${displayType} — Fallback Issue\n\n` : `### ${displayType}\n\n`; + const sectionTitle = isFallback ? `### ${displayType} — ${fallbackType === "pull_request" ? "Fallback Pull Request" : "Fallback Issue"}\n\n` : `### ${displayType}\n\n`; summary += sectionTitle; if (isFallback) { diff --git a/actions/setup/js/safe_output_summary.test.cjs b/actions/setup/js/safe_output_summary.test.cjs index 5abfd1cb354..e37f78cdf08 100644 --- a/actions/setup/js/safe_output_summary.test.cjs +++ b/actions/setup/js/safe_output_summary.test.cjs @@ -386,6 +386,33 @@ describe("safe_output_summary", () => { expect(summary).toContain("owner/repo#71"); expect(summary).toContain("non-fast-forward"); }); + + it("should prefer explicit fallback_type over inferred shape for backward compatibility", () => { + const options = { + type: "push_to_pull_request_branch", + messageIndex: 4, + success: true, + result: { + fallback_used: true, + fallback_type: "issue", + // pull_request_url present by shape, but explicit fallback_type should win + pull_request_url: "https://github.com/owner/repo/pull/72", + issue_number: 123, + issue_url: "https://github.com/owner/repo/issues/123", + repo: "owner/repo", + }, + message: { + body: "Pushing to PR branch.", + }, + }; + + const summary = generateSafeOutputSummary(options); + + expect(summary).toContain("Fallback Issue Created"); + expect(summary).toContain("Fallback Issue:"); + expect(summary).toContain("https://github.com/owner/repo/issues/123"); + expect(summary).not.toContain("Fallback Pull Request Created"); + }); }); describe("writeSafeOutputSummaries", () => { diff --git a/pkg/workflow/compiler_safe_outputs_handlers.go b/pkg/workflow/compiler_safe_outputs_handlers.go index c42bb7abc61..1386b9aef3b 100644 --- a/pkg/workflow/compiler_safe_outputs_handlers.go +++ b/pkg/workflow/compiler_safe_outputs_handlers.go @@ -425,6 +425,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_files", c.AllowedFiles). AddStringSlice("excluded_files", c.ExcludedFiles). AddIfNotEmpty("patch_format", c.PatchFormat). + AddBoolPtr("fallback_as_pull_request", c.FallbackAsPullRequest). Build() }, "update_pull_request": func(cfg *SafeOutputsConfig) map[string]any { diff --git a/pkg/workflow/push_to_pull_request_branch_test.go b/pkg/workflow/push_to_pull_request_branch_test.go index 0487036e7dc..870e6d3fee4 100644 --- a/pkg/workflow/push_to_pull_request_branch_test.go +++ b/pkg/workflow/push_to_pull_request_branch_test.go @@ -162,6 +162,9 @@ safe-outputs: } lockContentStr := string(lockContent) + if !strings.Contains(lockContentStr, `"fallback_as_pull_request":false`) && !strings.Contains(lockContentStr, `"fallback_as_pull_request": false`) { + t.Errorf("Generated workflow should contain fallback_as_pull_request in handler config JSON") + } if strings.Contains(lockContentStr, "pull-requests: write") { t.Errorf("Generated workflow should NOT have pull-requests: write permission when fallback-as-pull-request is false") } From e1ce37aa5aadba533a98fa5943e21bcb9e372c7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:52:34 +0000 Subject: [PATCH 07/10] refactor: simplify fallback type selection in safe output summary Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f35b039c-a302-4ce9-a90d-a41ec5468b27 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/safe_output_summary.cjs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/actions/setup/js/safe_output_summary.cjs b/actions/setup/js/safe_output_summary.cjs index 86b3ffcf5e6..1e17ed89b29 100644 --- a/actions/setup/js/safe_output_summary.cjs +++ b/actions/setup/js/safe_output_summary.cjs @@ -34,12 +34,7 @@ function generateSafeOutputSummary(options) { // Prefer explicit fallback_type when available; infer only for backward compatibility. const isFallback = success && result && result.fallback_used === true; const inferredFallbackType = isFallback && (result.pull_request_url || result.pull_request_number != null) ? "pull_request" : "issue"; - let fallbackType = inferredFallbackType; - if (isFallback && result?.fallback_type === "pull_request") { - fallbackType = "pull_request"; - } else if (isFallback && result?.fallback_type === "issue") { - fallbackType = "issue"; - } + const fallbackType = isFallback && result?.fallback_type ? result.fallback_type : inferredFallbackType; // Choose emoji and status based on success and fallback const emoji = isFallback ? "⚠️" : success ? "✅" : "❌"; From a76669258a85e4e56109c4ac5f2a95b7cf83fcf3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:21:46 +0000 Subject: [PATCH 08/10] test: harden fallback_as_pull_request compile assertions Agent-Logs-Url: https://github.com/github/gh-aw/sessions/892e3d78-093f-4f10-9bd4-3d91ae48774e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/safe_output_summary.cjs | 2 +- .../push_to_pull_request_branch_test.go | 111 +++++++++++++++++- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/safe_output_summary.cjs b/actions/setup/js/safe_output_summary.cjs index 1e17ed89b29..6355e68436b 100644 --- a/actions/setup/js/safe_output_summary.cjs +++ b/actions/setup/js/safe_output_summary.cjs @@ -34,7 +34,7 @@ function generateSafeOutputSummary(options) { // Prefer explicit fallback_type when available; infer only for backward compatibility. const isFallback = success && result && result.fallback_used === true; const inferredFallbackType = isFallback && (result.pull_request_url || result.pull_request_number != null) ? "pull_request" : "issue"; - const fallbackType = isFallback && result?.fallback_type ? result.fallback_type : inferredFallbackType; + const fallbackType = result?.fallback_type || inferredFallbackType; // Choose emoji and status based on success and fallback const emoji = isFallback ? "⚠️" : success ? "✅" : "❌"; diff --git a/pkg/workflow/push_to_pull_request_branch_test.go b/pkg/workflow/push_to_pull_request_branch_test.go index 870e6d3fee4..20a28de8c83 100644 --- a/pkg/workflow/push_to_pull_request_branch_test.go +++ b/pkg/workflow/push_to_pull_request_branch_test.go @@ -3,6 +3,7 @@ package workflow import ( + "encoding/json" "os" "path/filepath" "strings" @@ -11,8 +12,66 @@ import ( "github.com/github/gh-aw/pkg/stringutil" "github.com/github/gh-aw/pkg/testutil" + "go.yaml.in/yaml/v3" ) +func extractPushToPullRequestBranchHandlerConfigFromLockContent(t *testing.T, lockContent []byte) map[string]any { + t.Helper() + + var workflowDoc map[string]any + if err := yaml.Unmarshal(lockContent, &workflowDoc); err != nil { + t.Fatalf("Failed to unmarshal lock workflow YAML: %v", err) + } + + jobsRaw, ok := workflowDoc["jobs"].(map[string]any) + if !ok { + t.Fatalf("Generated workflow should contain jobs map") + } + + safeOutputsJobRaw, ok := jobsRaw["safe_outputs"].(map[string]any) + if !ok { + t.Fatalf("Generated workflow should contain safe_outputs job") + } + + stepsRaw, ok := safeOutputsJobRaw["steps"].([]any) + if !ok { + t.Fatalf("Generated workflow safe_outputs job should contain steps array") + } + + var handlerConfigJSON string + for _, step := range stepsRaw { + stepMap, ok := step.(map[string]any) + if !ok { + continue + } + envMap, ok := stepMap["env"].(map[string]any) + if !ok { + continue + } + rawConfig, ok := envMap["GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG"].(string) + if ok && rawConfig != "" { + handlerConfigJSON = rawConfig + break + } + } + + if handlerConfigJSON == "" { + t.Fatalf("Generated workflow should contain GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG env var") + } + + var handlerConfig map[string]any + if err := json.Unmarshal([]byte(handlerConfigJSON), &handlerConfig); err != nil { + t.Fatalf("Failed to unmarshal GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG JSON: %v", err) + } + + pushCfgRaw, ok := handlerConfig["push_to_pull_request_branch"].(map[string]any) + if !ok { + t.Fatalf("Handler config should contain push_to_pull_request_branch object") + } + + return pushCfgRaw +} + func TestPushToPullRequestBranchConfigParsing(t *testing.T) { // Create a temporary directory for the test tmpDir := testutil.TempDir(t, "test-*") @@ -162,14 +221,64 @@ safe-outputs: } lockContentStr := string(lockContent) - if !strings.Contains(lockContentStr, `"fallback_as_pull_request":false`) && !strings.Contains(lockContentStr, `"fallback_as_pull_request": false`) { + pushConfig := extractPushToPullRequestBranchHandlerConfigFromLockContent(t, lockContent) + fallbackAsPullRequest, exists := pushConfig["fallback_as_pull_request"] + if !exists { t.Errorf("Generated workflow should contain fallback_as_pull_request in handler config JSON") } + if fallbackAsPullRequest != false { + t.Errorf("Expected fallback_as_pull_request=false, got %#v", fallbackAsPullRequest) + } if strings.Contains(lockContentStr, "pull-requests: write") { t.Errorf("Generated workflow should NOT have pull-requests: write permission when fallback-as-pull-request is false") } } +func TestPushToPullRequestBranchFallbackAsPullRequestEnabled(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + + testMarkdown := `--- +on: + pull_request: + types: [opened, synchronize] +safe-outputs: + push-to-pull-request-branch: + fallback-as-pull-request: true +--- + +# Test Push to PR Branch Fallback Enabled +` + + mdFile := filepath.Join(tmpDir, "test-push-to-pull-request-branch-fallback-enabled.md") + if err := os.WriteFile(mdFile, []byte(testMarkdown), 0644); err != nil { + t.Fatalf("Failed to write test markdown file: %v", err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(mdFile); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(mdFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + pushConfig := extractPushToPullRequestBranchHandlerConfigFromLockContent(t, lockContent) + fallbackAsPullRequest, exists := pushConfig["fallback_as_pull_request"] + if !exists { + t.Errorf("Generated workflow should contain fallback_as_pull_request in handler config JSON") + } + if fallbackAsPullRequest != true { + t.Errorf("Expected fallback_as_pull_request=true, got %#v", fallbackAsPullRequest) + } + if !strings.Contains(lockContentStr, "pull-requests: write") { + t.Errorf("Generated workflow should have pull-requests: write permission when fallback-as-pull-request is true") + } +} + func TestPushToPullRequestBranchWithTargetAsterisk(t *testing.T) { // Create a temporary directory for the test tmpDir := testutil.TempDir(t, "test-*") From 3bd003c49f6bbeeca1eec71ec81145ada928b86d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:26:11 +0000 Subject: [PATCH 09/10] refactor: polish fallback_as_pull_request compile tests Agent-Logs-Url: https://github.com/github/gh-aw/sessions/892e3d78-093f-4f10-9bd4-3d91ae48774e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../push_to_pull_request_branch_test.go | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/pkg/workflow/push_to_pull_request_branch_test.go b/pkg/workflow/push_to_pull_request_branch_test.go index 20a28de8c83..ab28bd369c9 100644 --- a/pkg/workflow/push_to_pull_request_branch_test.go +++ b/pkg/workflow/push_to_pull_request_branch_test.go @@ -15,7 +15,7 @@ import ( "go.yaml.in/yaml/v3" ) -func extractPushToPullRequestBranchHandlerConfigFromLockContent(t *testing.T, lockContent []byte) map[string]any { +func extractPushToPRBranchHandlerConfig(t *testing.T, lockContent []byte) map[string]any { t.Helper() var workflowDoc map[string]any @@ -221,13 +221,17 @@ safe-outputs: } lockContentStr := string(lockContent) - pushConfig := extractPushToPullRequestBranchHandlerConfigFromLockContent(t, lockContent) + pushConfig := extractPushToPRBranchHandlerConfig(t, lockContent) fallbackAsPullRequest, exists := pushConfig["fallback_as_pull_request"] if !exists { t.Errorf("Generated workflow should contain fallback_as_pull_request in handler config JSON") } - if fallbackAsPullRequest != false { - t.Errorf("Expected fallback_as_pull_request=false, got %#v", fallbackAsPullRequest) + fallbackAsPullRequestBool, isBool := fallbackAsPullRequest.(bool) + if !isBool { + t.Errorf("Expected fallback_as_pull_request to be a bool, got %#v", fallbackAsPullRequest) + } + if fallbackAsPullRequestBool { + t.Errorf("Expected fallback_as_pull_request=false, got %#v", fallbackAsPullRequestBool) } if strings.Contains(lockContentStr, "pull-requests: write") { t.Errorf("Generated workflow should NOT have pull-requests: write permission when fallback-as-pull-request is false") @@ -266,13 +270,17 @@ safe-outputs: } lockContentStr := string(lockContent) - pushConfig := extractPushToPullRequestBranchHandlerConfigFromLockContent(t, lockContent) + pushConfig := extractPushToPRBranchHandlerConfig(t, lockContent) fallbackAsPullRequest, exists := pushConfig["fallback_as_pull_request"] if !exists { t.Errorf("Generated workflow should contain fallback_as_pull_request in handler config JSON") } - if fallbackAsPullRequest != true { - t.Errorf("Expected fallback_as_pull_request=true, got %#v", fallbackAsPullRequest) + fallbackAsPullRequestBool, isBool := fallbackAsPullRequest.(bool) + if !isBool { + t.Errorf("Expected fallback_as_pull_request to be a bool, got %#v", fallbackAsPullRequest) + } + if !fallbackAsPullRequestBool { + t.Errorf("Expected fallback_as_pull_request=true, got %#v", fallbackAsPullRequestBool) } if !strings.Contains(lockContentStr, "pull-requests: write") { t.Errorf("Generated workflow should have pull-requests: write permission when fallback-as-pull-request is true") From 6db0ad6e0ec3951d7402217b8dad6a09a870002a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:30:27 +0000 Subject: [PATCH 10/10] refactor: align fallback helper naming and fallback type guard Agent-Logs-Url: https://github.com/github/gh-aw/sessions/892e3d78-093f-4f10-9bd4-3d91ae48774e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/safe_output_summary.cjs | 2 +- pkg/workflow/push_to_pull_request_branch_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/safe_output_summary.cjs b/actions/setup/js/safe_output_summary.cjs index 6355e68436b..1e17ed89b29 100644 --- a/actions/setup/js/safe_output_summary.cjs +++ b/actions/setup/js/safe_output_summary.cjs @@ -34,7 +34,7 @@ function generateSafeOutputSummary(options) { // Prefer explicit fallback_type when available; infer only for backward compatibility. const isFallback = success && result && result.fallback_used === true; const inferredFallbackType = isFallback && (result.pull_request_url || result.pull_request_number != null) ? "pull_request" : "issue"; - const fallbackType = result?.fallback_type || inferredFallbackType; + const fallbackType = isFallback && result?.fallback_type ? result.fallback_type : inferredFallbackType; // Choose emoji and status based on success and fallback const emoji = isFallback ? "⚠️" : success ? "✅" : "❌"; diff --git a/pkg/workflow/push_to_pull_request_branch_test.go b/pkg/workflow/push_to_pull_request_branch_test.go index ab28bd369c9..989d018b4a2 100644 --- a/pkg/workflow/push_to_pull_request_branch_test.go +++ b/pkg/workflow/push_to_pull_request_branch_test.go @@ -15,7 +15,7 @@ import ( "go.yaml.in/yaml/v3" ) -func extractPushToPRBranchHandlerConfig(t *testing.T, lockContent []byte) map[string]any { +func extractPushToPullRequestBranchHandlerConfig(t *testing.T, lockContent []byte) map[string]any { t.Helper() var workflowDoc map[string]any @@ -221,7 +221,7 @@ safe-outputs: } lockContentStr := string(lockContent) - pushConfig := extractPushToPRBranchHandlerConfig(t, lockContent) + pushConfig := extractPushToPullRequestBranchHandlerConfig(t, lockContent) fallbackAsPullRequest, exists := pushConfig["fallback_as_pull_request"] if !exists { t.Errorf("Generated workflow should contain fallback_as_pull_request in handler config JSON") @@ -270,7 +270,7 @@ safe-outputs: } lockContentStr := string(lockContent) - pushConfig := extractPushToPRBranchHandlerConfig(t, lockContent) + pushConfig := extractPushToPullRequestBranchHandlerConfig(t, lockContent) fallbackAsPullRequest, exists := pushConfig["fallback_as_pull_request"] if !exists { t.Errorf("Generated workflow should contain fallback_as_pull_request in handler config JSON")