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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ The mCode Action accepts the following inputs:
| | `duration` | number | Duration of the run in seconds. Takes precedence over any `--duration` passed via `args`. | 60 |
| | `args` | string | Additional CLI override [arguments](https://app.mayhem.security/docs/code-testing/reference/mayhem-cli-commands/#run) such as specifying the `--testsuite` directory path for a seed test suite. |
| | `revision` | string | Commit recorded on the run. Set it when the job builds a commit other than the one it checked out. | Pull request head on `pull_request` events, else the commit checked out under `package`, else `GITHUB_SHA` |
| | `parent-revision` | string | Parent commit recorded on the run. | The first parent of `revision`, read from its commit object; none when that commit is not checked out (e.g. a pull request head) |
| | `build-id` | string | Build id recorded on the run. | The newest commit `revision` shares with the upstream repository this one was forked from; none when the repository is not a fork |
| | `sarif-output` | string | Path for generating a SARIF report output file. |
| | `junit-output` | string | Path for generating a jUnit report output file. |
| | `coverage-output` | string | Path for generating a coverage report output files. |
Expand Down
174 changes: 173 additions & 1 deletion __tests__/revision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { execFileSync } from "child_process";
import { mkdtempSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { checkedOutRevision, resolveRevision } from "../src/revision";
import {
checkedOutRevision,
firstParent,
resolveBuildId,
resolveParentRevision,
resolveRevision,
upstreamMergeBase,
} from "../src/revision";

const SHA_A = "a".repeat(40);
const SHA_B = "b".repeat(40);
Expand Down Expand Up @@ -71,3 +78,168 @@ describe("checkedOutRevision", () => {
);
});
});

describe("resolveParentRevision", () => {
test("an explicit parent-revision input wins over the commit object", () => {
expect(resolveParentRevision("deadbeef", SHA_A)).toEqual({
parentRevision: "deadbeef",
source: "parent-revision input",
});
});

test("the recorded revision's first parent otherwise", () => {
expect(resolveParentRevision("", SHA_A)).toEqual({
parentRevision: SHA_A,
source: "commit object",
});
});

test("none when neither is known", () => {
expect(resolveParentRevision("", undefined)).toEqual({
parentRevision: "",
source: "none",
});
});
});

describe("firstParent", () => {
/** Runs git in `cwd` with a fixed identity, returning trimmed stdout. */
const git = (cwd: string, ...args: string[]): string =>
execFileSync(
"git",
[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"init.defaultBranch=main",
...args,
],
{ cwd, stdio: ["ignore", "pipe", "pipe"] },
)
.toString()
.trim();

/** A fresh repository with an empty commit per message, returning its path. */
const repoWith = (...messages: string[]): string => {
const dir = mkdtempSync(join(tmpdir(), "parent-"));
git(dir, "init", "-q");
for (const m of messages)
git(dir, "commit", "-q", "--allow-empty", "-m", m);
return dir;
};

test("reads the parent of a commit with full history", () => {
const dir = repoWith("upstream", "mayhem layer");
expect(firstParent(dir, git(dir, "rev-parse", "HEAD"))).toEqual(
git(dir, "rev-parse", "HEAD^"),
);
});

test("reads the parent in a depth-1 clone, where HEAD^ is not available", () => {
const src = repoWith("upstream", "mayhem layer");
const clone = mkdtempSync(join(tmpdir(), "shallow-"));
git(clone, "clone", "-q", "--depth", "1", `file://${src}`, ".");
expect(() => git(clone, "rev-parse", "--verify", "HEAD^")).toThrow();
expect(firstParent(clone, git(clone, "rev-parse", "HEAD"))).toEqual(
git(src, "rev-parse", "HEAD^"),
);
});

test("a merge commit gives its first parent", () => {
const dir = repoWith("base");
git(dir, "checkout", "-q", "-b", "side");
git(dir, "commit", "-q", "--allow-empty", "-m", "side");
git(dir, "checkout", "-q", "main");
git(dir, "commit", "-q", "--allow-empty", "-m", "main");
const mainHead = git(dir, "rev-parse", "HEAD");
git(dir, "merge", "-q", "--no-ff", "-m", "merge", "side");
expect(firstParent(dir, git(dir, "rev-parse", "HEAD"))).toEqual(mainHead);
});

test("a root commit has none, even when its message has a parent line", () => {
const dir = repoWith(`root\n\nparent ${SHA_A}`);
expect(firstParent(dir, git(dir, "rev-parse", "HEAD"))).toBeUndefined();
});

test("a commit that is not present, or no git at all, is undefined, never a throw", () => {
expect(firstParent(repoWith("x"), SHA_B)).toBeUndefined();
expect(
firstParent(mkdtempSync(join(tmpdir(), "no-git-")), SHA_B),
).toBeUndefined();
expect(firstParent("/nonexistent/path", SHA_B)).toBeUndefined();
});
});

