Skip to content

Commit 83f094d

Browse files
authored
Add update_pull_request_branches maintenance operation with dedicated workflow job (#28108)
1 parent 8844a98 commit 83f094d

6 files changed

Lines changed: 450 additions & 7 deletions

File tree

.github/workflows/agentics-maintenance.yml

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ on:
5353
- 'activity_report'
5454
- 'close_agentic_workflows_issues'
5555
- 'clean_cache_memories'
56+
- 'update_pull_request_branches'
5657
- 'validate'
5758
run_url:
5859
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:
6263
workflow_call:
6364
inputs:
6465
operation:
65-
description: 'Optional maintenance operation to run (disable, enable, update, upgrade, safe_outputs, create_labels, activity_report, close_agentic_workflows_issues, clean_cache_memories, validate)'
66+
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)'
6667
required: false
6768
type: string
6869
default: ''
@@ -157,7 +158,7 @@ jobs:
157158
await main();
158159
159160
run_operation:
160-
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)) }}
161+
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)) }}
161162
runs-on: ubuntu-slim
162163
permissions:
163164
actions: write
@@ -213,6 +214,47 @@ jobs:
213214
id: record
214215
run: echo "operation=${{ inputs.operation }}" >> "$GITHUB_OUTPUT"
215216

217+
update_pull_request_branches:
218+
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'update_pull_request_branches' && (!(github.event.repository.fork)) }}
219+
runs-on: ubuntu-slim
220+
permissions:
221+
contents: write
222+
pull-requests: write
223+
steps:
224+
- name: Checkout actions folder
225+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
226+
with:
227+
sparse-checkout: |
228+
actions
229+
persist-credentials: false
230+
231+
- name: Setup Scripts
232+
uses: ./actions/setup
233+
with:
234+
destination: ${{ runner.temp }}/gh-aw/actions
235+
236+
- name: Check admin/maintainer permissions
237+
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
238+
with:
239+
github-token: ${{ secrets.GITHUB_TOKEN }}
240+
script: |
241+
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
242+
setupGlobals(core, github, context, exec, io, getOctokit);
243+
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs');
244+
await main();
245+
246+
- name: Update pull request branches
247+
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
248+
env:
249+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
250+
with:
251+
github-token: ${{ secrets.GITHUB_TOKEN }}
252+
script: |
253+
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
254+
setupGlobals(core, github, context, exec, io, getOctokit);
255+
const { main } = require('${{ runner.temp }}/gh-aw/actions/update_pull_request_branches.cjs');
256+
await main();
257+
216258
apply_safe_outputs:
217259
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'safe_outputs' && (!(github.event.repository.fork)) }}
218260
runs-on: ubuntu-slim

actions/setup/js/run_operation_update_upgrade.cjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ function formatTimestamp(date) {
4545
}
4646

