From f78eb12227f1a2ef5acab934f3ebb94776a0ad6c Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Mon, 12 Oct 2026 20:44:34 +0000 Subject: [PATCH] Discover Previews from Open Pull Request Heads GitHub can return no commit associations for an open fork pull request, even when its current head matches successful CI and the preview artifact is available. Discover candidates by listing current open pull request heads instead so fork and draft previews can be found without those associations. Keep discovery based on GitHub API metadata, require exactly one matching repository and head SHA, and recheck the selected pull request before using its artifact. Preserve pagination and the existing rejection of stale, closed, ambiguous, or unrelated builds. --- .github/scripts/preview-github.mts | 14 +++++----- .github/scripts/preview-github.test.mts | 34 ++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/.github/scripts/preview-github.mts b/.github/scripts/preview-github.mts index 2e832065..4f3703df 100644 --- a/.github/scripts/preview-github.mts +++ b/.github/scripts/preview-github.mts @@ -41,8 +41,9 @@ export function isCurrentPullRequest( } // Determine identity exclusively through GitHub's API, never through files -// uploaded by PR code. workflow_run.pull_requests can be empty for fork PRs, -// so resolve associated PRs through the commit API in that case as well. +// uploaded by PR code. Both workflow_run.pull_requests and the commit's PR +// associations can be empty for a fork, including a draft PR. Query current +// open PR heads directly and require an exact repository and SHA match. export async function findPreview( github: GitHubClient, run: SourceRun, @@ -63,10 +64,11 @@ export async function findPreview( if (workflow.path !== ".github/workflows/ci.yml") { return null; } - const prs = await github.paginate( - github.rest.repos.listPullRequestsAssociatedWithCommit, - { ...repo, commit_sha: run.head_sha, per_page: 100 }, - ); + const prs = await github.paginate(github.rest.pulls.list, { + ...repo, + state: "open", + per_page: 100, + }); const candidates = prs.filter((pr) => isCurrentPullRequest(pr, run.head_sha, repository), ); diff --git a/.github/scripts/preview-github.test.mts b/.github/scripts/preview-github.test.mts index 3d0b3824..62fdf324 100644 --- a/.github/scripts/preview-github.test.mts +++ b/.github/scripts/preview-github.test.mts @@ -27,6 +27,7 @@ const run: SourceRun = { const pr = { number: 42, state: "open", + draft: true, head: { sha }, base: { repo: { full_name: "LiveSplit/LiveSplitOne" } }, }; @@ -60,8 +61,12 @@ function githubFixture( let result: unknown; if (url.pathname.endsWith("/actions/workflows/1")) { result = { path: options.workflow ?? ".github/workflows/ci.yml" }; - } else if (url.pathname.endsWith(`/commits/${sha}/pulls`)) { + } else if (url.pathname === `/repos/${repo.owner}/${repo.repo}/pulls`) { result = options.candidates ?? [pr]; + } else if (url.pathname.endsWith(`/commits/${sha}/pulls`)) { + // GitHub returned no associations for the first live fork PR, + // despite its current head and uploaded artifact matching CI. + result = []; } else if (url.pathname.endsWith("/pulls/42")) { result = options.current ?? pr; } else if (url.pathname.endsWith("/actions/runs/9/artifacts")) { @@ -106,14 +111,37 @@ function githubFixture( return { github: new Octokit({ request: { fetch: fetcher } }), calls }; } -test("discovers fork PRs through signed commit metadata without artifact-supplied identity", async () => { +test("discovers a draft fork PR even when its commit has no PR associations", async () => { const { github, calls } = githubFixture(); assert.deepEqual(await findPreview(github, run, repo), { number: 42, sha, artifact: 7, }); - assert(calls.some((call) => call.path.endsWith(`/commits/${sha}/pulls`))); + assert( + calls.some( + (call) => call.path === `/repos/${repo.owner}/${repo.repo}/pulls`, + ), + ); +}); + +test("ignores open PRs with a different head or base repository", async () => { + const { github } = githubFixture({ + candidates: [ + { ...pr, number: 43, head: { sha: "b".repeat(40) } }, + { + ...pr, + number: 44, + base: { repo: { full_name: "other/repo" } }, + }, + pr, + ], + }); + assert.deepEqual(await findPreview(github, run, repo), { + number: 42, + sha, + artifact: 7, + }); }); test("ignores runs from other repositories, failed CI, and non-PR events", async () => {