Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v6

- name: Download coverage artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: coverage
path: coverage
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
- run: bun test --coverage
- name: Upload coverage artifact
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: coverage
path: coverage/lcov.info
Expand Down
11 changes: 11 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ inputs:
Newline- or comma-separated list of coverage artifact paths.
Each entry is `<tool>:<path>`, e.g. `bun:coverage/lcov.info` or `go:coverage.out`.
required: true
pull-request-number:
description: >
PR number to comment on. Auto-detected from event context
when omitted. Use this when running under workflow_run
with fork PRs where auto-detection may fail.
required: false
default: ""
show-commit-link:
description: "Include a commit link at the top of the comment (on/off)."
required: false
default: "on"
base-branch:
description: "Target branch for comparison (e.g. main). Defaults to the PR base ref."
required: false
Expand Down
123 changes: 123 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import * as github from "@actions/github";

type Context = typeof github.context;

/**
* Resolve the PR number from the event context using a priority chain:
*
* 1. Explicit `pull-request-number` input (manual override)
* 2. `pull_request` / `pull_request_target` event payload
* 3. `workflow_run` event — first PR in `pull_requests` array
* 4. API fallback — search open PRs by head SHA
*
* Returns `undefined` when no PR can be found.
*/
export async function resolvePrNumber(
inputOverride: string,
token: string,
context: Context = github.context,
): Promise<number | undefined> {
// 1. Manual override
if (inputOverride) {
const n = parseInt(inputOverride, 10);
if (Number.isFinite(n) && n > 0) return n;
}

// 2. Direct PR trigger
if (context.eventName === "pull_request" || context.eventName === "pull_request_target") {
const num = context.payload.pull_request?.number;
if (typeof num === "number") return num;
}

// 3. workflow_run trigger — PR was the original event
if (context.eventName === "workflow_run") {
const prs: unknown[] | undefined = context.payload.workflow_run?.pull_requests;
if (Array.isArray(prs) && prs.length > 0) {
const first = prs[0] as { number?: number; };
if (typeof first.number === "number") return first.number;
}

// 4. API fallback — find PR by head branch
if (token) {
const headBranch: string | undefined = context.payload.workflow_run?.head_branch;
const headRepoOwner: string | undefined = context.payload.workflow_run?.head_repository
?.owner?.login;
if (headBranch) {
try {
const octokit = github.getOctokit(token);
const { owner, repo } = context.repo;
const head = headRepoOwner && headRepoOwner !== owner
? `${headRepoOwner}:${headBranch}`
: `${owner}:${headBranch}`;
const { data: prs } = await octokit.rest.pulls.list({
owner,
repo,
head,
state: "open",
});
if (prs.length > 0) return prs[0].number;
} catch {
// Swallowed — caller will get undefined
}
}
}
}

return undefined;
}

/**
* Resolve the head commit SHA from the event context.
*
* Under `workflow_run` the SHA of the triggering run is more accurate than
* `context.sha` (which points at the merge commit on the default branch).
*/
export function resolveHeadSha(context: Context = github.context): string {
if (context.eventName === "workflow_run") {
return context.payload.workflow_run?.head_sha ?? context.sha;
}
return context.sha;
}

/**
* Resolve the base branch for cache key scoping.
*
* Under `workflow_run`, `context.ref` points to the *default* branch, not the
* PR base. Use `head_branch` from the triggering run instead. Falls back to the
* explicit `base-branch` input or `main`.
*/
export function resolveBaseBranch(
inputBaseBranch: string,
context: Context = github.context,
): string {
if (inputBaseBranch) return inputBaseBranch;

if (context.eventName === "pull_request" || context.eventName === "pull_request_target") {
return context.payload.pull_request?.base?.ref ?? "main";
}

if (context.eventName === "workflow_run") {
return context.payload.workflow_run?.head_branch ?? "main";
}

return context.ref.replace("refs/heads/", "") || "main";
}

/**
* Resolve the current (head) branch for cache saving.
*
* Under `workflow_run`, the head branch comes from the triggering workflow.
*/
export function resolveCurrentBranch(context: Context = github.context): string {
if (context.eventName === "pull_request" || context.eventName === "pull_request_target") {
return context.payload.pull_request?.head?.ref
?? context.ref.replace("refs/heads/", "");
}

if (context.eventName === "workflow_run") {
return context.payload.workflow_run?.head_branch
?? context.ref.replace("refs/heads/", "");
}

return context.ref.replace("refs/heads/", "");
}
207 changes: 207 additions & 0 deletions src/context_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import {
describe,
expect,
test,
} from "bun:test";

import {
resolveBaseBranch,
resolveCurrentBranch,
resolveHeadSha,
resolvePrNumber,
} from "./context";

