Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion .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
12 changes: 10 additions & 2 deletions actions/setup/js/run_operation_update_upgrade.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ 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'
* - 'update_pull_request_branches'
* creating a pull request when needed for update/upgrade operations.
*
* For update/upgrade: runs with --no-compile so lock files are not modified.
Expand All @@ -56,7 +58,7 @@ function formatTimestamp(date) {
*
* Required environment variables:
* GH_TOKEN - GitHub token for gh CLI auth and git push
* GH_AW_OPERATION - 'update', 'upgrade', 'disable', or 'enable'
* GH_AW_OPERATION - 'update', 'upgrade', 'disable', 'enable', or 'update_pull_request_branches'
* GH_AW_CMD_PREFIX - Command prefix: './gh-aw' (dev) or 'gh aw' (release)
*
* @returns {Promise<void>}
Expand All @@ -71,6 +73,12 @@ async function main() {
const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw";
const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean);

if (operation === "update_pull_request_branches") {
const { main: updatePullRequestBranchesMain } = require("./update_pull_request_branches.cjs");
await updatePullRequestBranchesMain();
return;
}

// Handle enable/disable operations: run the command and finish (no PR needed)
if (operation === "disable" || operation === "enable") {
const fullCmd = [bin, ...prefixArgs, operation].join(" ");
Expand Down
211 changes: 211 additions & 0 deletions actions/setup/js/update_pull_request_branches.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// @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 ACTIVE_SESSION_STATES = new Set(["open", "active", "in_progress", "queued"]);
const LIST_PULL_REQUESTS_PER_PAGE = 100;
const SESSION_LIST_LIMIT = 1000;
const UPDATE_DELAY_MS = 1000;

/**
* @param {unknown} value
* @returns {number | null}
*/
function parsePullRequestNumber(value) {
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number.parseInt(trimmed, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}

/**
* @param {unknown} value
* @returns {boolean}
*/
function isActiveSessionState(value) {
return typeof value === "string" && ACTIVE_SESSION_STATES.has(value.trim().toLowerCase());
}

/**
* @returns {Promise<Set<number>>}
*/
async function listPullRequestsWithActiveSessions() {

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 want PR WITHOUT active sessions

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 to explicitly filter to PRs without active sessions before branch updates (new filterPullRequestsWithoutActiveSessions path + test coverage). Addressed in commit a003433.

core.info("Listing agent sessions to identify PRs with active sessions");
const { stdout } = await exec.getExecOutput("gh", ["agent-task", "list", "--limit", String(SESSION_LIST_LIMIT), "--json", "pullRequestNumber,state"], {
silent: true,
});

if (!stdout.trim()) return new Set();

/** @type {Array<{pullRequestNumber?: number | string, state?: string}>} */
const sessions = JSON.parse(stdout);
const prNumbers = new Set();

for (const session of sessions) {
if (!isActiveSessionState(session?.state)) continue;
const prNumber = parsePullRequestNumber(session?.pullRequestNumber);
if (prNumber !== null) prNumbers.add(prNumber);
}

core.info(`Found ${prNumbers.size} pull request(s) with active agent sessions`);
return prNumbers;
}

/**
* @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 = [];

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 isMergeable = pull?.state === "open" && pull?.mergeable === true && pull?.draft !== true;
if (isMergeable) {
mergeable.push(pullNumber);
continue;
}

core.info(`Skipping PR #${pullNumber}: mergeable=${String(pull?.mergeable)}, state=${pull?.state || "unknown"}, draft=${String(Boolean(pull?.draft))}`);
}
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 that do not have active agent sessions.
* @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;

const pullRequestsWithSessions = await listPullRequestsWithActiveSessions();
const eligiblePullRequests = mergeablePullRequests.filter(number => !pullRequestsWithSessions.has(number));
core.info(`Found ${eligiblePullRequests.length} eligible pull request(s) without active sessions`);
if (eligiblePullRequests.length === 0) return;

let updatedCount = 0;
let skippedCount = 0;
let failedCount = 0;

for (let i = 0; i < eligiblePullRequests.length; i++) {
const pullNumber = eligiblePullRequests[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 < eligiblePullRequests.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,
parsePullRequestNumber,
isActiveSessionState,
listPullRequestsWithActiveSessions,
filterMergeablePullRequests,
isNonFatalUpdateBranchError,
};
110 changes: 110 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,110 @@
// @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 mockExec;
/** @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(),
rest: {
pulls: {
list: vi.fn(),
get: vi.fn(),
updateBranch: vi.fn(),
},
},
};
mockExec = {
getExecOutput: vi.fn(),
};
mockContext = {
repo: {
owner: "owner",
repo: "repo",
},
};

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

it("updates only mergeable pull requests without active sessions", 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 } };
if (pull_number === 2) return { data: { state: "open", mergeable: false, draft: false } };
return { data: { state: "open", mergeable: true, draft: false } };
});
mockExec.getExecOutput.mockResolvedValue({
stdout: JSON.stringify([
{ pullRequestNumber: 3, state: "open" },
{ pullRequestNumber: 10, state: "closed" },
]),
stderr: "",
exitCode: 0,
});
mockGithub.rest.pulls.updateBranch.mockResolvedValue({ data: {} });

await moduleUnderTest.main();

expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledTimes(1);
expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
pull_number: 1,
});
});

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 } });
mockExec.getExecOutput.mockResolvedValue({
stdout: JSON.stringify([]),
stderr: "",
exitCode: 0,
});
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("parses pull request numbers and active states correctly", () => {
expect(moduleUnderTest.parsePullRequestNumber(12)).toBe(12);
expect(moduleUnderTest.parsePullRequestNumber("34")).toBe(34);
expect(moduleUnderTest.parsePullRequestNumber("0")).toBeNull();
expect(moduleUnderTest.parsePullRequestNumber("not-a-number")).toBeNull();

expect(moduleUnderTest.isActiveSessionState("OPEN")).toBe(true);
expect(moduleUnderTest.isActiveSessionState("in_progress")).toBe(true);
expect(moduleUnderTest.isActiveSessionState("closed")).toBe(false);
});
});
5 changes: 5 additions & 0 deletions pkg/workflow/maintenance_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,11 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) {
t.Error("workflow_dispatch operation choices should include 'clean_cache_memories'")
}

// Verify update_pull_request_branches is an option in the operation choices
if !strings.Contains(yaml, "- 'update_pull_request_branches'") {
t.Error("workflow_dispatch operation choices should include 'update_pull_request_branches'")
}

// Verify validate is an option in the operation choices
if !strings.Contains(yaml, "- 'validate'") {
t.Error("workflow_dispatch operation choices should include 'validate'")
Expand Down
Loading
Loading