Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
81ac174
chore: plan update_pull_request_branches maintenance command
Copilot Apr 23, 2026
e6542a5
feat: add update_pull_request_branches maintenance operation
Copilot Apr 23, 2026
a003433
fix: clarify and enforce filtering to PRs without active sessions
Copilot Apr 23, 2026
649024c
chore: revert unrelated spec test artifact
Copilot Apr 23, 2026
b064a93
fix: use Copilot REST API for active session listing
Copilot Apr 23, 2026
69eacaa
chore: add core logging for session listing diagnostics
Copilot Apr 23, 2026
8f30fef
fix: improve core logging safety in session API errors
Copilot Apr 23, 2026
1507b7f
chore: refine logging helper diagnostics
Copilot Apr 23, 2026
6546f14
chore: harden error preview logging path
Copilot Apr 23, 2026
d71669e
fix: split update_pull_request_branches into dedicated maintenance job
Copilot Apr 23, 2026
c74ae15
test: ensure draft pull requests are excluded from branch updates
Copilot Apr 23, 2026
a40c8ae
fix: pass GH_TOKEN to maintenance admin-check API steps
Copilot Apr 23, 2026
d73f1e1
revert: undo GH_TOKEN env propagation in maintenance admin checks
Copilot Apr 23, 2026
2911334
fix: remove agent session checks from update_pull_request_branches
Copilot Apr 24, 2026
aca2295
refactor: remove redundant eligible pull requests variable
Copilot Apr 24, 2026
78679e2
fix: skip fork PRs in update_pull_request_branches permission checks
Copilot Apr 24, 2026
3c51429
fix: log explicit skip reasons for non-updatable PR branches
Copilot Apr 24, 2026
bec130a
refactor: simplify head repository skip-reason logic
Copilot Apr 24, 2026
f592da1
refactor: clarify skip reason derivation for PR update filtering
Copilot Apr 24, 2026
9819257
fix: grant contents write to update_pull_request_branches job
Copilot Apr 24, 2026
c3a5a66
Merge branch 'main' into copilot/add-update-pull-request-branches-com…
github-actions[bot] Apr 24, 2026
3f789eb
feat: comment on PR after branch update with run backlink
Copilot Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions .github/workflows/agentics-maintenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand All @@ -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: ''
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Copy link

Copilot AI Apr 24, 2026

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/checkout with sparse-checkout) but its job-level permissions only include pull-requests: write. With job-level permissions set, contents becomes none, which can break checkout and the local ./actions/setup action. Add contents: read to the job permissions.

Suggested change
permissions:
permissions:
contents: read

Copilot uses AI. Check for mistakes.
contents: write
pull-requests: write
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot you need contents: write as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 9819257: the update_pull_request_branches job now includes contents: write alongside pull-requests: write in both generator output and the generated workflow, with a regression assertion added in workflow generation tests.

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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a40c8ae: all maintenance Check admin/maintainer permissions github-script steps now receive GH_TOKEN in env, and the activity-report logs download step already passes GH_TOKEN. Added regression coverage in TestGenerateMaintenanceWorkflow_AdminCheckPassesGHTokenEnv to keep this enforced.

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
Expand Down
3 changes: 2 additions & 1 deletion actions/setup/js/run_operation_update_upgrade.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ function formatTimestamp(date) {
}

/**
* Run 'gh aw update', 'gh aw upgrade', 'gh aw disable', or 'gh aw enable',
* Run maintenance operations handled by run_operation:
* - 'gh aw update', 'gh aw upgrade', 'gh aw disable', 'gh aw enable'
* creating a pull request when needed for update/upgrade operations.
*
* For update/upgrade: runs with --no-compile so lock files are not modified.
Expand Down
184 changes: 184 additions & 0 deletions actions/setup/js/update_pull_request_branches.cjs
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
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pulls.get can return mergeable: null while GitHub is still computing mergeability. Current logic treats anything other than mergeable === true as non-mergeable and will skip these PRs without retrying, so some mergeable PRs may never get updated. Consider retrying specifically when mergeable === null (similar to getPullRequestWithMergeability in actions/setup/js/merge_pull_request.cjs) before deciding to skip.

Copilot uses AI. Check for mistakes.

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,
};
Loading