diff --git a/src/cli/load.js b/src/cli/load.js new file mode 100644 index 0000000..51f4297 --- /dev/null +++ b/src/cli/load.js @@ -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 + . 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; diff --git a/src/cli/load.test.js b/src/cli/load.test.js new file mode 100644 index 0000000..3339cb3 --- /dev/null +++ b/src/cli/load.test.js @@ -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; +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", + ], + }); + }); + }); + }); +});