Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 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
45 changes: 43 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,46 @@ 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:

Copilot AI Apr 24, 2026

Copy link

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.
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
163 changes: 163 additions & 0 deletions actions/setup/js/update_pull_request_branches.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// @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 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

Copilot AI Apr 24, 2026

Copy link

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}`
);
}

/**
* Update all mergeable PR branches.
* @returns {Promise<void>}
*/
async function main() {
const owner = context.repo.owner;
const repo = context.repo.repo;

core.info(`Updating pull request branches in ${owner}/${repo}`);
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);
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,
};
124 changes: 124 additions & 0 deletions actions/setup/js/update_pull_request_branches.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// @ts-check
import { describe, it, expect, beforeEach, vi } from "vitest";

vi.mock("./github_rate_limit_logger.cjs", () => ({
fetchAndLogRateLimit: vi.fn().mockResolvedValue(undefined),
}));

const moduleUnderTest = await import("./update_pull_request_branches.cjs");

describe("update_pull_request_branches", () => {
/** @type {any} */
let mockCore;
/** @type {any} */
let mockGithub;
/** @type {any} */
let mockContext;

beforeEach(() => {
vi.clearAllMocks();

mockCore = {
info: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
notice: vi.fn(),
};
mockGithub = {
paginate: vi.fn(),
graphql: vi.fn(),
rest: {
pulls: {
list: vi.fn(),
get: vi.fn(),
updateBranch: vi.fn(),
},
},
};
mockContext = {
repo: {
owner: "owner",
repo: "repo",
},
};

global.core = mockCore;
global.github = mockGithub;
global.context = mockContext;
});

it("updates only mergeable pull requests", async () => {
mockGithub.paginate.mockResolvedValue([{ number: 1 }, { number: 2 }, { number: 3 }]);
mockGithub.rest.pulls.get.mockImplementation(async ({ pull_number }) => {
if (pull_number === 1) return { data: { state: "open", mergeable: true, draft: false, head: { repo: { full_name: "owner/repo" } } } };
if (pull_number === 2) return { data: { state: "open", mergeable: false, draft: false, head: { repo: { full_name: "owner/repo" } } } };
return { data: { state: "open", mergeable: true, draft: false, head: { repo: { full_name: "owner/repo" } } } };
});
mockGithub.rest.pulls.updateBranch.mockResolvedValue({ data: {} });

await moduleUnderTest.main();

expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledTimes(2);
expect(mockGithub.rest.pulls.updateBranch).toHaveBeenNthCalledWith(1, {
Comment on lines +63 to +68

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

moduleUnderTest.main() sleeps for UPDATE_DELAY_MS (1s) between PR updates. The first test updates 2 PRs, so the test will incur a real 1s delay and can slow/flakify the suite. Consider using vi.useFakeTimers() + vi.advanceTimersByTimeAsync(...), or mocking sleep so tests run instantly.

This issue also appears on line 87 of the same file.

Copilot uses AI. Check for mistakes.
owner: "owner",
repo: "repo",
pull_number: 1,
});
expect(mockGithub.rest.pulls.updateBranch).toHaveBeenNthCalledWith(2, {
owner: "owner",
repo: "repo",
pull_number: 3,
});
});

it("continues on non-fatal updateBranch failures", async () => {
mockGithub.paginate.mockResolvedValue([{ number: 7 }]);
mockGithub.rest.pulls.get.mockResolvedValue({ data: { state: "open", mergeable: true, draft: false, head: { repo: { full_name: "owner/repo" } } } });
const err = new Error("Update branch failed");
// @ts-ignore
err.status = 422;
mockGithub.rest.pulls.updateBranch.mockRejectedValue(err);

await expect(moduleUnderTest.main()).resolves.not.toThrow();
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Skipping PR #7"));
});

it("ignores draft pull requests when filtering mergeable pull requests", async () => {
mockGithub.rest.pulls.get.mockImplementation(async ({ pull_number }) => {
if (pull_number === 1) return { data: { state: "open", mergeable: true, draft: true, head: { repo: { full_name: "owner/repo" } } } };
if (pull_number === 2) return { data: { state: "open", mergeable: true, draft: false, head: { repo: { full_name: "owner/repo" } } } };
return { data: { state: "open", mergeable: false, draft: false, head: { repo: { full_name: "owner/repo" } } } };
});

const result = await moduleUnderTest.filterMergeablePullRequests("owner", "repo", [1, 2, 3]);

expect(result).toEqual([2]);
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping PR #1"));
});

it("ignores fork pull requests that cannot be updated by repository token", async () => {
mockGithub.rest.pulls.get.mockImplementation(async ({ pull_number }) => {
if (pull_number === 1) return { data: { state: "open", mergeable: true, draft: false, head: { repo: { full_name: "fork-owner/repo" } } } };
return { data: { state: "open", mergeable: true, draft: false, head: { repo: { full_name: "owner/repo" } } } };
});

const result = await moduleUnderTest.filterMergeablePullRequests("owner", "repo", [1, 2]);

expect(result).toEqual([2]);
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("reason=head_repository_mismatch"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("head_repo=fork-owner/repo"));
});

it("logs explicit reason when head repository is unavailable", async () => {
mockGithub.rest.pulls.get.mockResolvedValue({
data: { state: "open", mergeable: true, draft: false, head: { repo: null } },
});

const result = await moduleUnderTest.filterMergeablePullRequests("owner", "repo", [11]);

expect(result).toEqual([]);
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("reason=head_repository_missing"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("head_repo=unknown"));
});
});
Loading
Loading