4747
/**
48-
* Run 'gh aw update', 'gh aw upgrade', 'gh aw disable', or 'gh aw enable',
48+
* Run maintenance operations handled by run_operation:
49+
* - 'gh aw update', 'gh aw upgrade', 'gh aw disable', 'gh aw enable'
4950
* creating a pull request when needed for update/upgrade operations.
5051
*
5152
* For update/upgrade: runs with --no-compile so lock files are not modified.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// @ts-check
2+
/// <reference types="@actions/github-script" />
3+
4+
const { getErrorMessage } = require("./error_helpers.cjs");
5+
const { withRetry, isTransientError, sleep } = require("./error_recovery.cjs");
6+
const { fetchAndLogRateLimit } = require("./github_rate_limit_logger.cjs");
7+
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
8+
9+
const LIST_PULL_REQUESTS_PER_PAGE = 100;
10+
const UPDATE_DELAY_MS = 1000;
11+
12+
/**
13+
* @param {string} owner
14+
* @param {string} repo
15+
* @returns {Promise<number[]>}
16+
*/
17+
async function listOpenPullRequests(owner, repo) {
18+
const pulls = await github.paginate(github.rest.pulls.list, {
19+
owner,
20+
repo,
21+
state: "open",
22+
per_page: LIST_PULL_REQUESTS_PER_PAGE,
23+
});
24+
25+
return pulls.map(pr => pr.number).filter(number => Number.isInteger(number));
26+
}
27+
28+
/**
29+
* @param {string} owner
30+
* @param {string} repo
31+
* @param {number[]} pullNumbers
32+
* @returns {Promise<number[]>}
33+
*/
34+
async function filterMergeablePullRequests(owner, repo, pullNumbers) {
35+
const mergeable = [];
36+
const baseRepository = `${owner}/${repo}`.toLowerCase();
37+
38+
for (const pullNumber of pullNumbers) {
39+
const { data: pull } = await withRetry(
40+
() =>
41+
github.rest.pulls.get({
42+
owner,
43+
repo,
44+
pull_number: pullNumber,
45+
}),
46+
{
47+
maxRetries: 2,
48+
initialDelayMs: 500,
49+
maxDelayMs: 2000,
50+
jitterMs: 0,
51+
shouldRetry: isTransientError,
52+
},
53+
`fetch pull request #${pullNumber}`
54+
);
55+
56+
const headRepositoryRaw = pull?.head?.repo?.full_name;
57+
const headRepository = headRepositoryRaw?.toLowerCase() ?? "";
58+
const isSameRepository = headRepository === baseRepository;
59+
const isMergeable = pull?.state === "open" && pull?.mergeable === true && pull?.draft !== true && isSameRepository;
60+
if (isMergeable) {
61+
mergeable.push(pullNumber);
62+
continue;
63+
}
64+
65+
let skipReason = "not_mergeable";
66+
if (!isSameRepository) {
67+
skipReason = headRepository ? "head_repository_mismatch" : "head_repository_missing";
68+
}
69+
core.info(`Skipping PR #${pullNumber}: reason=${skipReason}, mergeable=${String(pull?.mergeable)}, state=${pull?.state || "unknown"}, draft=${String(Boolean(pull?.draft))}, head_repo=${headRepository || "unknown"}`);
70+
}
71+
72+
return mergeable;
73+
}
74+
75+
/**
76+
* @param {unknown} error
77+
* @returns {boolean}
78+
*/
79+
function isNonFatalUpdateBranchError(error) {
80+
if (typeof error === "object" && error !== null && "status" in error && error.status === 422) {
81+
return true;
82+
}
83+
84+
const message = getErrorMessage(error).toLowerCase();
85+
return message.includes("update branch failed") || message.includes("head branch is not behind");
86+
}
87+
88+
/**
89+
* @param {string} owner
90+
* @param {string} repo
91+
* @param {number} pullNumber
92+
* @returns {Promise<void>}
93+
*/
94+
async function updatePullRequestBranch(owner, repo, pullNumber) {
95+
await withRetry(
96+
() =>
97+
github.rest.pulls.updateBranch({
98+
owner,
99+
repo,
100+
pull_number: pullNumber,
101+
}),
102+
{
103+
maxRetries: 2,
104+
initialDelayMs: 1000,
105+
maxDelayMs: 10000,
106+
shouldRetry: isTransientError,
107+
},
108+
`update branch for pull request #${pullNumber}`
109+
);
110+
}
111+
112+
/**
113+
* @param {string} owner
114+
* @param {string} repo
115+
* @param {number} pullNumber
116+
* @param {string} runUrl
117+
* @returns {Promise<void>}
118+
*/
119+
async function addMaintenanceUpdateComment(owner, repo, pullNumber, runUrl) {
120+
const body = `🛠️ Agentic Maintenance updated this pull request branch.\n\n[View workflow run](${runUrl})`;
121+
await github.rest.issues.createComment({
122+
owner,
123+
repo,
124+
issue_number: pullNumber,
125+
body,
126+
});
127+
}
128+
129+
/**
130+
* Update all mergeable PR branches.
131+
* @returns {Promise<void>}
132+
*/
133+
async function main() {
134+
const owner = context.repo.owner;
135+
const repo = context.repo.repo;
136+
const runUrl = buildWorkflowRunUrl(context, context.repo);
137+
138+
core.info(`Updating pull request branches in ${owner}/${repo}`);
139+
core.info(`Run URL: ${runUrl}`);
140+
await fetchAndLogRateLimit(github, "update_pull_request_branches_start");
141+
142+
const openPullRequests = await listOpenPullRequests(owner, repo);
143+
core.info(`Found ${openPullRequests.length} open pull request(s)`);
144+
if (openPullRequests.length === 0) return;
145+
146+
const mergeablePullRequests = await filterMergeablePullRequests(owner, repo, openPullRequests);
147+
core.info(`Found ${mergeablePullRequests.length} mergeable pull request(s)`);
148+
if (mergeablePullRequests.length === 0) return;
149+
150+
let updatedCount = 0;
151+
let skippedCount = 0;
152+
let failedCount = 0;
153+
154+
for (let i = 0; i < mergeablePullRequests.length; i++) {
155+
const pullNumber = mergeablePullRequests[i];
156+
try {
157+
core.info(`Updating branch for PR #${pullNumber}`);
158+
await updatePullRequestBranch(owner, repo, pullNumber);
159+
await addMaintenanceUpdateComment(owner, repo, pullNumber, runUrl);
160+
updatedCount++;
161+
} catch (error) {
162+
if (isNonFatalUpdateBranchError(error)) {
163+
skippedCount++;
164+
core.warning(`Skipping PR #${pullNumber}: ${getErrorMessage(error)}`);
165+
} else {
166+
failedCount++;
167+
core.error(`Failed to update branch for PR #${pullNumber}: ${getErrorMessage(error)}`);
168+
}
169+
}
170+
171+
if (i < mergeablePullRequests.length - 1) {
172+
await sleep(UPDATE_DELAY_MS);
173+
}
174+
}
175+
176+
await fetchAndLogRateLimit(github, "update_pull_request_branches_end");
177+
core.notice(`update_pull_request_branches completed: updated=${updatedCount}, skipped=${skippedCount}, failed=${failedCount}`);
178+
}
179+
180+
module.exports = {
181+
main,
182+
filterMergeablePullRequests,
183+
isNonFatalUpdateBranchError,
184+
};

0 commit comments

Comments
 (0)