re-implement src/cli/load

The new implementation wraps `api/load`.

Test plan: I've ported over the tests from the old `cli/load`. Run `yarn
test`.
This commit is contained in:
Dandelion Mané 2019-07-18 16:45:13 +01:00
parent 1f7ee2ed1c
commit e31269283a
2 changed files with 346 additions and 0 deletions

146
src/cli/load.js Normal file
View File

@ -0,0 +1,146 @@
// @flow
// Implementation of `sourcecred load`
import dedent from "../util/dedent";
import {LoggingTaskReporter} from "../util/taskReporter";
import type {Command} from "./command";
import * as Common from "./common";
import {defaultWeights, fromJSON as weightsFromJSON} from "../analysis/weights";
import {load} from "../api/load";
import {specToProject} from "../plugins/github/specToProject";
import fs from "fs-extra";
function usage(print: (string) => void): void {
print(
dedent`\
usage: sourcecred load [PROJECT_SPEC...]
[--weights WEIGHTS_FILE]
sourcecred load --help
Load a target project, generating a cred attribution for it.
PROJET_SPEC is a string that describes a project.
Currently, it must be a GitHub repository in the form OWNER/NAME: for
example, torvalds/linux. Support for more PROJECT_SPECS will be added
shortly.
Arguments:
PROJECT_SPEC:
Identifier of a project to load.
--weights WEIGHTS_FILE
Path to a json file which contains a weights configuration.
This will be used instead of the default weights and persisted.
--help
Show this help message and exit, as 'sourcecred help load'.
Environment variables:
SOURCECRED_GITHUB_TOKEN
API token for GitHub. This should be a 40-character hex
string. Required if using the GitHub plugin; ignored
otherwise.
To generate a token, create a "Personal access token" at
<https://github.com/settings/tokens>. When loading data for
public repositories, no special permissions are required.
For private repositories, the 'repo' scope is required.
SOURCECRED_DIRECTORY
Directory owned by SourceCred, in which data, caches,
registries, etc. are stored. Optional: defaults to a
directory 'sourcecred' under your OS's temporary directory;
namely:
${Common.defaultSourcecredDirectory()}
`.trimRight()
);
}
function die(std, message) {
std.err("fatal: " + message);
std.err("fatal: run 'sourcecred help load' for help");
return 1;
}
const loadCommand: Command = async (args, std) => {
const projectSpecs: string[] = [];
let weightsPath: ?string;
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case "--help": {
usage(std.out);
return 0;
}
case "--weights": {
if (weightsPath != undefined)
return die(std, "'--weights' given multiple times");
if (++i >= args.length)
return die(std, "'--weights' given without value");
weightsPath = args[i];
break;
}
default: {
projectSpecs.push(args[i]);
break;
}
}
}
if (projectSpecs.length == 0) {
return die(std, "projects not specified");
}
let weights = defaultWeights();
if (weightsPath) {
weights = await loadWeightOverrides(weightsPath);
}
const githubToken = Common.githubToken();
if (githubToken == null) {
return die(std, "SOURCECRED_GITHUB_TOKEN not set");
}
const taskReporter = new LoggingTaskReporter();
const projects = await Promise.all(
projectSpecs.map((s) => specToProject(s, githubToken))
);
const params = {alpha: 0.05, intervalDecay: 0.5, weights};
const optionses = projects.map((project) => ({
project,
params,
sourcecredDirectory: Common.sourcecredDirectory(),
githubToken,
}));
// Deliberately load in serial because GitHub requests that their API not
// be called concurrently
for (const options of optionses) {
await load(options, taskReporter);
}
return 0;
};
const loadWeightOverrides = async (path: string) => {
if (!(await fs.exists(path))) {
throw new Error("Could not find the weights file");
}
const raw = await fs.readFile(path, "utf-8");
const weightsJSON = JSON.parse(raw);
try {
return weightsFromJSON(weightsJSON);
} catch (e) {
throw new Error(`provided weights file is invalid:\n${e}`);
}
};
export const help: Command = async (args, std) => {
if (args.length === 0) {
usage(std.out);
return 0;
} else {
usage(std.err);
return 1;
}
};
export default loadCommand;

200
src/cli/load.test.js Normal file
View File

@ -0,0 +1,200 @@
// @flow
import tmp from "tmp";
import fs from "fs-extra";
import {LoggingTaskReporter} from "../util/taskReporter";
import {NodeAddress} from "../core/graph";
import {run} from "./testUtil";
import loadCommand, {help} from "./load";
import {defaultWeights, toJSON as weightsToJSON} from "../analysis/weights";
import * as Common from "./common";
import {makeRepoId, stringToRepoId} from "../core/repoId";
jest.mock("../api/load", () => ({load: jest.fn()}));
type JestMockFn = $Call<typeof jest.fn>;
const load: JestMockFn = (require("../api/load").load: any);
describe("cli/load", () => {
beforeEach(() => {
jest.clearAllMocks();
// Tests should call `newSourcecredDirectory` directly when they
// need the value. We call it here in case a test needs it to be set
// but does not care about the particular value.
newSourcecredDirectory();
});
const fakeGithubToken = "....".replace(/./g, "0123456789");
function newSourcecredDirectory() {
const dirname = tmp.dirSync().name;
process.env.SOURCECRED_DIRECTORY = dirname;
process.env.SOURCECRED_GITHUB_TOKEN = fakeGithubToken;
return dirname;
}
describe("'help' command", () => {
it("prints usage when given no arguments", async () => {
expect(await run(help, [])).toEqual({
exitCode: 0,
stdout: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred load/),
]),
stderr: [],
});
});
it("fails when given arguments", async () => {
expect(await run(help, ["foo/bar"])).toEqual({
exitCode: 1,
stdout: [],
stderr: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred load/),
]),
});
});
});
describe("'load' command wrapper", () => {
it("prints usage with '--help'", async () => {
expect(await run(loadCommand, ["--help"])).toEqual({
exitCode: 0,
stdout: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred load/),
]),
stderr: [],
});
});
it("calls load with a single repo", async () => {
const invocation = run(loadCommand, ["foo/bar"]);
const expectedOptions = {
project: {id: "foo/bar", repoIds: [makeRepoId("foo", "bar")]},
params: {alpha: 0.05, intervalDecay: 0.5, weights: defaultWeights()},
sourcecredDirectory: Common.sourcecredDirectory(),
githubToken: fakeGithubToken,
};
expect(await invocation).toEqual({
exitCode: 0,
stdout: [],
stderr: [],
});
expect(load).toHaveBeenCalledWith(
expectedOptions,
expect.any(LoggingTaskReporter)
);
});
it("calls load with multiple repos", async () => {
const invocation = run(loadCommand, ["foo/bar", "zoink/zod"]);
const expectedOptions = (projectId: string) => ({
project: {id: projectId, repoIds: [stringToRepoId(projectId)]},
params: {alpha: 0.05, intervalDecay: 0.5, weights: defaultWeights()},
sourcecredDirectory: Common.sourcecredDirectory(),
githubToken: fakeGithubToken,
});
expect(await invocation).toEqual({
exitCode: 0,
stdout: [],
stderr: [],
});
expect(load).toHaveBeenCalledWith(
expectedOptions("foo/bar"),
expect.any(LoggingTaskReporter)
);
expect(load).toHaveBeenCalledWith(
expectedOptions("zoink/zod"),
expect.any(LoggingTaskReporter)
);
});
it("loads the weights, if provided", async () => {
const weights = defaultWeights();
weights.nodeTypeWeights.set(NodeAddress.empty, 33);
const weightsJSON = weightsToJSON(weights);
const weightsFile = tmp.tmpNameSync();
fs.writeFileSync(weightsFile, JSON.stringify(weightsJSON));
const invocation = run(loadCommand, [
"foo/bar",
"--weights",
weightsFile,
]);
const expectedOptions = {
project: {id: "foo/bar", repoIds: [makeRepoId("foo", "bar")]},
params: {alpha: 0.05, intervalDecay: 0.5, weights},
sourcecredDirectory: Common.sourcecredDirectory(),
githubToken: fakeGithubToken,
};
expect(await invocation).toEqual({
exitCode: 0,
stdout: [],
stderr: [],
});
expect(load).toHaveBeenCalledWith(
expectedOptions,
expect.any(LoggingTaskReporter)
);
});
describe("errors if", () => {
async function expectFailure({args, message}) {
expect(await run(loadCommand, args)).toEqual({
exitCode: 1,
stdout: [],
stderr: message,
});
expect(load).not.toHaveBeenCalled();
}
it("no projects specified", async () => {
await expectFailure({
args: [],
message: [
"fatal: projects not specified",
"fatal: run 'sourcecred help load' for help",
],
});
});
it("the weights file does not exist", async () => {
const weightsFile = tmp.tmpNameSync();
await expectFailure({
args: ["foo/bar", "--weights", weightsFile],
message: [
expect.stringMatching("^Error: Could not find the weights file"),
],
});
});
it("the weights file is invalid", async () => {
const weightsFile = tmp.tmpNameSync();
fs.writeFileSync(weightsFile, JSON.stringify({weights: 3}));
await expectFailure({
args: ["foo/bar", "--weights", weightsFile],
message: [
expect.stringMatching("^Error: provided weights file is invalid"),
],
});
});
it("the repo identifier is invalid", async () => {
await expectFailure({
args: ["missing_delimiter"],
message: [
expect.stringMatching("^Error: invalid spec: missing_delimiter"),
],
});
});
it("the SOURCECRED_GITHUB_TOKEN is unset", async () => {
delete process.env.SOURCECRED_GITHUB_TOKEN;
await expectFailure({
args: ["missing_delimiter"],
message: [
"fatal: SOURCECRED_GITHUB_TOKEN not set",
"fatal: run 'sourcecred help load' for help",
],
});
});
});
});
});