diff --git a/.github/workflows/chaos-pr-bundle-fuzzer.lock.yml b/.github/workflows/chaos-pr-bundle-fuzzer.lock.yml index 4ababcfb714..b86694c2103 100644 --- a/.github/workflows/chaos-pr-bundle-fuzzer.lock.yml +++ b/.github/workflows/chaos-pr-bundle-fuzzer.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f62fc93407bd6ab331f4e3abc2379d35615d8f0139042c81a957c760d5399ce1","body_hash":"6259ed3b76b0756c3579e48ba619eeffa8e740e694758e39577368ec457739c1","strict":true,"agent_id":"copilot","agent_model":"claude-sonnet-4.6"} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"31f878f6a1363586ee1ff769b0cb474a62d9c81f9855a94ca87cab6bed417d9d","body_hash":"6259ed3b76b0756c3579e48ba619eeffa8e740e694758e39577368ec457739c1","strict":true,"agent_id":"copilot","agent_model":"claude-sonnet-4.6"} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.58"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.22"},{"image":"ghcr.io/github/github-mcp-server:v1.1.0"},{"image":"node:lts-alpine","digest":"sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14","pinned_image":"node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14"}]} # ___ _ _ # / _ \ | | (_) @@ -229,24 +229,24 @@ jobs: run: | bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_99d50a698bdb1384_EOF' + cat << 'GH_AW_PROMPT_321ecf5730b07296_EOF' - GH_AW_PROMPT_99d50a698bdb1384_EOF + GH_AW_PROMPT_321ecf5730b07296_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_99d50a698bdb1384_EOF' + cat << 'GH_AW_PROMPT_321ecf5730b07296_EOF' Tools: create_pull_request(max:5), missing_tool, missing_data, noop - GH_AW_PROMPT_99d50a698bdb1384_EOF + GH_AW_PROMPT_321ecf5730b07296_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" - cat << 'GH_AW_PROMPT_99d50a698bdb1384_EOF' + cat << 'GH_AW_PROMPT_321ecf5730b07296_EOF' - GH_AW_PROMPT_99d50a698bdb1384_EOF + GH_AW_PROMPT_321ecf5730b07296_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_99d50a698bdb1384_EOF' + cat << 'GH_AW_PROMPT_321ecf5730b07296_EOF' The following GitHub context information is available for this workflow: {{#if github.actor}} @@ -275,14 +275,14 @@ jobs: {{/if}} - GH_AW_PROMPT_99d50a698bdb1384_EOF + GH_AW_PROMPT_321ecf5730b07296_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_99d50a698bdb1384_EOF' + cat << 'GH_AW_PROMPT_321ecf5730b07296_EOF' {{#runtime-import .github/workflows/shared/otlp.md}} {{#runtime-import .github/workflows/shared/noop-reminder.md}} {{#runtime-import .github/workflows/chaos-pr-bundle-fuzzer.md}} - GH_AW_PROMPT_99d50a698bdb1384_EOF + GH_AW_PROMPT_321ecf5730b07296_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -525,9 +525,9 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_3cfc8d51efc6b9c7_EOF' - {"create_pull_request":{"allowed_files":["tmp/chaos/**","scratchpad/chaos/**","tests/chaos/**"],"draft":true,"excluded_files":[".github/workflows/**"],"expires":4,"if_no_changes":"ignore","labels":["test-in-progress"],"max":5,"max_patch_files":100,"max_patch_size":1024,"preserve_branch_name":true,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","recreate_ref":true,"title_prefix":"[chaos-test] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_3cfc8d51efc6b9c7_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_69c091d10d55b480_EOF' + {"create_pull_request":{"allowed_files":["tmp/chaos/**","scratchpad/chaos/**","tests/chaos/**"],"close_older_pull_requests":true,"draft":true,"excluded_files":[".github/workflows/**"],"expires":4,"if_no_changes":"ignore","labels":["test-in-progress"],"max":5,"max_patch_files":100,"max_patch_size":1024,"preserve_branch_name":true,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","recreate_ref":true,"title_prefix":"[chaos-test] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_69c091d10d55b480_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -742,7 +742,7 @@ jobs: mkdir -p /home/runner/.copilot GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_16abf2005f7a55f7_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_f90db49140838049_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { @@ -788,7 +788,7 @@ jobs: } } } - GH_AW_MCP_CONFIG_16abf2005f7a55f7_EOF + GH_AW_MCP_CONFIG_f90db49140838049_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -1583,7 +1583,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.grafana.net,*.sentry.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"allowed_files\":[\"tmp/chaos/**\",\"scratchpad/chaos/**\",\"tests/chaos/**\"],\"draft\":true,\"excluded_files\":[\".github/workflows/**\"],\"expires\":4,\"if_no_changes\":\"ignore\",\"labels\":[\"test-in-progress\"],\"max\":5,\"max_patch_files\":100,\"max_patch_size\":1024,\"preserve_branch_name\":true,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"recreate_ref\":true,\"title_prefix\":\"[chaos-test] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"allowed_files\":[\"tmp/chaos/**\",\"scratchpad/chaos/**\",\"tests/chaos/**\"],\"close_older_pull_requests\":true,\"draft\":true,\"excluded_files\":[\".github/workflows/**\"],\"expires\":4,\"if_no_changes\":\"ignore\",\"labels\":[\"test-in-progress\"],\"max\":5,\"max_patch_files\":100,\"max_patch_size\":1024,\"preserve_branch_name\":true,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"recreate_ref\":true,\"title_prefix\":\"[chaos-test] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/chaos-pr-bundle-fuzzer.md b/.github/workflows/chaos-pr-bundle-fuzzer.md index 6d5f710b165..a072245e865 100644 --- a/.github/workflows/chaos-pr-bundle-fuzzer.md +++ b/.github/workflows/chaos-pr-bundle-fuzzer.md @@ -27,6 +27,7 @@ safe-outputs: max: 5 expires: 4h if-no-changes: "ignore" + close-older-pull-requests: true allowed-files: - "tmp/chaos/**" - "scratchpad/chaos/**" diff --git a/actions/setup/js/close_older_pull_requests.cjs b/actions/setup/js/close_older_pull_requests.cjs new file mode 100644 index 00000000000..6293d12805a --- /dev/null +++ b/actions/setup/js/close_older_pull_requests.cjs @@ -0,0 +1,224 @@ +// @ts-check +/// + +const { sanitizeContent } = require("./sanitize_content.cjs"); +const { closeOlderEntities, MAX_CLOSE_COUNT: SHARED_MAX_CLOSE_COUNT } = require("./close_older_entities.cjs"); +const { buildMarkerSearchQuery, filterByMarker, logFilterSummary } = require("./close_older_search_helpers.cjs"); + +/** + * Maximum number of older pull requests to close + */ +const MAX_CLOSE_COUNT = SHARED_MAX_CLOSE_COUNT; + +/** + * Delay between API calls in milliseconds to avoid rate limiting + */ +const API_DELAY_MS = 500; + +/** + * Search for open pull requests with a matching workflow-id marker + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} workflowId - Workflow ID to match in the marker + * @param {number} excludeNumber - PR number to exclude (the newly created one) + * @param {string} [callerWorkflowId] - Optional calling workflow identity for precise filtering. + * When set, filters by the `gh-aw-workflow-call-id` marker so callers sharing the same + * reusable workflow do not close each other's PRs. Falls back to `gh-aw-workflow-id` + * when not provided (backward compat for PRs created before this fix). + * @param {string} [closeOlderKey] - Optional explicit deduplication key. When set, the + * `gh-aw-close-key` marker is used as the primary search term and exact filter instead + * of the workflow-id / workflow-call-id markers. + * @returns {Promise, created_at: string}>>} Matching pull requests + */ +async function searchOlderPullRequests(github, owner, repo, workflowId, excludeNumber, callerWorkflowId, closeOlderKey) { + core.info(`Starting search for older pull requests in ${owner}/${repo}`); + core.info(` Workflow ID: ${workflowId || "(none)"}`); + core.info(` Exclude PR number: ${excludeNumber}`); + + if (!workflowId && !closeOlderKey) { + core.info("No workflow ID or close-older-key provided - cannot search for older pull requests"); + return []; + } + + const { searchQuery, exactMarker } = buildMarkerSearchQuery({ + owner, + repo, + workflowId, + callerWorkflowId, + closeOlderKey, + entityQualifier: "is:pr", + }); + core.info(`Executing GitHub search with query: ${searchQuery}`); + + const result = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 50, + }); + + core.info(`Search API returned ${result?.data?.items?.length || 0} total results`); + + if (!result || !result.data || !result.data.items) { + core.info("No results returned from search API"); + return []; + } + + core.info("Filtering search results..."); + + const { filtered: filteredItems, counters } = filterByMarker({ + items: result.data.items, + excludeNumber, + exactMarker, + entityType: "pull request", + additionalFilter: (item, extra) => { + if (!item.pull_request) { + extra.issueCount = (extra.issueCount || 0) + 1; + return false; + } + return true; + }, + }); + + const filtered = filteredItems.map(item => ({ + number: item.number, + title: item.title, + html_url: item.html_url, + labels: item.labels || [], + created_at: item.created_at, + })); + + logFilterSummary({ + entityTypePlural: "pull requests", + counters, + extraLabels: [["issueCount", "Excluded issues"]], + }); + + return filtered; +} + +/** + * Add comment to a GitHub Pull Request using REST API + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} prNumber - Pull request number + * @param {string} message - Comment body + * @returns {Promise<{id: number, html_url: string}>} Comment details + */ +async function addPullRequestComment(github, owner, repo, prNumber, message) { + core.info(`Adding comment to pull request #${prNumber} in ${owner}/${repo}`); + core.info(` Comment length: ${message.length} characters`); + + const result = await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: sanitizeContent(message), + }); + + core.info(` ✓ Comment created successfully with ID: ${result.data.id}`); + core.info(` Comment URL: ${result.data.html_url}`); + + return { + id: result.data.id, + html_url: result.data.html_url, + }; +} + +/** + * Close a GitHub Pull Request using REST API + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} prNumber - Pull request number + * @returns {Promise<{number: number, html_url: string}>} Pull request details + */ +async function closePullRequest(github, owner, repo, prNumber) { + core.info(`Closing pull request #${prNumber} in ${owner}/${repo}`); + + const result = await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: "closed", + }); + + core.info(` ✓ Pull request #${result.data.number} closed successfully`); + core.info(` Pull request URL: ${result.data.html_url}`); + + return { + number: result.data.number, + html_url: result.data.html_url, + }; +} + +/** + * Generate closing message for older pull requests + * @param {object} params - Parameters for the message + * @param {string} params.newPullRequestUrl - URL of the new pull request + * @param {number} params.newPullRequestNumber - Number of the new pull request + * @param {string} params.workflowName - Name of the workflow + * @param {string} params.runUrl - URL of the workflow run + * @returns {string} Closing message + */ +function getCloseOlderPullRequestMessage({ newPullRequestUrl, newPullRequestNumber, workflowName, runUrl }) { + return `This pull request is being closed as superseded. A newer pull request has been created: #${newPullRequestNumber} + +[View newer pull request](${newPullRequestUrl}) + +--- + +*This action was performed automatically by the [\`${workflowName}\`](${runUrl}) workflow.*`; +} + +/** + * Close older pull requests that match the workflow-id marker + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} workflowId - Workflow ID to match in the marker + * @param {{number: number, html_url: string}} newPullRequest - The newly created pull request + * @param {string} workflowName - Name of the workflow + * @param {string} runUrl - URL of the workflow run + * @param {string} [callerWorkflowId] - Optional calling workflow identity for precise filtering + * @param {string} [closeOlderKey] - Optional explicit deduplication key for close-older matching + * @returns {Promise>} List of closed pull requests + */ +async function closeOlderPullRequests(github, owner, repo, workflowId, newPullRequest, workflowName, runUrl, callerWorkflowId, closeOlderKey) { + const result = await closeOlderEntities(github, owner, repo, workflowId, newPullRequest, workflowName, runUrl, { + entityType: "pull request", + entityTypePlural: "pull requests", + // Use a closure so callerWorkflowId and closeOlderKey are forwarded to searchOlderPullRequests + // without going through the closeOlderEntities extraArgs mechanism (which appends + // excludeNumber last) + searchOlderEntities: (gh, o, r, wid, excludeNumber) => searchOlderPullRequests(gh, o, r, wid, excludeNumber, callerWorkflowId, closeOlderKey), + getCloseMessage: params => + getCloseOlderPullRequestMessage({ + newPullRequestUrl: params.newEntityUrl, + newPullRequestNumber: params.newEntityNumber, + workflowName: params.workflowName, + runUrl: params.runUrl, + }), + addComment: addPullRequestComment, + closeEntity: closePullRequest, + delayMs: API_DELAY_MS, + getEntityId: entity => entity.number, + getEntityUrl: entity => entity.html_url, + }); + + // Map to pull-request-specific return type + return result.map(item => ({ + number: item.number, + html_url: item.html_url || "", + })); +} + +module.exports = { + closeOlderPullRequests, + searchOlderPullRequests, + addPullRequestComment, + closePullRequest, + getCloseOlderPullRequestMessage, + MAX_CLOSE_COUNT, + API_DELAY_MS, +}; diff --git a/actions/setup/js/close_older_pull_requests.test.cjs b/actions/setup/js/close_older_pull_requests.test.cjs new file mode 100644 index 00000000000..a393464254e --- /dev/null +++ b/actions/setup/js/close_older_pull_requests.test.cjs @@ -0,0 +1,459 @@ +// @ts-check + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { closeOlderPullRequests, searchOlderPullRequests, addPullRequestComment, closePullRequest, getCloseOlderPullRequestMessage, MAX_CLOSE_COUNT } from "./close_older_pull_requests.cjs"; + +// Mock globals +global.core = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), +}; + +describe("close_older_pull_requests", () => { + let mockGithub; + + beforeEach(() => { + vi.clearAllMocks(); + mockGithub = { + rest: { + search: { + issuesAndPullRequests: vi.fn(), + }, + issues: { + createComment: vi.fn(), + }, + pulls: { + update: vi.fn(), + }, + }, + }; + }); + + describe("searchOlderPullRequests", () => { + it("should search for pull requests with workflow-id marker", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Chaos test - 2024-01", + html_url: "https://github.com/owner/repo/pull/123", + labels: [], + pull_request: {}, + body: "", + }, + { + number: 124, + title: "Chaos test - 2024-02", + html_url: "https://github.com/owner/repo/pull/124", + labels: [], + pull_request: {}, + body: "", + }, + ], + }, + }); + + const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "chaos-pr-bundle-fuzzer", 125); + + expect(results).toHaveLength(2); + expect(results[0].number).toBe(123); + expect(results[1].number).toBe(124); + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: 'repo:owner/repo is:pr is:open "gh-aw-workflow-id: chaos-pr-bundle-fuzzer" in:body', + per_page: 50, + }); + }); + + it("should exclude the newly created pull request", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Chaos test - old", + html_url: "https://github.com/owner/repo/pull/123", + labels: [], + pull_request: {}, + body: "", + }, + { + number: 124, + title: "Chaos test - new", + html_url: "https://github.com/owner/repo/pull/124", + labels: [], + pull_request: {}, + body: "", + }, + ], + }, + }); + + const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "chaos-pr-bundle-fuzzer", 124); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + }); + + it("should return empty array if no workflow-id provided", async () => { + const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "", 125); + + expect(results).toHaveLength(0); + expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + }); + + it("should exclude issues (only return pull requests)", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Pull Request", + html_url: "https://github.com/owner/repo/pull/123", + labels: [], + pull_request: {}, + body: "", + }, + { + number: 124, + title: "Issue - should be excluded", + html_url: "https://github.com/owner/repo/issues/124", + labels: [], + // No pull_request property means it's an issue + body: "", + }, + ], + }, + }); + + const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", 125); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + }); + + it("should return empty array if no results", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [], + }, + }); + + const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", 125); + + expect(results).toHaveLength(0); + }); + + it("should exclude PRs whose body does not contain the exact marker", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Matching PR", + html_url: "https://github.com/owner/repo/pull/123", + labels: [], + pull_request: {}, + body: "Some content\n", + }, + { + number: 124, + title: "Substring match - should be excluded", + html_url: "https://github.com/owner/repo/pull/124", + labels: [], + pull_request: {}, + // Body has a related-but-longer workflow ID - GitHub search may match this + // but exact filtering should exclude it + body: "Some content\n", + }, + { + number: 125, + title: "No marker - should be excluded", + html_url: "https://github.com/owner/repo/pull/125", + labels: [], + pull_request: {}, + body: "PR without any marker", + }, + ], + }, + }); + + const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", 999); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + }); + + it("should filter by gh-aw-workflow-call-id when callerWorkflowId is provided", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Same caller - should be included", + html_url: "https://github.com/owner/repo/pull/123", + labels: [], + pull_request: {}, + body: "\n", + }, + { + number: 124, + title: "Different caller - should be excluded", + html_url: "https://github.com/owner/repo/pull/124", + labels: [], + pull_request: {}, + body: "\n", + }, + { + number: 125, + title: "Old PR without call-id - should be excluded", + html_url: "https://github.com/owner/repo/pull/125", + labels: [], + pull_request: {}, + body: "", + }, + ], + }, + }); + + const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "my-reusable-workflow", 999, "owner/repo/CallerA"); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + }); + + it("should use close-key marker as primary search term when closeOlderKey is provided", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Has close-key marker - should be included", + html_url: "https://github.com/owner/repo/pull/123", + labels: [], + pull_request: {}, + body: "\n", + }, + { + number: 124, + title: "Missing close-key marker - should be excluded", + html_url: "https://github.com/owner/repo/pull/124", + labels: [], + pull_request: {}, + body: "", + }, + ], + }, + }); + + const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "some-workflow", 999, undefined, "my-stable-key"); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: 'repo:owner/repo is:pr is:open "gh-aw-close-key: my-stable-key" in:body', + per_page: 50, + }); + }); + + it("should return empty array when neither workflowId nor closeOlderKey is provided", async () => { + const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "", 999, undefined, undefined); + + expect(results).toHaveLength(0); + expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + }); + }); + + describe("addPullRequestComment", () => { + it("should add comment to pull request", async () => { + mockGithub.rest.issues.createComment.mockResolvedValue({ + data: { + id: 456, + html_url: "https://github.com/owner/repo/pull/123#issuecomment-456", + }, + }); + + const result = await addPullRequestComment(mockGithub, "owner", "repo", 123, "Test comment"); + + expect(result).toEqual({ + id: 456, + html_url: "https://github.com/owner/repo/pull/123#issuecomment-456", + }); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ + owner: "owner", + repo: "repo", + issue_number: 123, + body: "Test comment", + }); + }); + }); + + describe("closePullRequest", () => { + it("should close pull request", async () => { + mockGithub.rest.pulls.update.mockResolvedValue({ + data: { + number: 123, + html_url: "https://github.com/owner/repo/pull/123", + }, + }); + + const result = await closePullRequest(mockGithub, "owner", "repo", 123); + + expect(result).toEqual({ + number: 123, + html_url: "https://github.com/owner/repo/pull/123", + }); + expect(mockGithub.rest.pulls.update).toHaveBeenCalledWith({ + owner: "owner", + repo: "repo", + pull_number: 123, + state: "closed", + }); + }); + }); + + describe("getCloseOlderPullRequestMessage", () => { + it("should generate closing message", () => { + const message = getCloseOlderPullRequestMessage({ + newPullRequestUrl: "https://github.com/owner/repo/pull/125", + newPullRequestNumber: 125, + workflowName: "Test Workflow", + runUrl: "https://github.com/owner/repo/actions/runs/123", + }); + + expect(message).toContain("newer pull request has been created: #125"); + expect(message).toContain("https://github.com/owner/repo/pull/125"); + expect(message).toContain("Test Workflow"); + expect(message).toContain("https://github.com/owner/repo/actions/runs/123"); + }); + }); + + describe("closeOlderPullRequests", () => { + it("should close older pull requests successfully", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "[chaos-test] Old PR", + html_url: "https://github.com/owner/repo/pull/123", + labels: [], + pull_request: {}, + body: "", + }, + ], + }, + }); + + mockGithub.rest.issues.createComment.mockResolvedValue({ + data: { id: 456, html_url: "https://github.com/owner/repo/pull/123#issuecomment-456" }, + }); + + mockGithub.rest.pulls.update.mockResolvedValue({ + data: { number: 123, html_url: "https://github.com/owner/repo/pull/123" }, + }); + + const newPR = { number: 125, html_url: "https://github.com/owner/repo/pull/125" }; + const results = await closeOlderPullRequests(mockGithub, "owner", "repo", "chaos-pr-bundle-fuzzer", newPR, "Chaos PR Bundle Fuzzer", "https://github.com/owner/repo/actions/runs/123"); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); + expect(mockGithub.rest.pulls.update).toHaveBeenCalledWith({ + owner: "owner", + repo: "repo", + pull_number: 123, + state: "closed", + }); + }); + + it("should limit to MAX_CLOSE_COUNT pull requests", async () => { + const items = []; + for (let i = 1; i <= 15; i++) { + items.push({ + number: i, + title: `PR ${i}`, + html_url: `https://github.com/owner/repo/pull/${i}`, + labels: [], + pull_request: {}, + body: "", + }); + } + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { items }, + }); + + mockGithub.rest.issues.createComment.mockResolvedValue({ + data: { id: 456, html_url: "https://github.com/owner/repo/pull/1#issuecomment-456" }, + }); + + mockGithub.rest.pulls.update.mockResolvedValue({ + data: { number: 1, html_url: "https://github.com/owner/repo/pull/1" }, + }); + + const newPR = { number: 20, html_url: "https://github.com/owner/repo/pull/20" }; + const results = await closeOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", newPR, "My Workflow", "https://github.com/owner/repo/actions/runs/123"); + + expect(results).toHaveLength(MAX_CLOSE_COUNT); + expect(global.core.warning).toHaveBeenCalledWith(`⚠️ Found 15 older pull requests, but only closing the first ${MAX_CLOSE_COUNT}`); + }); + + it("should continue on error for individual pull requests", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "PR 1", + html_url: "https://github.com/owner/repo/pull/123", + labels: [], + pull_request: {}, + body: "", + }, + { + number: 124, + title: "PR 2", + html_url: "https://github.com/owner/repo/pull/124", + labels: [], + pull_request: {}, + body: "", + }, + ], + }, + }); + + // First PR fails + mockGithub.rest.issues.createComment.mockRejectedValueOnce(new Error("API Error")); + + // Second PR succeeds + mockGithub.rest.issues.createComment.mockResolvedValueOnce({ + data: { id: 456, html_url: "https://github.com/owner/repo/pull/124#issuecomment-456" }, + }); + + mockGithub.rest.pulls.update.mockResolvedValue({ + data: { number: 124, html_url: "https://github.com/owner/repo/pull/124" }, + }); + + const newPR = { number: 125, html_url: "https://github.com/owner/repo/pull/125" }; + const results = await closeOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", newPR, "My Workflow", "https://github.com/owner/repo/actions/runs/123"); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(124); + expect(global.core.error).toHaveBeenCalledWith(expect.stringContaining("Failed to close pull request #123")); + }); + + it("should return empty array if no older pull requests found", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { items: [] }, + }); + + const newPR = { number: 125, html_url: "https://github.com/owner/repo/pull/125" }; + const results = await closeOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", newPR, "My Workflow", "https://github.com/owner/repo/actions/runs/123"); + + expect(results).toHaveLength(0); + expect(global.core.info).toHaveBeenCalledWith("✓ No older pull requests found to close - operation complete"); + }); + }); +}); diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 853bfdc878e..43adbcfbe32 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -15,7 +15,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { replaceTemporaryIdReferences, replaceTemporaryIdReferencesInPatch, getOrGenerateTemporaryId } = require("./temporary_id.cjs"); const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { addExpirationToFooter } = require("./ephemerals.cjs"); -const { generateWorkflowIdMarker } = require("./generate_footer.cjs"); +const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, normalizeCloseOlderKey } = require("./generate_footer.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { assembleMarkdownBodyParts } = require("./markdown_body_helpers.cjs"); const { getBodyHeader } = require("./messages_header.cjs"); @@ -23,6 +23,7 @@ const { generateHistoryUrl } = require("./generate_history_link.cjs"); const { normalizeBranchName } = require("./normalize_branch_name.cjs"); const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"); const { createCheckoutManager } = require("./dynamic_checkout.cjs"); +const { closeOlderPullRequests } = require("./close_older_pull_requests.cjs"); const { getBaseBranch } = require("./get_base_branch.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); @@ -681,6 +682,9 @@ async function main(config = {}) { const includeFooter = parseBoolTemplatable(config.footer, true); const fallbackAsIssue = config.fallback_as_issue !== false; // Default to true (fallback enabled) const autoCloseIssue = parseBoolTemplatable(config.auto_close_issue, true); // Default to true (auto-close enabled) + const closeOlderPullRequestsEnabled = parseBoolTemplatable(config.close_older_pull_requests, false); + const rawCloseOlderKey = config.close_older_key ? String(config.close_older_key) : ""; + const closeOlderKey = rawCloseOlderKey ? normalizeCloseOlderKey(rawCloseOlderKey) : ""; // Environment validation - fail early if required variables are missing const workflowId = process.env.GH_AW_WORKFLOW_ID; @@ -688,6 +692,8 @@ async function main(config = {}) { throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); } + const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID || ""; + // Extract triggering issue number from context (for auto-linking PRs to issues) const triggeringIssueNumber = typeof context !== "undefined" && context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; @@ -728,6 +734,12 @@ async function main(config = {}) { if (expiresHours > 0) { core.info(`Pull requests expire after: ${expiresHours} hours`); } + if (closeOlderPullRequestsEnabled) { + core.info(`Close older pull requests enabled: older PRs with same workflow-id marker will be closed`); + if (rawCloseOlderKey) { + core.info(` Using explicit close-older-key: "${closeOlderKey}"`); + } + } core.info(`Max count: ${maxCount}`); core.info(`Max patch size: ${maxSizeKb} KB`); core.info(`Max patch files: ${maxFiles}`); @@ -1319,6 +1331,17 @@ async function main(config = {}) { footerParts.push(workflowIdMarker); } + // Embed gh-aw-workflow-call-id marker so callers sharing the same reusable workflow + // do not close each other's PRs when close-older-pull-requests is enabled. + if (callerWorkflowId) { + bodyLines.push(generateWorkflowCallIdMarker(callerWorkflowId)); + } + + // Embed gh-aw-close-key marker when an explicit deduplication key is set. + if (closeOlderKey) { + bodyLines.push(generateCloseKeyMarker(closeOlderKey)); + } + bodyLines.push(""); // Prepare the body content @@ -2181,6 +2204,25 @@ ${patchPreview}`; // in the same repo as the activation, so the global client has the correct context for updating the comment. await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); + // Close older pull requests if enabled (best-effort: errors are logged but do not fail the workflow) + if (closeOlderPullRequestsEnabled) { + if (workflowId || closeOlderKey) { + const searchKey = closeOlderKey ? `gh-aw-close-key: ${closeOlderKey}` : `workflow-id: ${workflowId}`; + core.info(`Attempting to close older pull requests for ${repoParts.owner}/${repoParts.repo}#${pullRequest.number} using ${searchKey}`); + try { + const closedPRs = await closeOlderPullRequests(github, repoParts.owner, repoParts.repo, workflowId, { number: pullRequest.number, html_url: pullRequest.html_url }, workflowName, runUrl, callerWorkflowId, closeOlderKey); + if (closedPRs.length > 0) { + core.info(`Closed ${closedPRs.length} older pull request(s)`); + } + } catch (error) { + // Log error but don't fail the workflow + core.warning(`Failed to close older pull requests: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + core.warning("Close older pull requests enabled but neither GH_AW_WORKFLOW_ID nor close-older-key is set - skipping"); + } + } + // Write summary to GitHub Actions summary await core.summary .addRaw( diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 0b0fd28c69c..902a49c1e7e 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -4731,6 +4731,22 @@ safe-outputs: # (optional) signed-commits: true + # When true, automatically close older open pull requests from the same workflow + # (identified by the workflow-id marker in the PR body) with a comment linking to + # the new PR. Searches for open PRs containing the workflow-id marker. Maximum 10 + # pull requests will be closed. Only runs if PR creation succeeds. + # (optional) + close-older-pull-requests: true + + # Optional explicit deduplication key for close-older matching. When set, a `` marker is embedded in the PR body and used as the + # primary key for searching and filtering older pull requests instead of the + # workflow-id markers. This gives deterministic isolation across caller workflows + # and is stable across workflow renames. The value is normalized to identifier + # style (lowercase alphanumeric, dashes, underscores). + # (optional) + close-older-key: "example-value" + # If true, emit step summary messages instead of making GitHub API calls for this # specific output type (preview mode) # (optional) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 559178c0bb9..5b2f2354a37 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6365,6 +6365,21 @@ "description": "When true (default), automatically appends a closing keyword (\"Fixes #N\") to the PR description when the workflow is triggered from an issue and no closing keyword is already present. This causes GitHub to auto-close the triggering issue when the PR is merged. Set to false to prevent this behavior, e.g., for partial-work PRs or multi-PR workflows. Accepts a boolean or a GitHub Actions expression.", "default": true }, + "close-older-pull-requests": { + "allOf": [ + { + "$ref": "#/$defs/templatable_boolean" + } + ], + "description": "When true, automatically close older open pull requests with the same workflow-id marker (or close-older-key when set) with a comment linking to the new PR. Maximum 10 PRs will be closed. Only runs if PR creation succeeds.", + "default": false + }, + "close-older-key": { + "type": "string", + "description": "Optional explicit deduplication key for close-older-pull-requests matching. When set, a `` marker is embedded in the PR body and used as the primary key for searching and filtering older PRs instead of the workflow-id markers. The value is normalized to identifier style (lowercase alphanumeric, dashes, underscores).", + "minLength": 1, + "pattern": "\\S" + }, "github-token-for-extra-empty-commit": { "type": "string", "description": "Token used to push an empty commit after PR creation 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." diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 141020cb6af..4bcbbc00bca 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -17,6 +17,18 @@ func getFallbackAsIssue(config *CreatePullRequestsConfig) bool { return *config.FallbackAsIssue } +// isCloseOlderPullRequestsEnabled returns true when close-older-pull-requests is +// configured and not explicitly set to false ("false" or "0"). Any other non-empty +// value, including GitHub Actions expressions like "${{ ... }}", is treated as enabled. +// Used for compile-time permission calculation. +func isCloseOlderPullRequestsEnabled(config *CreatePullRequestsConfig) bool { + if config == nil || config.CloseOlderPullRequests == nil { + return false + } + v := *config.CloseOlderPullRequests + return v != "" && v != "false" && v != "0" +} + // CreatePullRequestsConfig holds configuration for creating GitHub pull requests from agent output type CreatePullRequestsConfig struct { BaseSafeOutputConfig `yaml:",inline"` @@ -53,6 +65,8 @@ type CreatePullRequestsConfig struct { PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. + CloseOlderPullRequests *string `yaml:"close-older-pull-requests,omitempty"` // When true, close older open pull requests with the same workflow-id marker when a new one is created. Capped at 10 closures per run. + CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. } // parseCreatePullRequestsConfig handles only create-pull-request (singular) configuration @@ -68,7 +82,7 @@ func (c *Compiler) parseCreatePullRequestsConfig(outputMap map[string]any) *Crea outputMap, "create-pull-request", CreateParseOptions{ - BoolFields: []string{"draft", "allow-empty", "auto-merge", "footer", "auto-close-issue"}, + BoolFields: []string{"draft", "allow-empty", "auto-merge", "footer", "auto-close-issue", "close-older-pull-requests"}, IntFields: []string{"max"}, HandleExpires: true, }, diff --git a/pkg/workflow/safe_output_handlers.go b/pkg/workflow/safe_output_handlers.go index 0dde2900386..ab85dcc383d 100644 --- a/pkg/workflow/safe_output_handlers.go +++ b/pkg/workflow/safe_output_handlers.go @@ -159,6 +159,10 @@ var safeOutputHandlers = []safeOutputHandlerDescriptor{ if safeOutputs.CreatePullRequests.AllowWorkflows { permissions.Set(PermissionWorkflows, PermissionWrite) } + // close-older-pull-requests requires issues: write to add closing comments + if isCloseOlderPullRequestsEnabled(safeOutputs.CreatePullRequests) { + permissions.Set(PermissionIssues, PermissionWrite) + } return permissions }, }, diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 7395051719c..885d988d4f4 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -1251,6 +1251,8 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfTrue("recreate_ref", c.RecreateRef). AddIfNotEmpty("patch_format", c.PatchFormat). AddBoolPtr("signed_commits", c.SignedCommits). + AddTemplatableBool("close_older_pull_requests", c.CloseOlderPullRequests). + AddIfNotEmpty("close_older_key", c.CloseOlderKey). AddIfTrue("staged", c.Staged) return builder.Build() },