function makeContext(overrides: {
eventName?: string;
sha?: string;
ref?: string;
payload?: Record<string, unknown>;
repo?: { owner: string; repo: string; };
}) {
return {
eventName: overrides.eventName ?? "push",
sha: overrides.sha ?? "aaa111",
ref: overrides.ref ?? "refs/heads/main",
payload: overrides.payload ?? {},
repo: overrides.repo ?? { owner: "test-owner", repo: "test-repo" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}

describe("resolvePrNumber", () => {
test("returns input override when provided", async () => {
const ctx = makeContext({ eventName: "push" });
expect(await resolvePrNumber("42", "", ctx)).toBe(42);
});

test("ignores invalid input override", async () => {
const ctx = makeContext({ eventName: "push" });
expect(await resolvePrNumber("abc", "", ctx)).toBeUndefined();
expect(await resolvePrNumber("0", "", ctx)).toBeUndefined();
expect(await resolvePrNumber("-5", "", ctx)).toBeUndefined();
});

test("resolves from pull_request event", async () => {
const ctx = makeContext({
eventName: "pull_request",
payload: { pull_request: { number: 99 } },
});
expect(await resolvePrNumber("", "", ctx)).toBe(99);
});

test("resolves from pull_request_target event", async () => {
const ctx = makeContext({
eventName: "pull_request_target",
payload: { pull_request: { number: 77 } },
});
expect(await resolvePrNumber("", "", ctx)).toBe(77);
});

test("resolves from workflow_run.pull_requests", async () => {
const ctx = makeContext({
eventName: "workflow_run",
payload: {
workflow_run: {
pull_requests: [{ number: 55 }],
head_branch: "feature-x",
},
},
});
expect(await resolvePrNumber("", "", ctx)).toBe(55);
});

test("falls back to API when workflow_run.pull_requests is empty", async () => {
// Mock the GitHub API call by providing a token that will trigger the
// API fallback. Since we can't actually call the API here, we test that
// undefined is returned when the API is unreachable.
const ctx = makeContext({
eventName: "workflow_run",
payload: {
workflow_run: {
pull_requests: [],
head_branch: "feature-x",
head_repository: { owner: { login: "test-owner" } },
},
},
});
// No token → skips API fallback → undefined
expect(await resolvePrNumber("", "", ctx)).toBeUndefined();
});

test("returns undefined when all fallbacks fail", async () => {
const ctx = makeContext({ eventName: "workflow_run", payload: { workflow_run: {} } });
expect(await resolvePrNumber("", "", ctx)).toBeUndefined();
});

test("returns undefined for push event without override", async () => {
const ctx = makeContext({ eventName: "push" });
expect(await resolvePrNumber("", "", ctx)).toBeUndefined();
});

test("input override takes precedence over event payload", async () => {
const ctx = makeContext({
eventName: "pull_request",
payload: { pull_request: { number: 99 } },
});
expect(await resolvePrNumber("5", "", ctx)).toBe(5);
});
});

describe("resolveHeadSha", () => {
test("returns context.sha for pull_request event", () => {
const ctx = makeContext({ eventName: "pull_request", sha: "pr-sha-123" });
expect(resolveHeadSha(ctx)).toBe("pr-sha-123");
});

test("returns workflow_run.head_sha for workflow_run event", () => {
const ctx = makeContext({
eventName: "workflow_run",
sha: "default-sha",
payload: { workflow_run: { head_sha: "wr-sha-456" } },
});
expect(resolveHeadSha(ctx)).toBe("wr-sha-456");
});

test("falls back to context.sha when workflow_run.head_sha missing", () => {
const ctx = makeContext({
eventName: "workflow_run",
sha: "fallback-sha",
payload: { workflow_run: {} },
});
expect(resolveHeadSha(ctx)).toBe("fallback-sha");
});
});

describe("resolveCurrentBranch", () => {
test("returns pull_request.head.ref for PR event", () => {
const ctx = makeContext({
eventName: "pull_request",
ref: "refs/heads/main",
payload: { pull_request: { head: { ref: "feature-branch" } } },
});
expect(resolveCurrentBranch(ctx)).toBe("feature-branch");
});

test("returns workflow_run.head_branch for workflow_run event", () => {
const ctx = makeContext({
eventName: "workflow_run",
ref: "refs/heads/main",
payload: { workflow_run: { head_branch: "wr-branch" } },
});
expect(resolveCurrentBranch(ctx)).toBe("wr-branch");
});

test("strips refs/heads/ prefix for push event", () => {
const ctx = makeContext({ eventName: "push", ref: "refs/heads/develop" });
expect(resolveCurrentBranch(ctx)).toBe("develop");
});
});

describe("resolveBaseBranch", () => {
test("returns input override when provided", () => {
const ctx = makeContext({ eventName: "pull_request" });
expect(resolveBaseBranch("staging", ctx)).toBe("staging");
});

test("returns pull_request.base.ref for PR event", () => {
const ctx = makeContext({
eventName: "pull_request",
payload: { pull_request: { base: { ref: "develop" } } },
});
expect(resolveBaseBranch("", ctx)).toBe("develop");
});

test("returns workflow_run.head_branch for workflow_run event", () => {
const ctx = makeContext({
eventName: "workflow_run",
payload: { workflow_run: { head_branch: "wr-base" } },
});
expect(resolveBaseBranch("", ctx)).toBe("wr-base");
});

test("returns main as default when no context available", () => {
const ctx = makeContext({ eventName: "push", ref: "" });
expect(resolveBaseBranch("", ctx)).toBe("main");
});

test("returns main when workflow_run has no head_branch", () => {
const ctx = makeContext({
eventName: "workflow_run",
payload: { workflow_run: {} },
});
expect(resolveBaseBranch("", ctx)).toBe("main");
});

test("input override takes precedence over PR payload", () => {
const ctx = makeContext({
eventName: "pull_request",
payload: { pull_request: { base: { ref: "develop" } } },
});
expect(resolveBaseBranch("release", ctx)).toBe("release");
});

test("strips refs/heads/ prefix for push event", () => {
const ctx = makeContext({ eventName: "push", ref: "refs/heads/develop" });
expect(resolveBaseBranch("", ctx)).toBe("develop");
});
});
File renamed without changes.
File renamed without changes.
Loading
Loading