Add CLI command: `sourcecred export-graph` (#1110)

* Add CLI command: `sourcecred export-graph`

This adds an `export-graph` command to the SourceCred CLI. It exports
the combined cred graphs for individual RepoIds, as was done for
[sourcecred/research#4].

Example usage:
```
$ node bin/sourcecred.js load sourcecred/mission
$ node bin/sourcecred.js export-graph sourcecred/mission >
  /tmp/mission_graph.json
```

Test plan:
The new command is thoroughly unit tested.

[sourcecred/research#4]: https://github.com/sourcecred/research/pull/4

* Address review feedback by @wchargin
This commit is contained in:
Dandelion Mané 2019-03-01 15:33:40 -07:00 committed by GitHub
parent b561b1728b
commit 996899ade3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 364 additions and 3 deletions

View File

@ -1,6 +1,7 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
- Add the `export-graph` command (#1110)
- Enable loading private repositories (#1085) - Enable loading private repositories (#1085)
- Enable setting type weights to 0 in the UI (#1005) - Enable setting type weights to 0 in the UI (#1005)
- Add support for 🚀 and 👀 reaction types (#1068) - Add support for 🚀 and 👀 reaction types (#1068)

128
src/cli/exportGraph.js Normal file
View File

@ -0,0 +1,128 @@
// @flow
// Implementation of `sourcecred export-graph`.
import {Graph} from "../core/graph";
import * as NullUtil from "../util/null";
import * as RepoIdRegistry from "../core/repoIdRegistry";
import {repoIdToString, stringToRepoId, type RepoId} from "../core/repoId";
import dedent from "../util/dedent";
import type {Command} from "./command";
import * as Common from "./common";
import stringify from "json-stable-stringify";
import type {IAnalysisAdapter} from "../analysis/analysisAdapter";
import {AnalysisAdapter as GithubAnalysisAdapter} from "../plugins/github/analysisAdapter";
import {AnalysisAdapter as GitAnalysisAdapter} from "../plugins/git/analysisAdapter";
function usage(print: (string) => void): void {
print(
dedent`\
usage: sourcecred export-graph REPO_ID [--help]
Print a combined SourceCred graph for a given REPO_ID.
Data must already be loaded for the given REPO_ID, using
'sourcecred load REPO_ID'
REPO_ID refers to a GitHub repository in the form OWNER/NAME: for
example, torvalds/linux. The REPO_ID may be a "combined" repo as
created by the --output flag to sourcecred load.
Arguments:
REPO_ID
Already-loaded repository for which to load data.
--help
Show this help message and exit, as 'sourcecred help export-graph'.
Environment Variables:
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 export-graph' for help");
return 1;
}
export function makeExportGraph(
adapters: $ReadOnlyArray<IAnalysisAdapter>
): Command {
return async function exportGraph(args, std) {
let repoId: RepoId | null = null;
if (adapters.length === 0) {
std.err("fatal: no plugins available");
std.err("fatal: this is likely a build error");
return 1;
}
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case "--help": {
usage(std.out);
return 0;
}
default: {
if (repoId != null)
return die(std, "multiple repository IDs provided");
// Should be a repository.
repoId = stringToRepoId(args[i]);
break;
}
}
}
if (repoId == null) {
return die(std, "no repository ID provided");
}
const directory = Common.sourcecredDirectory();
const registry = RepoIdRegistry.getRegistry(directory);
if (RepoIdRegistry.getEntry(registry, repoId) == null) {
const repoIdStr = repoIdToString(repoId);
std.err(`fatal: repository ID ${repoIdStr} not loaded`);
std.err(`Try running \`sourcecred load ${repoIdStr}\` first.`);
return 1;
}
async function graphForAdapter(adapter: IAnalysisAdapter): Promise<Graph> {
try {
return await adapter.load(directory, NullUtil.get(repoId));
} catch (e) {
throw new Error(
`plugin "${adapter.declaration().name}" errored: ${e.message}`
);
}
}
let graphs: Graph[];
try {
graphs = await Promise.all(adapters.map(graphForAdapter));
} catch (e) {
return die(std, e.message);
}
const graph = Graph.merge(graphs);
const graphJSON = graph.toJSON();
std.out(stringify(graphJSON));
return 0;
};
}
const defaultAdapters = [new GithubAnalysisAdapter(), new GitAnalysisAdapter()];
export const exportGraph = makeExportGraph(defaultAdapters);
export const help: Command = async (args, std) => {
if (args.length === 0) {
usage(std.out);
return 0;
} else {
usage(std.err);
return 1;
}
};
export default exportGraph;

203
src/cli/exportGraph.test.js Normal file
View File

@ -0,0 +1,203 @@
// @flow
import tmp from "tmp";
import {run} from "./testUtil";
import {help, makeExportGraph} from "./exportGraph";
import {Graph, NodeAddress, EdgeAddress} from "../core/graph";
import type {IAnalysisAdapter} from "../analysis/analysisAdapter";
import stringify from "json-stable-stringify";
import * as RepoIdRegistry from "../core/repoIdRegistry";
import {makeRepoId, type RepoId} from "../core/repoId";
class MockAnalysisAdapter implements IAnalysisAdapter {
_resolutionGraph: ?Graph;
_name: string;
/**
* Takes a name for the plugin, and a graph that
* is provided as a result of a successful load.
* If no graph is provided, then load will fail.
*/
constructor(name: string, resolutionGraph: ?Graph) {
this._name = name;
this._resolutionGraph = resolutionGraph;
}
declaration() {
return {
name: this._name,
nodePrefix: NodeAddress.empty,
edgePrefix: EdgeAddress.empty,
nodeTypes: [],
edgeTypes: [],
};
}
async load(
_unused_sourcecredDirectory: string,
_unused_repoId: RepoId
): Promise<Graph> {
if (this._resolutionGraph != null) {
return this._resolutionGraph;
} else {
throw new Error("MockAnalysisAdapterRejects");
}
}
}
describe("cli/exportGraph", () => {
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 export-graph/),
]),
stderr: [],
});
});
it("fails when given arguments", async () => {
expect(await run(help, ["foo/bar"])).toEqual({
exitCode: 1,
stdout: [],
stderr: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred export-graph/),
]),
});
});
});
describe("'exportGraph' command", () => {
function setUpRegistryWithId(repoId: RepoId) {
const dirname = tmp.dirSync().name;
process.env.SOURCECRED_DIRECTORY = dirname;
const registry = RepoIdRegistry.addEntry(RepoIdRegistry.emptyRegistry(), {
repoId,
});
RepoIdRegistry.writeRegistry(registry, dirname);
return dirname;
}
it("prints usage with '--help'", async () => {
const exportGraph = makeExportGraph([new MockAnalysisAdapter("foo")]);
expect(await run(exportGraph, ["--help"])).toEqual({
exitCode: 0,
stdout: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred export-graph/),
]),
stderr: [],
});
});
it("errors if no repoId is provided", async () => {
const exportGraph = makeExportGraph([new MockAnalysisAdapter("foo")]);
expect(await run(exportGraph, [])).toEqual({
exitCode: 1,
stdout: [],
stderr: expect.arrayContaining([
"fatal: no repository ID provided",
"fatal: run 'sourcecred help export-graph' for help",
]),
});
});
it("throws an error if no plugins are available", async () => {
const exportGraph = makeExportGraph([]);
expect(await run(exportGraph, ["--help"])).toEqual({
exitCode: 1,
stdout: [],
stderr: [
"fatal: no plugins available",
"fatal: this is likely a build error",
],
});
});
it("prints json-serialized graph to stdout for a single plugin", async () => {
const g = new Graph().addNode(NodeAddress.empty);
const mockAdapter = new MockAnalysisAdapter("foo", g);
const exportGraph = makeExportGraph([mockAdapter]);
setUpRegistryWithId(makeRepoId("foo", "bar"));
const result = run(exportGraph, ["foo/bar"]);
expect(await result).toEqual({
exitCode: 0,
stdout: [stringify(g.toJSON())],
stderr: [],
});
});
it("merges graphs for multiple plugins", async () => {
const g1 = new Graph().addNode(NodeAddress.fromParts(["g1"]));
const g2 = new Graph().addNode(NodeAddress.fromParts(["g2"]));
const m1 = new MockAnalysisAdapter("foo", g1);
const m2 = new MockAnalysisAdapter("bar", g2);
const mergedGraph = Graph.merge([g1, g2]);
setUpRegistryWithId(makeRepoId("foo", "bar"));
const exportGraph = makeExportGraph([m1, m2]);
expect(await run(exportGraph, ["foo/bar"])).toEqual({
exitCode: 0,
stdout: [stringify(mergedGraph.toJSON())],
stderr: [],
});
});
it("errors if multiple repos are provided", async () => {
const m1 = new MockAnalysisAdapter("foo");
const m2 = new MockAnalysisAdapter("bar");
const exportGraph = makeExportGraph([m1, m2]);
expect(await run(exportGraph, ["foo/bar", "zod/zoink"])).toEqual({
exitCode: 1,
stdout: [],
stderr: [
"fatal: multiple repository IDs provided",
"fatal: run 'sourcecred help export-graph' for help",
],
});
});
it("errors if the repoId was not loaded first", async () => {
const g = new Graph().addNode(NodeAddress.empty);
const mockAdapter = new MockAnalysisAdapter("mock", g);
const exportGraph = makeExportGraph([mockAdapter]);
setUpRegistryWithId(makeRepoId("foo", "bar"));
const result = run(exportGraph, ["zod/zoink"]);
expect(await result).toEqual({
exitCode: 1,
stdout: [],
stderr: [
"fatal: repository ID zod/zoink not loaded",
"Try running `sourcecred load zod/zoink` first.",
],
});
});
it("passes the right arguments to adapter.load", async () => {
const mockAdapter = new MockAnalysisAdapter("zoo");
const exportGraph = makeExportGraph([mockAdapter]);
const repoId = makeRepoId("foo", "bar");
// $ExpectFlowError
mockAdapter.load = jest.fn();
const directory = setUpRegistryWithId(repoId);
await run(exportGraph, ["foo/bar"]);
expect(mockAdapter.load).toHaveBeenCalledWith(directory, repoId);
});
it("reports the failing plugin when a plugin rejects", async () => {
const mockAdapter = new MockAnalysisAdapter("bar");
const exportGraph = makeExportGraph([mockAdapter]);
const repoId = makeRepoId("foo", "bar");
setUpRegistryWithId(repoId);
const result = await run(exportGraph, ["foo/bar"]);
expect(result).toEqual({
exitCode: 1,
stdout: [],
stderr: [
'fatal: plugin "bar" errored: MockAnalysisAdapterRejects',
"fatal: run 'sourcecred help export-graph' for help",
],
});
});
});
});

View File

@ -6,6 +6,7 @@ import dedent from "../util/dedent";
import {help as loadHelp} from "./load"; import {help as loadHelp} from "./load";
import {help as analyzeHelp} from "./analyze"; import {help as analyzeHelp} from "./analyze";
import {help as exportGraphHelp} from "./exportGraph";
const help: Command = async (args, std) => { const help: Command = async (args, std) => {
if (args.length === 0) { if (args.length === 0) {
@ -17,6 +18,7 @@ const help: Command = async (args, std) => {
help: metaHelp, help: metaHelp,
load: loadHelp, load: loadHelp,
analyze: analyzeHelp, analyze: analyzeHelp,
"export-graph": exportGraphHelp,
}; };
if (subHelps[command] !== undefined) { if (subHelps[command] !== undefined) {
return subHelps[command](args.slice(1), std); return subHelps[command](args.slice(1), std);
@ -27,6 +29,8 @@ const help: Command = async (args, std) => {
}; };
function usage(print: (string) => void): void { function usage(print: (string) => void): void {
// TODO: Make the usage function pull its list of commands
// from the sub-helps, to ensure that it is comprehensive
print( print(
dedent`\ dedent`\
usage: sourcecred COMMAND [ARGS...] usage: sourcecred COMMAND [ARGS...]
@ -35,6 +39,7 @@ function usage(print: (string) => void): void {
Commands: Commands:
load load repository data into SourceCred load load repository data into SourceCred
analyze analyze cred for a loaded repository analyze analyze cred for a loaded repository
export-graph print a raw SourceCred graph
help show this help message help show this help message
Use 'sourcecred help COMMAND' for help about an individual command. Use 'sourcecred help COMMAND' for help about an individual command.

View File

@ -45,6 +45,16 @@ describe("cli/help", () => {
}); });
}); });
it("prints help about 'sourcecred export-graph'", async () => {
expect(await run(help, ["export-graph"])).toEqual({
exitCode: 0,
stdout: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred export-graph/),
]),
stderr: [],
});
});
it("fails when given an unknown command", async () => { it("fails when given an unknown command", async () => {
expect(await run(help, ["wat"])).toEqual({ expect(await run(help, ["wat"])).toEqual({
exitCode: 1, exitCode: 1,

View File

@ -8,6 +8,7 @@ import {VERSION_SHORT} from "../core/version";
import help from "./help"; import help from "./help";
import load from "./load"; import load from "./load";
import analyze from "./analyze"; import analyze from "./analyze";
import exportGraph from "./exportGraph";
const sourcecred: Command = async (args, std) => { const sourcecred: Command = async (args, std) => {
if (args.length === 0) { if (args.length === 0) {
@ -25,6 +26,8 @@ const sourcecred: Command = async (args, std) => {
return load(args.slice(1), std); return load(args.slice(1), std);
case "analyze": case "analyze":
return analyze(args.slice(1), std); return analyze(args.slice(1), std);
case "export-graph":
return exportGraph(args.slice(1), std);
default: default:
std.err("fatal: unknown command: " + JSON.stringify(args[0])); std.err("fatal: unknown command: " + JSON.stringify(args[0]));
std.err("fatal: run 'sourcecred help' for commands and usage"); std.err("fatal: run 'sourcecred help' for commands and usage");

View File

@ -14,6 +14,7 @@ function mockCommand(name) {
jest.mock("./help", () => mockCommand("help")); jest.mock("./help", () => mockCommand("help"));
jest.mock("./load", () => mockCommand("load")); jest.mock("./load", () => mockCommand("load"));
jest.mock("./analyze", () => mockCommand("analyze")); jest.mock("./analyze", () => mockCommand("analyze"));
jest.mock("./exportGraph", () => mockCommand("export-graph"));
describe("cli/sourcecred", () => { describe("cli/sourcecred", () => {
it("fails with usage when invoked with no arguments", async () => { it("fails with usage when invoked with no arguments", async () => {
@ -64,6 +65,16 @@ describe("cli/sourcecred", () => {
}); });
}); });
it("responds to 'export-graph'", async () => {
expect(
await run(sourcecred, ["export-graph", "foo/bar", "foo/baz"])
).toEqual({
exitCode: 2,
stdout: ['out(export-graph): ["foo/bar","foo/baz"]'],
stderr: ["err(export-graph)"],
});
});
it("fails given an unknown command", async () => { it("fails given an unknown command", async () => {
expect(await run(sourcecred, ["wat"])).toEqual({ expect(await run(sourcecred, ["wat"])).toEqual({
exitCode: 1, exitCode: 1,