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:
parent
1f7ee2ed1c
commit
e31269283a
|
@ -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;
|
|
@ -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",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue