From 81ac174d69243aa9f65901c0ff7a5baa2dc5a6b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:51:16 +0000 Subject: [PATCH 01/21] chore: plan update_pull_request_branches maintenance command Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e1b6217e-c851-487f-8690-a75028715f5d Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/spec_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cli/spec_test.go b/pkg/cli/spec_test.go index c6b739b93fc..f46eabc88ec 100644 --- a/pkg/cli/spec_test.go +++ b/pkg/cli/spec_test.go @@ -1117,11 +1117,11 @@ func TestSpec_PublicAPI_ValidateWorkflowIntent(t *testing.T) { // Spec: "Sets a field in frontmatter YAML" func TestSpec_PublicAPI_UpdateFieldInFrontmatter(t *testing.T) { tests := []struct { - name string - content string - fieldName string - fieldValue string - wantErr bool + name string + content string + fieldName string + fieldValue string + wantErr bool checkContains string }{ { From e6542a5a1c9f76c3d6f5d1f1c18e26c5a58d94d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:00:56 +0000 Subject: [PATCH 02/21] feat: add update_pull_request_branches maintenance operation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e1b6217e-c851-487f-8690-a75028715f5d Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 3 +- .../setup/js/run_operation_update_upgrade.cjs | 12 +- .../setup/js/update_pull_request_branches.cjs | 211 ++++++++++++++++++ .../js/update_pull_request_branches.test.cjs | 110 +++++++++ pkg/cli/spec_test.go | 10 +- pkg/workflow/maintenance_workflow_test.go | 5 + pkg/workflow/maintenance_workflow_yaml.go | 3 +- 7 files changed, 345 insertions(+), 9 deletions(-) create mode 100644 actions/setup/js/update_pull_request_branches.cjs create mode 100644 actions/setup/js/update_pull_request_branches.test.cjs diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 13a0d8a8848..07e570cc7ae 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -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: '' diff --git a/actions/setup/js/run_operation_update_upgrade.cjs b/actions/setup/js/run_operation_update_upgrade.cjs index c0dc3768d31..ea2afe6bcae 100644 --- a/actions/setup/js/run_operation_update_upgrade.cjs +++ b/actions/setup/js/run_operation_update_upgrade.cjs @@ -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. @@ -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} @@ -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(" "); diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs new file mode 100644 index 00000000000..6d0a9123773 --- /dev/null +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -0,0 +1,211 @@ +// @ts-check +/// + +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>} + */ +async function listPullRequestsWithActiveSessions() { + 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} + */ +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} + */ +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))}`); + } + + 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} + */ +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} + */ +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, +}; diff --git a/actions/setup/js/update_pull_request_branches.test.cjs b/actions/setup/js/update_pull_request_branches.test.cjs new file mode 100644 index 00000000000..328838ebb38 --- /dev/null +++ b/actions/setup/js/update_pull_request_branches.test.cjs @@ -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); + }); +}); diff --git a/pkg/cli/spec_test.go b/pkg/cli/spec_test.go index f46eabc88ec..c6b739b93fc 100644 --- a/pkg/cli/spec_test.go +++ b/pkg/cli/spec_test.go @@ -1117,11 +1117,11 @@ func TestSpec_PublicAPI_ValidateWorkflowIntent(t *testing.T) { // Spec: "Sets a field in frontmatter YAML" func TestSpec_PublicAPI_UpdateFieldInFrontmatter(t *testing.T) { tests := []struct { - name string - content string - fieldName string - fieldValue string - wantErr bool + name string + content string + fieldName string + fieldValue string + wantErr bool checkContains string }{ { diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 852f3fd78d3..193bbf62d25 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -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'") diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 3dd0362056d..c51b730b9f2 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -62,6 +62,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.' @@ -71,7 +72,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: '' From a00343362a59e43abc9604dad63587903492b9a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:01:14 +0000 Subject: [PATCH 03/21] fix: clarify and enforce filtering to PRs without active sessions Agent-Logs-Url: https://github.com/github/gh-aw/sessions/dfc1d53a-7fec-47e2-a76a-9ecbf4a15d73 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/update_pull_request_branches.cjs | 16 +++++++++++++--- .../js/update_pull_request_branches.test.cjs | 15 +++++++++++++++ pkg/cli/spec_test.go | 10 +++++----- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index 6d0a9123773..647b9ef5f51 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -56,6 +56,17 @@ async function listPullRequestsWithActiveSessions() { return prNumbers; } +/** + * @param {number[]} pullNumbers + * @returns {Promise} + */ +async function filterPullRequestsWithoutActiveSessions(pullNumbers) { + const pullRequestsWithSessions = await listPullRequestsWithActiveSessions(); + const eligiblePullRequests = pullNumbers.filter(number => !pullRequestsWithSessions.has(number)); + core.info(`Found ${eligiblePullRequests.length} eligible pull request(s) without active sessions`); + return eligiblePullRequests; +} + /** * @param {string} owner * @param {string} repo @@ -167,9 +178,7 @@ async function main() { 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`); + const eligiblePullRequests = await filterPullRequestsWithoutActiveSessions(mergeablePullRequests); if (eligiblePullRequests.length === 0) return; let updatedCount = 0; @@ -206,6 +215,7 @@ module.exports = { parsePullRequestNumber, isActiveSessionState, listPullRequestsWithActiveSessions, + filterPullRequestsWithoutActiveSessions, filterMergeablePullRequests, isNonFatalUpdateBranchError, }; diff --git a/actions/setup/js/update_pull_request_branches.test.cjs b/actions/setup/js/update_pull_request_branches.test.cjs index 328838ebb38..f517d32092c 100644 --- a/actions/setup/js/update_pull_request_branches.test.cjs +++ b/actions/setup/js/update_pull_request_branches.test.cjs @@ -107,4 +107,19 @@ describe("update_pull_request_branches", () => { expect(moduleUnderTest.isActiveSessionState("in_progress")).toBe(true); expect(moduleUnderTest.isActiveSessionState("closed")).toBe(false); }); + + it("filters candidate pull requests to only those without active sessions", async () => { + mockExec.getExecOutput.mockResolvedValue({ + stdout: JSON.stringify([ + { pullRequestNumber: 2, state: "OPEN" }, + { pullRequestNumber: 9, state: "queued" }, + ]), + stderr: "", + exitCode: 0, + }); + + const result = await moduleUnderTest.filterPullRequestsWithoutActiveSessions([1, 2, 3]); + + expect(result).toEqual([1, 3]); + }); }); diff --git a/pkg/cli/spec_test.go b/pkg/cli/spec_test.go index c6b739b93fc..f46eabc88ec 100644 --- a/pkg/cli/spec_test.go +++ b/pkg/cli/spec_test.go @@ -1117,11 +1117,11 @@ func TestSpec_PublicAPI_ValidateWorkflowIntent(t *testing.T) { // Spec: "Sets a field in frontmatter YAML" func TestSpec_PublicAPI_UpdateFieldInFrontmatter(t *testing.T) { tests := []struct { - name string - content string - fieldName string - fieldValue string - wantErr bool + name string + content string + fieldName string + fieldValue string + wantErr bool checkContains string }{ { From 649024c03cad71754dc59e990584b221b80f1c85 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:02:33 +0000 Subject: [PATCH 04/21] chore: revert unrelated spec test artifact Agent-Logs-Url: https://github.com/github/gh-aw/sessions/dfc1d53a-7fec-47e2-a76a-9ecbf4a15d73 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/spec_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cli/spec_test.go b/pkg/cli/spec_test.go index f46eabc88ec..c6b739b93fc 100644 --- a/pkg/cli/spec_test.go +++ b/pkg/cli/spec_test.go @@ -1117,11 +1117,11 @@ func TestSpec_PublicAPI_ValidateWorkflowIntent(t *testing.T) { // Spec: "Sets a field in frontmatter YAML" func TestSpec_PublicAPI_UpdateFieldInFrontmatter(t *testing.T) { tests := []struct { - name string - content string - fieldName string - fieldValue string - wantErr bool + name string + content string + fieldName string + fieldValue string + wantErr bool checkContains string }{ { From b064a93ac91b308043bf8bb79ef7007c71a54ca2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:03:27 +0000 Subject: [PATCH 05/21] fix: use Copilot REST API for active session listing Agent-Logs-Url: https://github.com/github/gh-aw/sessions/009352de-ca04-4409-8eaa-4bd19ccad058 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/update_pull_request_branches.cjs | 72 ++++++++++++++++--- .../js/update_pull_request_branches.test.cjs | 52 +++++++------- 2 files changed, 92 insertions(+), 32 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index 647b9ef5f51..5942c37863e 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -8,6 +8,7 @@ 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 SESSION_PAGE_SIZE = 100; const UPDATE_DELAY_MS = 1000; /** @@ -36,19 +37,22 @@ function isActiveSessionState(value) { */ async function listPullRequestsWithActiveSessions() { 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, - }); + const copilotApiURL = await getCopilotAPIURL(); - if (!stdout.trim()) return new Set(); + /** @type {Array<{resource_id?: number | string, state?: string, resource_type?: string}>} */ + const sessions = []; + for (let pageNumber = 1; sessions.length < SESSION_LIST_LIMIT; pageNumber++) { + const pageSessions = await listAgentSessionsPage(copilotApiURL, pageNumber, SESSION_PAGE_SIZE); + if (pageSessions.length === 0) break; + sessions.push(...pageSessions); + if (pageSessions.length < SESSION_PAGE_SIZE) break; + } - /** @type {Array<{pullRequestNumber?: number | string, state?: string}>} */ - const sessions = JSON.parse(stdout); const prNumbers = new Set(); - for (const session of sessions) { + if (session?.resource_type !== "pull") continue; if (!isActiveSessionState(session?.state)) continue; - const prNumber = parsePullRequestNumber(session?.pullRequestNumber); + const prNumber = parsePullRequestNumber(session?.resource_id); if (prNumber !== null) prNumbers.add(prNumber); } @@ -56,6 +60,58 @@ async function listPullRequestsWithActiveSessions() { return prNumbers; } +/** + * @returns {Promise} + */ +async function getCopilotAPIURL() { + const response = await github.graphql(` + query CopilotEndpointsForSessionListing { + viewer { + copilotEndpoints { + api + } + } + } + `); + const apiURL = response?.viewer?.copilotEndpoints?.api; + if (typeof apiURL !== "string" || !apiURL.trim()) { + throw new Error("Unable to resolve Copilot API URL for session listing"); + } + return apiURL.replace(/\/+$/, ""); +} + +/** + * @param {string} copilotApiURL + * @param {number} pageNumber + * @param {number} pageSize + * @returns {Promise>} + */ +async function listAgentSessionsPage(copilotApiURL, pageNumber, pageSize) { + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (!token) throw new Error("Missing GH_TOKEN/GITHUB_TOKEN for Copilot session listing"); + + const sessionsURL = new URL(`${copilotApiURL}/agents/sessions`); + sessionsURL.searchParams.set("page_size", String(pageSize)); + sessionsURL.searchParams.set("page_number", String(pageNumber)); + sessionsURL.searchParams.set("sort", "last_updated_at,desc"); + + const response = await fetch(sessionsURL.toString(), { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": "gh-aw-update-pull-request-branches", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to list agent sessions: HTTP ${response.status}`); + } + + const body = /** @type {any} */ await response.json(); + return Array.isArray(body?.sessions) ? body.sessions : []; +} + /** * @param {number[]} pullNumbers * @returns {Promise} diff --git a/actions/setup/js/update_pull_request_branches.test.cjs b/actions/setup/js/update_pull_request_branches.test.cjs index f517d32092c..a462e2205fe 100644 --- a/actions/setup/js/update_pull_request_branches.test.cjs +++ b/actions/setup/js/update_pull_request_branches.test.cjs @@ -13,9 +13,9 @@ describe("update_pull_request_branches", () => { /** @type {any} */ let mockGithub; /** @type {any} */ - let mockExec; - /** @type {any} */ let mockContext; + /** @type {any} */ + let fetchMock; beforeEach(() => { vi.clearAllMocks(); @@ -29,6 +29,7 @@ describe("update_pull_request_branches", () => { }; mockGithub = { paginate: vi.fn(), + graphql: vi.fn(), rest: { pulls: { list: vi.fn(), @@ -37,9 +38,6 @@ describe("update_pull_request_branches", () => { }, }, }; - mockExec = { - getExecOutput: vi.fn(), - }; mockContext = { repo: { owner: "owner", @@ -49,8 +47,10 @@ describe("update_pull_request_branches", () => { global.core = mockCore; global.github = mockGithub; - global.exec = mockExec; global.context = mockContext; + fetchMock = vi.fn(); + global.fetch = fetchMock; + process.env.GH_TOKEN = "test-token"; }); it("updates only mergeable pull requests without active sessions", async () => { @@ -60,13 +60,15 @@ describe("update_pull_request_branches", () => { 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.graphql.mockResolvedValue({ viewer: { copilotEndpoints: { api: "https://api.copilot.test" } } }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + sessions: [ + { resource_id: 3, state: "open", resource_type: "pull" }, + { resource_id: 10, state: "closed", resource_type: "pull" }, + ], + }), }); mockGithub.rest.pulls.updateBranch.mockResolvedValue({ data: {} }); @@ -83,10 +85,10 @@ describe("update_pull_request_branches", () => { 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, + mockGithub.graphql.mockResolvedValue({ viewer: { copilotEndpoints: { api: "https://api.copilot.test" } } }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ sessions: [] }), }); const err = new Error("Update branch failed"); // @ts-ignore @@ -109,13 +111,15 @@ describe("update_pull_request_branches", () => { }); it("filters candidate pull requests to only those without active sessions", async () => { - mockExec.getExecOutput.mockResolvedValue({ - stdout: JSON.stringify([ - { pullRequestNumber: 2, state: "OPEN" }, - { pullRequestNumber: 9, state: "queued" }, - ]), - stderr: "", - exitCode: 0, + mockGithub.graphql.mockResolvedValue({ viewer: { copilotEndpoints: { api: "https://api.copilot.test" } } }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + sessions: [ + { resource_id: 2, state: "OPEN", resource_type: "pull" }, + { resource_id: 9, state: "queued", resource_type: "pull" }, + ], + }), }); const result = await moduleUnderTest.filterPullRequestsWithoutActiveSessions([1, 2, 3]); From 69eacaab6738a2a9b507ab2302cf8f9c862364fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:33:00 +0000 Subject: [PATCH 06/21] chore: add core logging for session listing diagnostics Agent-Logs-Url: https://github.com/github/gh-aw/sessions/42605c79-7ebd-428c-9727-efe4ae592077 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/update_pull_request_branches.cjs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index 5942c37863e..5fe7db0a20a 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -38,15 +38,22 @@ function isActiveSessionState(value) { async function listPullRequestsWithActiveSessions() { core.info("Listing agent sessions to identify PRs with active sessions"); const copilotApiURL = await getCopilotAPIURL(); + core.info(`Resolved Copilot API endpoint for sessions: ${copilotApiURL}`); + core.info(`Fetching up to ${SESSION_LIST_LIMIT} sessions (page_size=${SESSION_PAGE_SIZE})`); /** @type {Array<{resource_id?: number | string, state?: string, resource_type?: string}>} */ const sessions = []; for (let pageNumber = 1; sessions.length < SESSION_LIST_LIMIT; pageNumber++) { const pageSessions = await listAgentSessionsPage(copilotApiURL, pageNumber, SESSION_PAGE_SIZE); + core.info(`Fetched ${pageSessions.length} session(s) from page ${pageNumber}`); if (pageSessions.length === 0) break; sessions.push(...pageSessions); if (pageSessions.length < SESSION_PAGE_SIZE) break; } + if (sessions.length >= SESSION_LIST_LIMIT) { + core.warning(`Session list reached limit (${SESSION_LIST_LIMIT}); newer sessions may have been truncated`); + } + core.info(`Fetched ${sessions.length} total session record(s) for filtering`); const prNumbers = new Set(); for (const session of sessions) { @@ -64,6 +71,7 @@ async function listPullRequestsWithActiveSessions() { * @returns {Promise} */ async function getCopilotAPIURL() { + core.info("Resolving Copilot API endpoint from GraphQL viewer.copilotEndpoints.api"); const response = await github.graphql(` query CopilotEndpointsForSessionListing { viewer { @@ -77,7 +85,9 @@ async function getCopilotAPIURL() { if (typeof apiURL !== "string" || !apiURL.trim()) { throw new Error("Unable to resolve Copilot API URL for session listing"); } - return apiURL.replace(/\/+$/, ""); + const normalizedAPIURL = apiURL.replace(/\/+$/, ""); + core.info(`Copilot API endpoint resolved: ${normalizedAPIURL}`); + return normalizedAPIURL; } /** @@ -94,6 +104,7 @@ async function listAgentSessionsPage(copilotApiURL, pageNumber, pageSize) { sessionsURL.searchParams.set("page_size", String(pageSize)); sessionsURL.searchParams.set("page_number", String(pageNumber)); sessionsURL.searchParams.set("sort", "last_updated_at,desc"); + core.debug(`Requesting Copilot sessions page ${pageNumber}: ${sessionsURL.toString()}`); const response = await fetch(sessionsURL.toString(), { method: "GET", @@ -105,6 +116,12 @@ async function listAgentSessionsPage(copilotApiURL, pageNumber, pageSize) { }); if (!response.ok) { + const responseBody = await response.text(); + const truncatedBody = responseBody.slice(0, 500); + core.error(`Failed to list agent sessions page ${pageNumber}: HTTP ${response.status} ${response.statusText}`); + if (truncatedBody) { + core.error(`Copilot sessions error response (truncated): ${truncatedBody}`); + } throw new Error(`Failed to list agent sessions: HTTP ${response.status}`); } From 8f30fef89782cd7bc22373b78c3ed174fda1f16f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:36:37 +0000 Subject: [PATCH 07/21] fix: improve core logging safety in session API errors Agent-Logs-Url: https://github.com/github/gh-aw/sessions/42605c79-7ebd-428c-9727-efe4ae592077 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/update_pull_request_branches.cjs | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index 5fe7db0a20a..db6f7be6116 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -104,7 +104,7 @@ async function listAgentSessionsPage(copilotApiURL, pageNumber, pageSize) { sessionsURL.searchParams.set("page_size", String(pageSize)); sessionsURL.searchParams.set("page_number", String(pageNumber)); sessionsURL.searchParams.set("sort", "last_updated_at,desc"); - core.debug(`Requesting Copilot sessions page ${pageNumber}: ${sessionsURL.toString()}`); + core.debug(`Requesting Copilot sessions page ${pageNumber}: ${sessionsURL.origin}${sessionsURL.pathname} (page_size=${pageSize})`); const response = await fetch(sessionsURL.toString(), { method: "GET", @@ -116,8 +116,7 @@ async function listAgentSessionsPage(copilotApiURL, pageNumber, pageSize) { }); if (!response.ok) { - const responseBody = await response.text(); - const truncatedBody = responseBody.slice(0, 500); + const truncatedBody = await readResponsePreview(response, 500); core.error(`Failed to list agent sessions page ${pageNumber}: HTTP ${response.status} ${response.statusText}`); if (truncatedBody) { core.error(`Copilot sessions error response (truncated): ${truncatedBody}`); @@ -129,6 +128,36 @@ async function listAgentSessionsPage(copilotApiURL, pageNumber, pageSize) { return Array.isArray(body?.sessions) ? body.sessions : []; } +/** + * @param {Response} response + * @param {number} maxChars + * @returns {Promise} + */ +async function readResponsePreview(response, maxChars) { + if (!response.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let result = ""; + + try { + while (result.length < maxChars) { + const { done, value } = await reader.read(); + if (done || !value) break; + result += decoder.decode(value, { stream: true }); + if (result.length >= maxChars) { + result = result.slice(0, maxChars); + break; + } + } + } catch { + return ""; + } finally { + reader.releaseLock(); + } + + return result; +} + /** * @param {number[]} pullNumbers * @returns {Promise} From 1507b7f6edaeedc80dc32d4106a62745ba329bd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:39:22 +0000 Subject: [PATCH 08/21] chore: refine logging helper diagnostics Agent-Logs-Url: https://github.com/github/gh-aw/sessions/42605c79-7ebd-428c-9727-efe4ae592077 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_pull_request_branches.cjs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index db6f7be6116..200f5c3ad7a 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -144,18 +144,15 @@ async function readResponsePreview(response, maxChars) { const { done, value } = await reader.read(); if (done || !value) break; result += decoder.decode(value, { stream: true }); - if (result.length >= maxChars) { - result = result.slice(0, maxChars); - break; - } } - } catch { + } catch (error) { + core.debug(`Failed to read Copilot sessions error preview: ${getErrorMessage(error)}`); return ""; } finally { reader.releaseLock(); } - return result; + return result.slice(0, maxChars); } /** From 6546f14708c6f84f8dbdada1376a91d437cf4f5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:42:12 +0000 Subject: [PATCH 09/21] chore: harden error preview logging path Agent-Logs-Url: https://github.com/github/gh-aw/sessions/42605c79-7ebd-428c-9727-efe4ae592077 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_pull_request_branches.cjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index 200f5c3ad7a..b053dd23d5b 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -135,7 +135,13 @@ async function listAgentSessionsPage(copilotApiURL, pageNumber, pageSize) { */ async function readResponsePreview(response, maxChars) { if (!response.body) return ""; - const reader = response.body.getReader(); + let reader; + try { + reader = response.body.getReader(); + } catch (error) { + core.debug(`Failed to open error response preview stream (non-critical): ${getErrorMessage(error)}`); + return ""; + } const decoder = new TextDecoder(); let result = ""; @@ -146,7 +152,7 @@ async function readResponsePreview(response, maxChars) { result += decoder.decode(value, { stream: true }); } } catch (error) { - core.debug(`Failed to read Copilot sessions error preview: ${getErrorMessage(error)}`); + core.debug(`Failed to read error response preview for debugging (non-critical): ${getErrorMessage(error)}`); return ""; } finally { reader.releaseLock(); From d71669ecd603179ea06160e4bcf2c76454cdc729 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:58:27 +0000 Subject: [PATCH 10/21] fix: split update_pull_request_branches into dedicated maintenance job Agent-Logs-Url: https://github.com/github/gh-aw/sessions/71dcefc7-2a67-4995-829b-60e155f10fc2 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 42 ++++++++++++++- .../setup/js/run_operation_update_upgrade.cjs | 9 +--- .../setup/js/update_pull_request_branches.cjs | 4 +- pkg/workflow/maintenance_workflow_test.go | 17 +++++- pkg/workflow/maintenance_workflow_yaml.go | 52 ++++++++++++++++++- 5 files changed, 111 insertions(+), 13 deletions(-) diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 07e570cc7ae..4fcf9606ec7 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -158,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 @@ -214,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: + pull-requests: write + 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 + 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 diff --git a/actions/setup/js/run_operation_update_upgrade.cjs b/actions/setup/js/run_operation_update_upgrade.cjs index ea2afe6bcae..1da45d2774d 100644 --- a/actions/setup/js/run_operation_update_upgrade.cjs +++ b/actions/setup/js/run_operation_update_upgrade.cjs @@ -47,7 +47,6 @@ function formatTimestamp(date) { /** * 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. @@ -58,7 +57,7 @@ function formatTimestamp(date) { * * Required environment variables: * GH_TOKEN - GitHub token for gh CLI auth and git push - * GH_AW_OPERATION - 'update', 'upgrade', 'disable', 'enable', or 'update_pull_request_branches' + * GH_AW_OPERATION - 'update', 'upgrade', 'disable', or 'enable' * GH_AW_CMD_PREFIX - Command prefix: './gh-aw' (dev) or 'gh aw' (release) * * @returns {Promise} @@ -73,12 +72,6 @@ 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(" "); diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index b053dd23d5b..ccea1d609cc 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -124,7 +124,9 @@ async function listAgentSessionsPage(copilotApiURL, pageNumber, pageSize) { throw new Error(`Failed to list agent sessions: HTTP ${response.status}`); } - const body = /** @type {any} */ await response.json(); + const rawBody = await response.json(); + /** @type {any} */ + const body = rawBody; return Array.isArray(body?.sessions) ? body.sessions : []; } diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 193bbf62d25..b540c06e033 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -282,9 +282,10 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { yaml := string(content) operationSkipCondition := `github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == ''` - operationRunCondition := `(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'` + operationRunCondition := `(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'` applySafeOutputsCondition := `(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'safe_outputs'` createLabelsCondition := `(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'create_labels'` + updatePullRequestBranchesCondition := `(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'update_pull_request_branches'` activityReportCondition := `(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'activity_report'` closeAgenticWorkflowIssuesCondition := `(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'close_agentic_workflows_issues'` cleanCacheMemoriesCondition := `github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '' || inputs.operation == 'clean_cache_memories'` @@ -356,6 +357,20 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } + // update_pull_request_branches job should be triggered when operation == 'update_pull_request_branches' + updatePullRequestBranchesIdx := strings.Index(yaml, "\n update_pull_request_branches:") + if updatePullRequestBranchesIdx == -1 { + t.Errorf("Job update_pull_request_branches not found in generated workflow") + } else { + updatePullRequestBranchesSection := yaml[updatePullRequestBranchesIdx : updatePullRequestBranchesIdx+runOpSectionSearchRange] + if !strings.Contains(updatePullRequestBranchesSection, updatePullRequestBranchesCondition) { + t.Errorf("Job update_pull_request_branches should have the activation condition %q in:\n%s", updatePullRequestBranchesCondition, updatePullRequestBranchesSection) + } + if !strings.Contains(updatePullRequestBranchesSection, "pull-requests: write") { + t.Errorf("Job update_pull_request_branches should include pull-requests: write permission in:\n%s", updatePullRequestBranchesSection) + } + } + // validate_workflows job should be triggered when operation == 'validate' validateCondition := `(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.operation == 'validate'` validateIdx := strings.Index(yaml, "\n validate_workflows:") diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index c51b730b9f2..05319c68e42 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -197,8 +197,8 @@ jobs: `) // Add unified run_operation job for all dispatch operations except those with dedicated jobs - // (safe_outputs, create_labels, activity_report, close_agentic_workflows_issues, clean_cache_memories, validate) - runOperationCondition := buildRunOperationCondition("safe_outputs", "create_labels", "activity_report", "close_agentic_workflows_issues", "clean_cache_memories", "validate") + // (safe_outputs, create_labels, activity_report, close_agentic_workflows_issues, clean_cache_memories, update_pull_request_branches, validate) + runOperationCondition := buildRunOperationCondition("safe_outputs", "create_labels", "activity_report", "close_agentic_workflows_issues", "clean_cache_memories", "update_pull_request_branches", "validate") yaml.WriteString(` run_operation: if: ${{ ` + RenderCondition(runOperationCondition) + ` }} @@ -252,6 +252,54 @@ jobs: run: echo "operation=${{ inputs.operation }}" >> "$GITHUB_OUTPUT" `) + // Add update_pull_request_branches job for workflow_dispatch with operation == 'update_pull_request_branches' + yaml.WriteString(` + update_pull_request_branches: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("update_pull_request_branches")) + ` }} + runs-on: ` + runsOnValue + ` + permissions: + pull-requests: write + steps: +`) + + // Add checkout step only in dev/script mode (for local action paths) + if actionMode == ActionModeDev || actionMode == ActionModeScript { + yaml.WriteString(" - name: Checkout actions folder\n") + yaml.WriteString(" uses: " + getActionPin("actions/checkout") + "\n") + yaml.WriteString(" with:\n") + yaml.WriteString(" sparse-checkout: |\n") + yaml.WriteString(" actions\n") + yaml.WriteString(" persist-credentials: false\n\n") + } + + yaml.WriteString(` - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + 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: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + 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(); +`) + // Add apply_safe_outputs job for workflow_dispatch with operation == 'safe_outputs' yaml.WriteString(` apply_safe_outputs: From c74ae154471eed0395ee560b3e10593fd96134d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:11:36 +0000 Subject: [PATCH 11/21] test: ensure draft pull requests are excluded from branch updates Agent-Logs-Url: https://github.com/github/gh-aw/sessions/9f795926-947f-4df9-8e2e-4320c796cccb Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/update_pull_request_branches.test.cjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/actions/setup/js/update_pull_request_branches.test.cjs b/actions/setup/js/update_pull_request_branches.test.cjs index a462e2205fe..e2ef8e2a7f2 100644 --- a/actions/setup/js/update_pull_request_branches.test.cjs +++ b/actions/setup/js/update_pull_request_branches.test.cjs @@ -126,4 +126,17 @@ describe("update_pull_request_branches", () => { expect(result).toEqual([1, 3]); }); + + 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 } }; + if (pull_number === 2) return { data: { state: "open", mergeable: true, draft: false } }; + return { data: { state: "open", mergeable: false, draft: false } }; + }); + + const result = await moduleUnderTest.filterMergeablePullRequests("owner", "repo", [1, 2, 3]); + + expect(result).toEqual([2]); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping PR #1")); + }); }); From a40c8ae0c999f29607ea66aa6247bba7a34c9d1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:02:19 +0000 Subject: [PATCH 12/21] fix: pass GH_TOKEN to maintenance admin-check API steps Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7ebeb219-18a9-4b7b-b7c0-d88853047007 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 14 ++++++++ pkg/workflow/maintenance_workflow_test.go | 39 ++++++++++++++++++++++ pkg/workflow/maintenance_workflow_yaml.go | 14 ++++++++ 3 files changed, 67 insertions(+) diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 4fcf9606ec7..68c44254f3c 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -179,6 +179,8 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -234,6 +236,8 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -280,6 +284,8 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -324,6 +330,8 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -374,6 +382,8 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -482,6 +492,8 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -519,6 +531,8 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index b540c06e033..63eb27657f8 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -533,6 +533,45 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } +func TestGenerateMaintenanceWorkflow_AdminCheckPassesGHTokenEnv(t *testing.T) { + workflowDataList := []*WorkflowData{ + { + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + Expires: 48, + }, + }, + }, + } + + tmpDir := t.TempDir() + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + + yaml := string(content) + sections := strings.Split(yaml, "- name: Check admin/maintainer permissions") + if len(sections) <= 1 { + t.Fatalf("Expected at least one check admin/maintainer step in generated workflow") + } + + for idx, section := range sections[1:] { + stepPrefix, _, found := strings.Cut(section, "\n with:") + if !found { + t.Fatalf("Expected step %d to include a with: block", idx+1) + } + if !strings.Contains(stepPrefix, "GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}") { + t.Errorf("Expected step %d to set GH_TOKEN in env before with: block", idx+1) + } + } +} + func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { workflowDataList := []*WorkflowData{ { diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 05319c68e42..2c354d2de00 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -222,6 +222,8 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -279,6 +281,8 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -328,6 +332,8 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -375,6 +381,8 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -422,6 +430,8 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -533,6 +543,8 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -575,6 +587,8 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | From d73f1e1fb1ef6b96628eff0c53c49277b6d69088 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:58:16 +0000 Subject: [PATCH 13/21] revert: undo GH_TOKEN env propagation in maintenance admin checks Agent-Logs-Url: https://github.com/github/gh-aw/sessions/c14085cc-c356-452e-bc47-4b5d0629b950 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 14 -------- pkg/workflow/maintenance_workflow_test.go | 39 ---------------------- pkg/workflow/maintenance_workflow_yaml.go | 14 -------- 3 files changed, 67 deletions(-) diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 68c44254f3c..4fcf9606ec7 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -179,8 +179,6 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -236,8 +234,6 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -284,8 +280,6 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -330,8 +324,6 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -382,8 +374,6 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -492,8 +482,6 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -531,8 +519,6 @@ jobs: - name: Check admin/maintainer permissions uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 63eb27657f8..b540c06e033 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -533,45 +533,6 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } -func TestGenerateMaintenanceWorkflow_AdminCheckPassesGHTokenEnv(t *testing.T) { - workflowDataList := []*WorkflowData{ - { - Name: "test-workflow", - SafeOutputs: &SafeOutputsConfig{ - CreateIssues: &CreateIssuesConfig{ - Expires: 48, - }, - }, - }, - } - - tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) - if err != nil { - t.Fatalf("Expected maintenance workflow to be generated: %v", err) - } - - yaml := string(content) - sections := strings.Split(yaml, "- name: Check admin/maintainer permissions") - if len(sections) <= 1 { - t.Fatalf("Expected at least one check admin/maintainer step in generated workflow") - } - - for idx, section := range sections[1:] { - stepPrefix, _, found := strings.Cut(section, "\n with:") - if !found { - t.Fatalf("Expected step %d to include a with: block", idx+1) - } - if !strings.Contains(stepPrefix, "GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}") { - t.Errorf("Expected step %d to set GH_TOKEN in env before with: block", idx+1) - } - } -} - func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { workflowDataList := []*WorkflowData{ { diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 2c354d2de00..05319c68e42 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -222,8 +222,6 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -281,8 +279,6 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -332,8 +328,6 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -381,8 +375,6 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -430,8 +422,6 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -543,8 +533,6 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -587,8 +575,6 @@ jobs: - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | From 29113346268292260190fda6b2cbe5014e7e4e77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:46:42 +0000 Subject: [PATCH 14/21] fix: remove agent session checks from update_pull_request_branches Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b40dc9ff-340a-4ea0-be08-f1022de04dc4 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/update_pull_request_branches.cjs | 175 +----------------- .../js/update_pull_request_branches.test.cjs | 59 +----- 2 files changed, 11 insertions(+), 223 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index ccea1d609cc..54b4856941f 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -5,175 +5,9 @@ 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 SESSION_PAGE_SIZE = 100; 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>} - */ -async function listPullRequestsWithActiveSessions() { - core.info("Listing agent sessions to identify PRs with active sessions"); - const copilotApiURL = await getCopilotAPIURL(); - core.info(`Resolved Copilot API endpoint for sessions: ${copilotApiURL}`); - core.info(`Fetching up to ${SESSION_LIST_LIMIT} sessions (page_size=${SESSION_PAGE_SIZE})`); - - /** @type {Array<{resource_id?: number | string, state?: string, resource_type?: string}>} */ - const sessions = []; - for (let pageNumber = 1; sessions.length < SESSION_LIST_LIMIT; pageNumber++) { - const pageSessions = await listAgentSessionsPage(copilotApiURL, pageNumber, SESSION_PAGE_SIZE); - core.info(`Fetched ${pageSessions.length} session(s) from page ${pageNumber}`); - if (pageSessions.length === 0) break; - sessions.push(...pageSessions); - if (pageSessions.length < SESSION_PAGE_SIZE) break; - } - if (sessions.length >= SESSION_LIST_LIMIT) { - core.warning(`Session list reached limit (${SESSION_LIST_LIMIT}); newer sessions may have been truncated`); - } - core.info(`Fetched ${sessions.length} total session record(s) for filtering`); - - const prNumbers = new Set(); - for (const session of sessions) { - if (session?.resource_type !== "pull") continue; - if (!isActiveSessionState(session?.state)) continue; - const prNumber = parsePullRequestNumber(session?.resource_id); - if (prNumber !== null) prNumbers.add(prNumber); - } - - core.info(`Found ${prNumbers.size} pull request(s) with active agent sessions`); - return prNumbers; -} - -/** - * @returns {Promise} - */ -async function getCopilotAPIURL() { - core.info("Resolving Copilot API endpoint from GraphQL viewer.copilotEndpoints.api"); - const response = await github.graphql(` - query CopilotEndpointsForSessionListing { - viewer { - copilotEndpoints { - api - } - } - } - `); - const apiURL = response?.viewer?.copilotEndpoints?.api; - if (typeof apiURL !== "string" || !apiURL.trim()) { - throw new Error("Unable to resolve Copilot API URL for session listing"); - } - const normalizedAPIURL = apiURL.replace(/\/+$/, ""); - core.info(`Copilot API endpoint resolved: ${normalizedAPIURL}`); - return normalizedAPIURL; -} - -/** - * @param {string} copilotApiURL - * @param {number} pageNumber - * @param {number} pageSize - * @returns {Promise>} - */ -async function listAgentSessionsPage(copilotApiURL, pageNumber, pageSize) { - const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; - if (!token) throw new Error("Missing GH_TOKEN/GITHUB_TOKEN for Copilot session listing"); - - const sessionsURL = new URL(`${copilotApiURL}/agents/sessions`); - sessionsURL.searchParams.set("page_size", String(pageSize)); - sessionsURL.searchParams.set("page_number", String(pageNumber)); - sessionsURL.searchParams.set("sort", "last_updated_at,desc"); - core.debug(`Requesting Copilot sessions page ${pageNumber}: ${sessionsURL.origin}${sessionsURL.pathname} (page_size=${pageSize})`); - - const response = await fetch(sessionsURL.toString(), { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - "User-Agent": "gh-aw-update-pull-request-branches", - }, - }); - - if (!response.ok) { - const truncatedBody = await readResponsePreview(response, 500); - core.error(`Failed to list agent sessions page ${pageNumber}: HTTP ${response.status} ${response.statusText}`); - if (truncatedBody) { - core.error(`Copilot sessions error response (truncated): ${truncatedBody}`); - } - throw new Error(`Failed to list agent sessions: HTTP ${response.status}`); - } - - const rawBody = await response.json(); - /** @type {any} */ - const body = rawBody; - return Array.isArray(body?.sessions) ? body.sessions : []; -} - -/** - * @param {Response} response - * @param {number} maxChars - * @returns {Promise} - */ -async function readResponsePreview(response, maxChars) { - if (!response.body) return ""; - let reader; - try { - reader = response.body.getReader(); - } catch (error) { - core.debug(`Failed to open error response preview stream (non-critical): ${getErrorMessage(error)}`); - return ""; - } - const decoder = new TextDecoder(); - let result = ""; - - try { - while (result.length < maxChars) { - const { done, value } = await reader.read(); - if (done || !value) break; - result += decoder.decode(value, { stream: true }); - } - } catch (error) { - core.debug(`Failed to read error response preview for debugging (non-critical): ${getErrorMessage(error)}`); - return ""; - } finally { - reader.releaseLock(); - } - - return result.slice(0, maxChars); -} - -/** - * @param {number[]} pullNumbers - * @returns {Promise} - */ -async function filterPullRequestsWithoutActiveSessions(pullNumbers) { - const pullRequestsWithSessions = await listPullRequestsWithActiveSessions(); - const eligiblePullRequests = pullNumbers.filter(number => !pullRequestsWithSessions.has(number)); - core.info(`Found ${eligiblePullRequests.length} eligible pull request(s) without active sessions`); - return eligiblePullRequests; -} - /** * @param {string} owner * @param {string} repo @@ -267,7 +101,7 @@ async function updatePullRequestBranch(owner, repo, pullNumber) { } /** - * Update all mergeable PR branches that do not have active agent sessions. + * Update all mergeable PR branches. * @returns {Promise} */ async function main() { @@ -285,7 +119,8 @@ async function main() { core.info(`Found ${mergeablePullRequests.length} mergeable pull request(s)`); if (mergeablePullRequests.length === 0) return; - const eligiblePullRequests = await filterPullRequestsWithoutActiveSessions(mergeablePullRequests); + const eligiblePullRequests = mergeablePullRequests; + core.info(`Found ${eligiblePullRequests.length} eligible pull request(s)`); if (eligiblePullRequests.length === 0) return; let updatedCount = 0; @@ -319,10 +154,6 @@ async function main() { module.exports = { main, - parsePullRequestNumber, - isActiveSessionState, - listPullRequestsWithActiveSessions, - filterPullRequestsWithoutActiveSessions, filterMergeablePullRequests, isNonFatalUpdateBranchError, }; diff --git a/actions/setup/js/update_pull_request_branches.test.cjs b/actions/setup/js/update_pull_request_branches.test.cjs index e2ef8e2a7f2..57f80e08d6f 100644 --- a/actions/setup/js/update_pull_request_branches.test.cjs +++ b/actions/setup/js/update_pull_request_branches.test.cjs @@ -14,8 +14,6 @@ describe("update_pull_request_branches", () => { let mockGithub; /** @type {any} */ let mockContext; - /** @type {any} */ - let fetchMock; beforeEach(() => { vi.clearAllMocks(); @@ -48,48 +46,35 @@ describe("update_pull_request_branches", () => { global.core = mockCore; global.github = mockGithub; global.context = mockContext; - fetchMock = vi.fn(); - global.fetch = fetchMock; - process.env.GH_TOKEN = "test-token"; }); - it("updates only mergeable pull requests without active sessions", async () => { + 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 } }; if (pull_number === 2) return { data: { state: "open", mergeable: false, draft: false } }; return { data: { state: "open", mergeable: true, draft: false } }; }); - mockGithub.graphql.mockResolvedValue({ viewer: { copilotEndpoints: { api: "https://api.copilot.test" } } }); - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - sessions: [ - { resource_id: 3, state: "open", resource_type: "pull" }, - { resource_id: 10, state: "closed", resource_type: "pull" }, - ], - }), - }); mockGithub.rest.pulls.updateBranch.mockResolvedValue({ data: {} }); await moduleUnderTest.main(); - expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledTimes(1); - expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledWith({ + expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledTimes(2); + expect(mockGithub.rest.pulls.updateBranch).toHaveBeenNthCalledWith(1, { 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 } }); - mockGithub.graphql.mockResolvedValue({ viewer: { copilotEndpoints: { api: "https://api.copilot.test" } } }); - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ sessions: [] }), - }); const err = new Error("Update branch failed"); // @ts-ignore err.status = 422; @@ -99,34 +84,6 @@ describe("update_pull_request_branches", () => { 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); - }); - - it("filters candidate pull requests to only those without active sessions", async () => { - mockGithub.graphql.mockResolvedValue({ viewer: { copilotEndpoints: { api: "https://api.copilot.test" } } }); - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - sessions: [ - { resource_id: 2, state: "OPEN", resource_type: "pull" }, - { resource_id: 9, state: "queued", resource_type: "pull" }, - ], - }), - }); - - const result = await moduleUnderTest.filterPullRequestsWithoutActiveSessions([1, 2, 3]); - - expect(result).toEqual([1, 3]); - }); - 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 } }; From aca2295b1b8cc40ba24b25ed392561ac3e753370 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:48:58 +0000 Subject: [PATCH 15/21] refactor: remove redundant eligible pull requests variable Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b40dc9ff-340a-4ea0-be08-f1022de04dc4 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_pull_request_branches.cjs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index 54b4856941f..00300fe60d6 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -119,16 +119,12 @@ async function main() { core.info(`Found ${mergeablePullRequests.length} mergeable pull request(s)`); if (mergeablePullRequests.length === 0) return; - const eligiblePullRequests = mergeablePullRequests; - core.info(`Found ${eligiblePullRequests.length} eligible pull request(s)`); - 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]; + 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); @@ -143,7 +139,7 @@ async function main() { } } - if (i < eligiblePullRequests.length - 1) { + if (i < mergeablePullRequests.length - 1) { await sleep(UPDATE_DELAY_MS); } } From 78679e262f7feb328e4518e1176cc3d9a59db12e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:10:00 +0000 Subject: [PATCH 16/21] fix: skip fork PRs in update_pull_request_branches permission checks Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ca209793-8352-47be-83f9-433ebaeb1aef Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/update_pull_request_branches.cjs | 7 +++-- .../js/update_pull_request_branches.test.cjs | 26 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index 00300fe60d6..c107fb35b52 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -32,6 +32,7 @@ async function listOpenPullRequests(owner, repo) { */ async function filterMergeablePullRequests(owner, repo, pullNumbers) { const mergeable = []; + const baseRepository = `${owner}/${repo}`.toLowerCase(); for (const pullNumber of pullNumbers) { const { data: pull } = await withRetry( @@ -51,13 +52,15 @@ async function filterMergeablePullRequests(owner, repo, pullNumbers) { `fetch pull request #${pullNumber}` ); - const isMergeable = pull?.state === "open" && pull?.mergeable === true && pull?.draft !== true; + const headRepository = (pull?.head?.repo?.full_name || "").toLowerCase(); + const isSameRepository = headRepository === baseRepository; + const isMergeable = pull?.state === "open" && pull?.mergeable === true && pull?.draft !== true && isSameRepository; if (isMergeable) { mergeable.push(pullNumber); continue; } - core.info(`Skipping PR #${pullNumber}: mergeable=${String(pull?.mergeable)}, state=${pull?.state || "unknown"}, draft=${String(Boolean(pull?.draft))}`); + core.info(`Skipping PR #${pullNumber}: mergeable=${String(pull?.mergeable)}, state=${pull?.state || "unknown"}, draft=${String(Boolean(pull?.draft))}, head_repo=${headRepository || "unknown"}`); } return mergeable; diff --git a/actions/setup/js/update_pull_request_branches.test.cjs b/actions/setup/js/update_pull_request_branches.test.cjs index 57f80e08d6f..777f2fd4e3a 100644 --- a/actions/setup/js/update_pull_request_branches.test.cjs +++ b/actions/setup/js/update_pull_request_branches.test.cjs @@ -51,9 +51,9 @@ describe("update_pull_request_branches", () => { 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 } }; - if (pull_number === 2) return { data: { state: "open", mergeable: false, draft: false } }; - return { data: { state: "open", mergeable: true, draft: false } }; + 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: {} }); @@ -74,7 +74,7 @@ describe("update_pull_request_branches", () => { 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 } }); + 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; @@ -86,9 +86,9 @@ describe("update_pull_request_branches", () => { 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 } }; - if (pull_number === 2) return { data: { state: "open", mergeable: true, draft: false } }; - return { data: { state: "open", mergeable: false, draft: false } }; + 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]); @@ -96,4 +96,16 @@ describe("update_pull_request_branches", () => { 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("head_repo=fork-owner/repo")); + }); }); From 3c51429087b425c7ea662a278ea1d139bc0e80d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:12:36 +0000 Subject: [PATCH 17/21] fix: log explicit skip reasons for non-updatable PR branches Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ca209793-8352-47be-83f9-433ebaeb1aef Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_pull_request_branches.cjs | 7 +++++-- .../setup/js/update_pull_request_branches.test.cjs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index c107fb35b52..3708d4e94ac 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -52,7 +52,9 @@ async function filterMergeablePullRequests(owner, repo, pullNumbers) { `fetch pull request #${pullNumber}` ); - const headRepository = (pull?.head?.repo?.full_name || "").toLowerCase(); + const headRepositoryRaw = pull?.head?.repo?.full_name; + const headRepository = (headRepositoryRaw || "").toLowerCase(); + const headRepositoryState = typeof headRepositoryRaw === "string" && headRepositoryRaw.trim() ? "present" : "missing"; const isSameRepository = headRepository === baseRepository; const isMergeable = pull?.state === "open" && pull?.mergeable === true && pull?.draft !== true && isSameRepository; if (isMergeable) { @@ -60,7 +62,8 @@ async function filterMergeablePullRequests(owner, repo, pullNumbers) { continue; } - core.info(`Skipping PR #${pullNumber}: mergeable=${String(pull?.mergeable)}, state=${pull?.state || "unknown"}, draft=${String(Boolean(pull?.draft))}, head_repo=${headRepository || "unknown"}`); + const skipReason = !isSameRepository ? (headRepositoryState === "missing" ? "head_repository_missing" : "head_repository_mismatch") : "not_mergeable"; + core.info(`Skipping PR #${pullNumber}: reason=${skipReason}, mergeable=${String(pull?.mergeable)}, state=${pull?.state || "unknown"}, draft=${String(Boolean(pull?.draft))}, head_repo=${headRepository || "unknown"}`); } return mergeable; diff --git a/actions/setup/js/update_pull_request_branches.test.cjs b/actions/setup/js/update_pull_request_branches.test.cjs index 777f2fd4e3a..c40512c9f23 100644 --- a/actions/setup/js/update_pull_request_branches.test.cjs +++ b/actions/setup/js/update_pull_request_branches.test.cjs @@ -106,6 +106,19 @@ describe("update_pull_request_branches", () => { 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")); + }); }); From bec130a659044c01ffebac047cb9936083cea884 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:14:57 +0000 Subject: [PATCH 18/21] refactor: simplify head repository skip-reason logic Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ca209793-8352-47be-83f9-433ebaeb1aef Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_pull_request_branches.cjs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index 3708d4e94ac..c8ec23866b5 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -53,8 +53,7 @@ async function filterMergeablePullRequests(owner, repo, pullNumbers) { ); const headRepositoryRaw = pull?.head?.repo?.full_name; - const headRepository = (headRepositoryRaw || "").toLowerCase(); - const headRepositoryState = typeof headRepositoryRaw === "string" && headRepositoryRaw.trim() ? "present" : "missing"; + const headRepository = headRepositoryRaw?.toLowerCase() || ""; const isSameRepository = headRepository === baseRepository; const isMergeable = pull?.state === "open" && pull?.mergeable === true && pull?.draft !== true && isSameRepository; if (isMergeable) { @@ -62,7 +61,7 @@ async function filterMergeablePullRequests(owner, repo, pullNumbers) { continue; } - const skipReason = !isSameRepository ? (headRepositoryState === "missing" ? "head_repository_missing" : "head_repository_mismatch") : "not_mergeable"; + const skipReason = !isSameRepository ? (headRepository ? "head_repository_mismatch" : "head_repository_missing") : "not_mergeable"; core.info(`Skipping PR #${pullNumber}: reason=${skipReason}, mergeable=${String(pull?.mergeable)}, state=${pull?.state || "unknown"}, draft=${String(Boolean(pull?.draft))}, head_repo=${headRepository || "unknown"}`); } From f592da1e632475805fd18a3a008d1db9081f571a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:17:21 +0000 Subject: [PATCH 19/21] refactor: clarify skip reason derivation for PR update filtering Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ca209793-8352-47be-83f9-433ebaeb1aef Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_pull_request_branches.cjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index c8ec23866b5..bffeae144c2 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -53,7 +53,7 @@ async function filterMergeablePullRequests(owner, repo, pullNumbers) { ); const headRepositoryRaw = pull?.head?.repo?.full_name; - const headRepository = headRepositoryRaw?.toLowerCase() || ""; + const headRepository = headRepositoryRaw?.toLowerCase() ?? ""; const isSameRepository = headRepository === baseRepository; const isMergeable = pull?.state === "open" && pull?.mergeable === true && pull?.draft !== true && isSameRepository; if (isMergeable) { @@ -61,7 +61,10 @@ async function filterMergeablePullRequests(owner, repo, pullNumbers) { continue; } - const skipReason = !isSameRepository ? (headRepository ? "head_repository_mismatch" : "head_repository_missing") : "not_mergeable"; + 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"}`); } From 9819257cc77dd684850718cd0bbada0a7d46e2ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:46:17 +0000 Subject: [PATCH 20/21] fix: grant contents write to update_pull_request_branches job Agent-Logs-Url: https://github.com/github/gh-aw/sessions/23aae901-ccbd-4108-b136-b6d46c02b308 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 1 + pkg/workflow/maintenance_workflow_test.go | 3 +++ pkg/workflow/maintenance_workflow_yaml.go | 1 + 3 files changed, 5 insertions(+) diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 4fcf9606ec7..b3238e040fc 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -218,6 +218,7 @@ jobs: 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 steps: - name: Checkout actions folder diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index b540c06e033..51a3991a18e 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -369,6 +369,9 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { if !strings.Contains(updatePullRequestBranchesSection, "pull-requests: write") { t.Errorf("Job update_pull_request_branches should include pull-requests: write permission in:\n%s", updatePullRequestBranchesSection) } + if !strings.Contains(updatePullRequestBranchesSection, "contents: write") { + t.Errorf("Job update_pull_request_branches should include contents: write permission in:\n%s", updatePullRequestBranchesSection) + } } // validate_workflows job should be triggered when operation == 'validate' diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 05319c68e42..bda1644251e 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -258,6 +258,7 @@ jobs: if: ${{ ` + RenderCondition(buildDispatchOperationCondition("update_pull_request_branches")) + ` }} runs-on: ` + runsOnValue + ` permissions: + contents: write pull-requests: write steps: `) From 3f789ebb3557f2f08a523199acda90c728794f44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:11:57 +0000 Subject: [PATCH 21/21] feat: comment on PR after branch update with run backlink Agent-Logs-Url: https://github.com/github/gh-aw/sessions/25032ac7-55f4-4109-9478-7b87691798d5 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/update_pull_request_branches.cjs | 21 +++++++++++++++++++ .../js/update_pull_request_branches.test.cjs | 19 +++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/actions/setup/js/update_pull_request_branches.cjs b/actions/setup/js/update_pull_request_branches.cjs index bffeae144c2..1a66c0f19ca 100644 --- a/actions/setup/js/update_pull_request_branches.cjs +++ b/actions/setup/js/update_pull_request_branches.cjs @@ -4,6 +4,7 @@ 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; @@ -108,6 +109,23 @@ async function updatePullRequestBranch(owner, repo, pullNumber) { ); } +/** + * @param {string} owner + * @param {string} repo + * @param {number} pullNumber + * @param {string} runUrl + * @returns {Promise} + */ +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} @@ -115,8 +133,10 @@ async function updatePullRequestBranch(owner, repo, pullNumber) { 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); @@ -136,6 +156,7 @@ async function main() { 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)) { diff --git a/actions/setup/js/update_pull_request_branches.test.cjs b/actions/setup/js/update_pull_request_branches.test.cjs index c40512c9f23..23a314820bf 100644 --- a/actions/setup/js/update_pull_request_branches.test.cjs +++ b/actions/setup/js/update_pull_request_branches.test.cjs @@ -29,6 +29,9 @@ describe("update_pull_request_branches", () => { paginate: vi.fn(), graphql: vi.fn(), rest: { + issues: { + createComment: vi.fn(), + }, pulls: { list: vi.fn(), get: vi.fn(), @@ -37,6 +40,8 @@ describe("update_pull_request_branches", () => { }, }; mockContext = { + runId: 123, + serverUrl: "https://github.com", repo: { owner: "owner", repo: "repo", @@ -70,6 +75,19 @@ describe("update_pull_request_branches", () => { repo: "repo", pull_number: 3, }); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledTimes(2); + expect(mockGithub.rest.issues.createComment).toHaveBeenNthCalledWith(1, { + owner: "owner", + repo: "repo", + issue_number: 1, + body: expect.stringContaining("[View workflow run](https://github.com/owner/repo/actions/runs/123)"), + }); + expect(mockGithub.rest.issues.createComment).toHaveBeenNthCalledWith(2, { + owner: "owner", + repo: "repo", + issue_number: 3, + body: expect.stringContaining("[View workflow run](https://github.com/owner/repo/actions/runs/123)"), + }); }); it("continues on non-fatal updateBranch failures", async () => { @@ -82,6 +100,7 @@ describe("update_pull_request_branches", () => { await expect(moduleUnderTest.main()).resolves.not.toThrow(); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Skipping PR #7")); + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); }); it("ignores draft pull requests when filtering mergeable pull requests", async () => {