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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ The mCode Action accepts the following inputs:
| | `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
74 changes: 74 additions & 0 deletions __tests__/revision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { join } from "path";
import {
checkedOutRevision,
firstParent,
resolveBuildId,
resolveParentRevision,
resolveRevision,
upstreamMergeBase,
} from "../src/revision";

const SHA_A = "a".repeat(40);
Expand Down Expand Up @@ -169,3 +171,75 @@ describe("firstParent", () => {
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",
});
});
});
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ inputs:
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
89 changes: 87 additions & 2 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,23 @@ function run() {
if (config.parentRevision) {
args.push("--parent-revision", config.parentRevision);
}
if (!args.includes("--build-id") && !args.includes("-b")) {
const explicitBuildId = (0, core_1.getInput)("build-id");
const mergeBase = explicitBuildId
? {}
: yield (0, revision_1.upstreamMergeBase)((0, github_1.getOctokit)(config.githubToken), config.repo, config.revision);
if (mergeBase.error) {
(0, core_1.warning)(`Could not find the upstream commit of ${config.revision} ` +
`(${mergeBase.error}); not passing --build-id.`);
}
const { buildId, source: buildIdSource } = (0, revision_1.resolveBuildId)(explicitBuildId, mergeBase.sha);
(0, core_1.info)(buildId
? `Build id: ${buildId} (from ${buildIdSource}).`
: "Build id: none (the repository is not a fork).");
if (buildId) {
args.push("--build-id", buildId);
}
}
const argsString = args.join(" ");
// Generate arguments for wait command
// sarif, junit, coverage
Expand Down Expand Up @@ -366,12 +383,21 @@ exports.mayhemProjectSlug = mayhemProjectSlug;
/***/ }),

/***/ 5517:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {

"use strict";

var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.resolveParentRevision = exports.firstParent = exports.resolveRevision = exports.checkedOutRevision = void 0;
exports.resolveBuildId = exports.upstreamMergeBase = exports.resolveParentRevision = exports.firstParent = exports.resolveRevision = exports.checkedOutRevision = void 0;
const child_process_1 = __nccwpck_require__(2081);
/**
* The commit the workspace actually has checked out. Never throws: when `cwd`
Expand Down Expand Up @@ -478,6 +504,65 @@ function resolveParentRevision(explicit, fromCommit) {
return { parentRevision: "", source: "none" };
}
exports.resolveParentRevision = resolveParentRevision;
/**
* The newest commit that `revision` shares with the upstream repository this
* repository was forked from: the merge base with the upstream's default
* branch, asked of the GitHub compare API so no upstream history needs to be
* fetched. Never throws.
* @param api an authenticated GitHub client.
* @param repo this repository as "owner/name".
* @param revision the commit recorded on the run.
* @return `{sha}` with the merge base, `{}` when the repository is not a fork,
* or `{error}` when GitHub could not answer.
*/
function upstreamMergeBase(api, repo, revision) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const [owner, name] = repo.split("/");
try {
const { data } = yield api.request("GET /repos/{owner}/{repo}", {
owner,
repo: name,
});
const parent = data.parent;
if (!parent) {
return {};
}
const [parentOwner, parentName] = parent.full_name.split("/");
const { data: compare } = yield api.request("GET /repos/{owner}/{repo}/compare/{basehead}", {
owner,
repo: name,
basehead: `${parentOwner}:${parentName}:${parent.default_branch}...${revision}`,
});
const sha = (_a = compare
.merge_base_commit) === null || _a === void 0 ? void 0 : _a.sha;
return sha
? { sha }
: { error: `no merge base between ${revision} and ${parent.full_name}` };
}
catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
});
}
exports.upstreamMergeBase = upstreamMergeBase;
/**
* Which build id to record on the Mayhem run: an explicit `build-id` input,
* else the upstream merge base of the recorded revision.
* @param explicit the `build-id` input, possibly empty.
* @param mergeBase what `upstreamMergeBase` found.
* @return the build id to pass to the CLI ("" for none) and where it came from.
*/
function resolveBuildId(explicit, mergeBase) {
if (explicit) {
return { buildId: explicit, source: "build-id input" };
}
if (mergeBase) {
return { buildId: mergeBase, source: "upstream merge base" };
}
return { buildId: "", source: "none" };
}
exports.resolveBuildId = resolveBuildId;


/***/ }),
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

32 changes: 31 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ import {
warning,
} from "@actions/core";
import { exec } from "@actions/exec";
import { context as githubContext } from "@actions/github";
import { context as githubContext, getOctokit } from "@actions/github";
import { downloadTool } from "@actions/tool-cache";
import { readFileSync, chmodSync } from "fs";
import {
checkedOutRevision,
firstParent,
ParentRevisionSource,
resolveParentRevision,
resolveBuildId,
resolveRevision,
RevisionSource,
upstreamMergeBase,
} from "./revision";
import { mayhemProjectSlug } from "./project";

Expand Down Expand Up @@ -249,6 +251,34 @@ async function run(): Promise<void> {
if (config.parentRevision) {
args.push("--parent-revision", config.parentRevision);
}
if (!args.includes("--build-id") && !args.includes("-b")) {
const explicitBuildId = getInput("build-id");
const mergeBase = explicitBuildId
? {}
: await upstreamMergeBase(
getOctokit(config.githubToken),
config.repo,
config.revision,
);
if (mergeBase.error) {
warning(
`Could not find the upstream commit of ${config.revision} ` +
`(${mergeBase.error}); not passing --build-id.`,
);
}
const { buildId, source: buildIdSource } = resolveBuildId(
explicitBuildId,
mergeBase.sha,
);
info(
buildId
? `Build id: ${buildId} (from ${buildIdSource}).`
: "Build id: none (the repository is not a fork).",
);
if (buildId) {
args.push("--build-id", buildId);
}
}

const argsString = args.join(" ");

Expand Down
80 changes: 80 additions & 0 deletions src/revision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,83 @@ export function resolveParentRevision(
}
return { parentRevision: "", source: "none" };
}

/** Where the build id recorded on a Mayhem run came from, for the action log. */
export type BuildIdSource = "build-id input" | "upstream merge base" | "none";

/** The subset of an Octokit client that `upstreamMergeBase` uses. */
type GitHubApi = {
request: (
route: string,
params: Record<string, string>,
) => Promise<{ data: unknown }>;
};

/**
* The newest commit that `revision` shares with the upstream repository this
* repository was forked from: the merge base with the upstream's default
* branch, asked of the GitHub compare API so no upstream history needs to be
* fetched. Never throws.
* @param api an authenticated GitHub client.
* @param repo this repository as "owner/name".
* @param revision the commit recorded on the run.
* @return `{sha}` with the merge base, `{}` when the repository is not a fork,
* or `{error}` when GitHub could not answer.
*/
export async function upstreamMergeBase(
api: GitHubApi,
repo: string,
revision: string,
): Promise<{ sha?: string; error?: string }> {
const [owner, name] = repo.split("/");
try {
const { data } = await api.request("GET /repos/{owner}/{repo}", {
owner,
repo: name,
});
const parent = (
data as {
parent?: { full_name: string; default_branch: string };
}
).parent;
if (!parent) {
return {};
}
const [parentOwner, parentName] = parent.full_name.split("/");
const { data: compare } = await api.request(
"GET /repos/{owner}/{repo}/compare/{basehead}",
{
owner,
repo: name,
basehead: `${parentOwner}:${parentName}:${parent.default_branch}...${revision}`,
},
);
const sha = (compare as { merge_base_commit?: { sha?: string } })
.merge_base_commit?.sha;
return sha
? { sha }
: { error: `no merge base between ${revision} and ${parent.full_name}` };
} catch (err: unknown) {
return { error: err instanceof Error ? err.message : String(err) };
}
}

/**
* Which build id to record on the Mayhem run: an explicit `build-id` input,
* else the upstream merge base of the recorded revision.
* @param explicit the `build-id` input, possibly empty.
* @param mergeBase what `upstreamMergeBase` found.
* @return the build id to pass to the CLI ("" for none) and where it came from.
*/
export function resolveBuildId(
explicit: string | undefined,
mergeBase: string | undefined,
): { buildId: string; source: BuildIdSource } {
if (explicit) {
return { buildId: explicit, source: "build-id input" };
}
if (mergeBase) {
return { buildId: mergeBase, source: "upstream merge base" };
}
return { buildId: "", source: "none" };
}