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 () => {