describe("upstreamMergeBase", () => {
/** A fake GitHub client answering each route with `answers[route]`. */
const api = (answers: Record<string, unknown>) => ({
calls: [] as Record<string, string>[],
async request(route: string, params: Record<string, string>) {
this.calls.push({ route, ...params });
const answer = answers[route];
if (answer instanceof Error) throw answer;
return { data: answer };
},
});
const COMPARE = "GET /repos/{owner}/{repo}/compare/{basehead}";

test("a fork gives the merge base with the upstream default branch", async () => {
const gh = api({
"GET /repos/{owner}/{repo}": {
parent: { full_name: "tj/histo", default_branch: "master" },
},
[COMPARE]: { merge_base_commit: { sha: SHA_B } },
});
expect(await upstreamMergeBase(gh, "mayhemheroes/histo", SHA_A)).toEqual({
sha: SHA_B,
});
expect(gh.calls[1]).toEqual({
route: COMPARE,
owner: "mayhemheroes",
repo: "histo",
basehead: `tj:histo:master...${SHA_A}`,
});
});

test("a repository that is not a fork has none", async () => {
const gh = api({ "GET /repos/{owner}/{repo}": {} });
expect(await upstreamMergeBase(gh, "o/r", SHA_A)).toEqual({});
});

test("an API failure is an error, never a throw", async () => {
const gh = api({
"GET /repos/{owner}/{repo}": {
parent: { full_name: "u/r", default_branch: "main" },
},
[COMPARE]: new Error("Not Found"),
});
expect(await upstreamMergeBase(gh, "o/r", SHA_A)).toEqual({
error: "Not Found",
});
});
});

describe("resolveBuildId", () => {
test("an explicit build-id input wins over the merge base", () => {
expect(resolveBuildId("b42", SHA_A)).toEqual({
buildId: "b42",
source: "build-id input",
});
});

test("the upstream merge base otherwise", () => {
expect(resolveBuildId("", SHA_A)).toEqual({
buildId: SHA_A,
source: "upstream merge base",
});
});

test("none when neither is known", () => {
expect(resolveBuildId("", undefined)).toEqual({
buildId: "",
source: "none",
});
});
});
13 changes: 13 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ inputs:
commit checked out under 'package', else GITHUB_SHA. Set it when the job builds a commit other than the
one it checked out.
required: false
parent-revision:
description: >-
Parent commit to record on the Mayhem run. Defaults to the first parent of the recorded 'revision', read
from its commit object (this works in a shallow checkout). Left empty when that commit is not checked out.
required: false
build-id:
description: >-
Build id to record on the Mayhem run. Defaults to the newest commit the recorded 'revision' shares with
the upstream repository this one was forked from (its merge base with the upstream default branch).
Left empty when the repository is not a fork. Note that this default may change without notice, if
you want to control the build-id, ensure you set a specific value either through this option or
through the args parameter.
required: false
args:
description: Command line arguments to override CLI behavior
required: false
Expand Down
Loading
Loading