-
Notifications
You must be signed in to change notification settings - Fork 371
Add update_pull_request_branches maintenance operation with dedicated workflow job #28108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
81ac174
e6542a5
a003433
649024c
b064a93
69eacaa
8f30fef
1507b7f
6546f14
d71669e
c74ae15
a40c8ae
d73f1e1
2911334
aca2295
78679e2
3c51429
bec130a
f592da1
9819257
c3a5a66
3f789eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,7 @@ on: | |
| - 'activity_report' | ||
| - 'close_agentic_workflows_issues' | ||
| - 'clean_cache_memories' | ||
| - 'update_pull_request_branches' | ||
| - '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.' | ||
|
|
@@ -62,7 +63,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, update_pull_request_branches, validate)' | ||
| required: false | ||
| type: string | ||
| default: '' | ||
|
|
@@ -157,7 +158,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 != 'update_pull_request_branches' && inputs.operation != 'validate' && (!(github.event.repository.fork)) }} | ||
| runs-on: ubuntu-slim | ||
| permissions: | ||
| actions: write | ||
|
|
@@ -213,6 +214,47 @@ jobs: | |
| id: record | ||
| run: echo "operation=${{ inputs.operation }}" >> "$GITHUB_OUTPUT" | ||
|
|
||
| update_pull_request_branches: | ||
| if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'update_pull_request_branches' && (!(github.event.repository.fork)) }} | ||
| runs-on: ubuntu-slim | ||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @copilot you need contents: write as well
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated in |
||
| steps: | ||
| - name: Checkout actions folder | ||
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | ||
| with: | ||
| sparse-checkout: | | ||
| actions | ||
| persist-credentials: false | ||
|
|
||
| - name: Setup Scripts | ||
| uses: ./actions/setup | ||
| with: | ||
| destination: ${{ runner.temp }}/gh-aw/actions | ||
|
|
||
| - name: Check admin/maintainer permissions | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @copilot pass the GitHub token to any step using GitHub APIs to avoid rate limiting, include admin check and logs download
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in |
||
| 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: Update pull request branches | ||
| uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| 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/update_pull_request_branches.cjs'); | ||
| await main(); | ||
|
|
||
| apply_safe_outputs: | ||
| if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'safe_outputs' && (!(github.event.repository.fork)) }} | ||
| runs-on: ubuntu-slim | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| // @ts-check | ||
| /// <reference types="@actions/github-script" /> | ||
|
|
||
| const { getErrorMessage } = require("./error_helpers.cjs"); | ||
| const { withRetry, isTransientError, sleep } = require("./error_recovery.cjs"); | ||
| const { fetchAndLogRateLimit } = require("./github_rate_limit_logger.cjs"); | ||
| const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); | ||
|
|
||
| const LIST_PULL_REQUESTS_PER_PAGE = 100; | ||
| const UPDATE_DELAY_MS = 1000; | ||
|
|
||
| /** | ||
| * @param {string} owner | ||
| * @param {string} repo | ||
| * @returns {Promise<number[]>} | ||
| */ | ||
| async function listOpenPullRequests(owner, repo) { | ||
| const pulls = await github.paginate(github.rest.pulls.list, { | ||
| owner, | ||
| repo, | ||
| state: "open", | ||
| per_page: LIST_PULL_REQUESTS_PER_PAGE, | ||
| }); | ||
|
|
||
| return pulls.map(pr => pr.number).filter(number => Number.isInteger(number)); | ||
| } | ||
|
|
||
| /** | ||
| * @param {string} owner | ||
| * @param {string} repo | ||
| * @param {number[]} pullNumbers | ||
| * @returns {Promise<number[]>} | ||
| */ | ||
| async function filterMergeablePullRequests(owner, repo, pullNumbers) { | ||
| const mergeable = []; | ||
| const baseRepository = `${owner}/${repo}`.toLowerCase(); | ||
|
|
||
| for (const pullNumber of pullNumbers) { | ||
| const { data: pull } = await withRetry( | ||
| () => | ||
| github.rest.pulls.get({ | ||
| owner, | ||
| repo, | ||
| pull_number: pullNumber, | ||
| }), | ||
| { | ||
| maxRetries: 2, | ||
| initialDelayMs: 500, | ||
| maxDelayMs: 2000, | ||
| jitterMs: 0, | ||
| shouldRetry: isTransientError, | ||
| }, | ||
| `fetch pull request #${pullNumber}` | ||
| ); | ||
|
|
||
| const headRepositoryRaw = pull?.head?.repo?.full_name; | ||
| const headRepository = headRepositoryRaw?.toLowerCase() ?? ""; | ||
| const isSameRepository = headRepository === baseRepository; | ||
| const isMergeable = pull?.state === "open" && pull?.mergeable === true && pull?.draft !== true && isSameRepository; | ||
| if (isMergeable) { | ||
| mergeable.push(pullNumber); | ||
| continue; | ||
| } | ||
|
|
||
| let skipReason = "not_mergeable"; | ||
| if (!isSameRepository) { | ||
| skipReason = headRepository ? "head_repository_mismatch" : "head_repository_missing"; | ||
| } | ||
| core.info(`Skipping PR #${pullNumber}: reason=${skipReason}, mergeable=${String(pull?.mergeable)}, state=${pull?.state || "unknown"}, draft=${String(Boolean(pull?.draft))}, head_repo=${headRepository || "unknown"}`); | ||
| } | ||
|
Comment on lines
+54
to
+70
|
||
|
|
||
| return mergeable; | ||
| } | ||
|
|
||
| /** | ||
| * @param {unknown} error | ||
| * @returns {boolean} | ||
| */ | ||
| function isNonFatalUpdateBranchError(error) { | ||
| if (typeof error === "object" && error !== null && "status" in error && error.status === 422) { | ||
| return true; | ||
| } | ||
|
|
||
| const message = getErrorMessage(error).toLowerCase(); | ||
| return message.includes("update branch failed") || message.includes("head branch is not behind"); | ||
| } | ||
|
|
||
| /** | ||
| * @param {string} owner | ||
| * @param {string} repo | ||
| * @param {number} pullNumber | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function updatePullRequestBranch(owner, repo, pullNumber) { | ||
| await withRetry( | ||
| () => | ||
| github.rest.pulls.updateBranch({ | ||
| owner, | ||
| repo, | ||
| pull_number: pullNumber, | ||
| }), | ||
| { | ||
| maxRetries: 2, | ||
| initialDelayMs: 1000, | ||
| maxDelayMs: 10000, | ||
| shouldRetry: isTransientError, | ||
| }, | ||
| `update branch for pull request #${pullNumber}` | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @param {string} owner | ||
| * @param {string} repo | ||
| * @param {number} pullNumber | ||
| * @param {string} runUrl | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function addMaintenanceUpdateComment(owner, repo, pullNumber, runUrl) { | ||
| const body = `🛠️ Agentic Maintenance updated this pull request branch.\n\n[View workflow run](${runUrl})`; | ||
| await github.rest.issues.createComment({ | ||
| owner, | ||
| repo, | ||
| issue_number: pullNumber, | ||
| body, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Update all mergeable PR branches. | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function main() { | ||
| const owner = context.repo.owner; | ||
| const repo = context.repo.repo; | ||
| const runUrl = buildWorkflowRunUrl(context, context.repo); | ||
|
|
||
| core.info(`Updating pull request branches in ${owner}/${repo}`); | ||
| core.info(`Run URL: ${runUrl}`); | ||
| await fetchAndLogRateLimit(github, "update_pull_request_branches_start"); | ||
|
|
||
| const openPullRequests = await listOpenPullRequests(owner, repo); | ||
| core.info(`Found ${openPullRequests.length} open pull request(s)`); | ||
| if (openPullRequests.length === 0) return; | ||
|
|
||
| const mergeablePullRequests = await filterMergeablePullRequests(owner, repo, openPullRequests); | ||
| core.info(`Found ${mergeablePullRequests.length} mergeable pull request(s)`); | ||
| if (mergeablePullRequests.length === 0) return; | ||
|
|
||
| let updatedCount = 0; | ||
| let skippedCount = 0; | ||
| let failedCount = 0; | ||
|
|
||
| for (let i = 0; i < mergeablePullRequests.length; i++) { | ||
| const pullNumber = mergeablePullRequests[i]; | ||
| try { | ||
| core.info(`Updating branch for PR #${pullNumber}`); | ||
| await updatePullRequestBranch(owner, repo, pullNumber); | ||
| await addMaintenanceUpdateComment(owner, repo, pullNumber, runUrl); | ||
| updatedCount++; | ||
| } catch (error) { | ||
| if (isNonFatalUpdateBranchError(error)) { | ||
| skippedCount++; | ||
| core.warning(`Skipping PR #${pullNumber}: ${getErrorMessage(error)}`); | ||
| } else { | ||
| failedCount++; | ||
| core.error(`Failed to update branch for PR #${pullNumber}: ${getErrorMessage(error)}`); | ||
| } | ||
| } | ||
|
|
||
| if (i < mergeablePullRequests.length - 1) { | ||
| await sleep(UPDATE_DELAY_MS); | ||
| } | ||
| } | ||
|
|
||
| await fetchAndLogRateLimit(github, "update_pull_request_branches_end"); | ||
| core.notice(`update_pull_request_branches completed: updated=${updatedCount}, skipped=${skippedCount}, failed=${failedCount}`); | ||
| } | ||
|
|
||
| module.exports = { | ||
| main, | ||
| filterMergeablePullRequests, | ||
| isNonFatalUpdateBranchError, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This job checks out repository content (
actions/checkoutwith sparse-checkout) but its job-level permissions only includepull-requests: write. With job-levelpermissionsset,contentsbecomesnone, which can break checkout and the local./actions/setupaction. Addcontents: readto the job permissions.