diff --git a/src/analysis/loadGraph.js b/src/analysis/loadGraph.js new file mode 100644 index 0000000..730527c --- /dev/null +++ b/src/analysis/loadGraph.js @@ -0,0 +1,69 @@ +// @flow + +import {Graph} from "../core/graph"; +import * as NullUtil from "../util/null"; +import * as RepoIdRegistry from "../core/repoIdRegistry"; +import {type RepoId} from "../core/repoId"; + +import type {IAnalysisAdapter} from "./analysisAdapter"; + +/** + * Module for loading a graph from a SOURCECRED_DIRECTORY. + */ + +export type LoadGraphResult = + | {|+status: "SUCCESS", +graph: Graph|} + | {|+status: "REPO_NOT_LOADED"|} + | {|+status: "PLUGIN_FAILURE", +pluginName: string, +error: Error|}; + +type GraphOrError = + | {|+type: "GRAPH", +graph: Graph|} + | {|+type: "ERROR", +pluginName: string, +error: Error|}; + +/** + * Load a Graph from disk. + * + * Loads a combined graph by merging each adapter's graph. + * Before this can succeed, every plugin represented in the adapter array + * must have already downloaded and serialized data for the provided + * repoId, e.g. by calling `sourcecred load REPO_ID`. + */ +export async function loadGraph( + sourcecredDirectory: string, + adapters: $ReadOnlyArray, + repoId: RepoId +): Promise { + const registry = RepoIdRegistry.getRegistry(sourcecredDirectory); + if (RepoIdRegistry.getEntry(registry, repoId) == null) { + return {status: "REPO_NOT_LOADED"}; + } + async function graphForAdapter( + adapter: IAnalysisAdapter + ): Promise { + try { + const graph = await adapter.load( + sourcecredDirectory, + NullUtil.get(repoId) + ); + return {type: "GRAPH", graph}; + } catch (e) { + return {type: "ERROR", pluginName: adapter.declaration().name, error: e}; + } + } + const results: GraphOrError[] = await Promise.all( + adapters.map(graphForAdapter) + ); + const graphs: Graph[] = []; + for (const r of results) { + if (r.type === "ERROR") { + return { + status: "PLUGIN_FAILURE", + pluginName: r.pluginName, + error: r.error, + }; + } else { + graphs.push(r.graph); + } + } + return {status: "SUCCESS", graph: Graph.merge(graphs)}; +} diff --git a/src/analysis/loadGraph.test.js b/src/analysis/loadGraph.test.js new file mode 100644 index 0000000..dc8ea78 --- /dev/null +++ b/src/analysis/loadGraph.test.js @@ -0,0 +1,121 @@ +// @flow + +import tmp from "tmp"; +import path from "path"; + +import {Graph, NodeAddress, EdgeAddress} from "../core/graph"; +import type {IAnalysisAdapter} from "../analysis/analysisAdapter"; +import * as RepoIdRegistry from "../core/repoIdRegistry"; +import {makeRepoId, type RepoId} from "../core/repoId"; +import {loadGraph} from "./loadGraph"; + +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 { + if (this._resolutionGraph != null) { + return this._resolutionGraph; + } else { + throw new Error("MockAnalysisAdapterRejects"); + } + } +} + +describe("analysis/loadGraph", () => { + 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; + } + describe("loadGraph", () => { + it("returns status:REPO_NOT_LOADED when sourcecred directory is empty", async () => { + const dirname = tmp.dirSync().name; + const result = await loadGraph( + dirname, + [new MockAnalysisAdapter("foo")], + makeRepoId("foo", "bar") + ); + expect(result).toEqual({status: "REPO_NOT_LOADED"}); + }); + it("returns status:REPO_NOT_LOADED when sourcecred directory doesn't exist", async () => { + const dirname = path.join(tmp.dirSync().name, "nonexistent"); + const result = await loadGraph( + dirname, + [new MockAnalysisAdapter("foo")], + makeRepoId("foo", "bar") + ); + expect(result).toEqual({status: "REPO_NOT_LOADED"}); + }); + it("returns status:REPO_NOT_LOADED when the repo just isn't in the registry", async () => { + const dirname = setUpRegistryWithId(makeRepoId("zod", "zoink")); + const result = await loadGraph( + dirname, + [new MockAnalysisAdapter("foo")], + makeRepoId("foo", "bar") + ); + expect(result).toEqual({status: "REPO_NOT_LOADED"}); + }); + it("returns status:SUCCESS with merged graph on success", 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]); + const dir = setUpRegistryWithId(makeRepoId("foo", "bar")); + const result = await loadGraph(dir, [m1, m2], makeRepoId("foo", "bar")); + expect(result.status).toEqual("SUCCESS"); + if (result.status !== "SUCCESS") { + throw new Error("Unreachable, needed to satisfy flow."); + } + expect(mergedGraph.equals(result.graph)).toBe(true); + }); + it("returns an empty graph if no adapters provided", async () => { + const dir = setUpRegistryWithId(makeRepoId("foo", "bar")); + const result = await loadGraph(dir, [], makeRepoId("foo", "bar")); + expect(result.status).toEqual("SUCCESS"); + if (result.status !== "SUCCESS") { + throw new Error("Unreachable, needed to satisfy flow."); + } + expect(result.graph.equals(new Graph())).toBe(true); + }); + it("returns a status:PLUGIN_FAILURE if the plugin errors", async () => { + const mockAdapter = new MockAnalysisAdapter("bar"); + const repoId = makeRepoId("foo", "bar"); + const dir = setUpRegistryWithId(repoId); + const result = await loadGraph(dir, [mockAdapter], repoId); + expect(result).toEqual({ + status: "PLUGIN_FAILURE", + pluginName: "bar", + error: new Error("MockAnalysisAdapterRejects"), + }); + }); + }); +}); diff --git a/src/cli/exportGraph.js b/src/cli/exportGraph.js index 0e30f23..dfbf828 100644 --- a/src/cli/exportGraph.js +++ b/src/cli/exportGraph.js @@ -1,16 +1,13 @@ // @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 {loadGraph, type LoadGraphResult} from "../analysis/loadGraph"; -import type {IAnalysisAdapter} from "../analysis/analysisAdapter"; import {AnalysisAdapter as GithubAnalysisAdapter} from "../plugins/github/analysisAdapter"; import {AnalysisAdapter as GitAnalysisAdapter} from "../plugins/git/analysisAdapter"; @@ -52,15 +49,10 @@ function die(std, message) { } export function makeExportGraph( - adapters: $ReadOnlyArray + loader: (RepoId) => Promise ): 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": { @@ -81,39 +73,40 @@ export function makeExportGraph( 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 { - try { - return await adapter.load(directory, NullUtil.get(repoId)); - } catch (e) { - throw new Error( - `plugin "${adapter.declaration().name}" errored: ${e.message}` + const result: LoadGraphResult = await loader(repoId); + switch (result.status) { + case "REPO_NOT_LOADED": { + const repoIdStr = repoIdToString(repoId); + std.err(`fatal: repository ID ${repoIdStr} not loaded`); + std.err(`Try running \`sourcecred load ${repoIdStr}\` first.`); + return 1; + } + case "PLUGIN_FAILURE": { + std.err( + `fatal: plugin "${result.pluginName}" errored: ${ + result.error.message + }` ); + return 1; + } + case "SUCCESS": { + const graphJSON = result.graph.toJSON(); + std.out(stringify(graphJSON)); + return 0; + } + // istanbul ignore next: unreachable per Flow + default: { + std.err(`Unexpected status: ${(result.status: empty)}`); + return 1; } } - 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); +const defaultLoadGraph = (r: RepoId) => + loadGraph(Common.sourcecredDirectory(), defaultAdapters, r); +export const exportGraph = makeExportGraph(defaultLoadGraph); export const help: Command = async (args, std) => { if (args.length === 0) { diff --git a/src/cli/exportGraph.test.js b/src/cli/exportGraph.test.js index 523f357..0480867 100644 --- a/src/cli/exportGraph.test.js +++ b/src/cli/exportGraph.test.js @@ -1,51 +1,11 @@ // @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 {Graph, NodeAddress} from "../core/graph"; 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 { - if (this._resolutionGraph != null) { - return this._resolutionGraph; - } else { - throw new Error("MockAnalysisAdapterRejects"); - } - } -} +import {makeRepoId} from "../core/repoId"; describe("cli/exportGraph", () => { describe("'help' command", () => { @@ -70,18 +30,8 @@ describe("cli/exportGraph", () => { }); 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")]); + const exportGraph = makeExportGraph(jest.fn()); expect(await run(exportGraph, ["--help"])).toEqual({ exitCode: 0, stdout: expect.arrayContaining([ @@ -92,7 +42,7 @@ describe("cli/exportGraph", () => { }); it("errors if no repoId is provided", async () => { - const exportGraph = makeExportGraph([new MockAnalysisAdapter("foo")]); + const exportGraph = makeExportGraph(jest.fn()); expect(await run(exportGraph, [])).toEqual({ exitCode: 1, stdout: [], @@ -103,50 +53,29 @@ describe("cli/exportGraph", () => { }); }); - 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("attempts to load the repoId provided", async () => { + const mockFn = jest.fn(); + const exportGraph = makeExportGraph(mockFn); + await run(exportGraph, ["foo/bar"]); + expect(mockFn).toHaveBeenCalledWith(makeRepoId("foo", "bar")); }); - 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")); + it("on load success, prints the stringified graph to stdout", async () => { + const graph = new Graph().addNode(NodeAddress.empty); + const loadGraphResult = {status: "SUCCESS", graph}; + const exportGraph = makeExportGraph( + (_unused_repoId) => new Promise((resolve) => resolve(loadGraphResult)) + ); 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())], + stdout: [stringify(graph.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]); + const exportGraph = makeExportGraph(jest.fn()); expect(await run(exportGraph, ["foo/bar", "zod/zoink"])).toEqual({ exitCode: 1, stdout: [], @@ -158,10 +87,10 @@ describe("cli/exportGraph", () => { }); 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 loadGraphResult = {status: "REPO_NOT_LOADED"}; + const exportGraph = makeExportGraph( + (_unused_repoId) => new Promise((resolve) => resolve(loadGraphResult)) + ); const result = run(exportGraph, ["zod/zoink"]); expect(await result).toEqual({ exitCode: 1, @@ -173,30 +102,20 @@ describe("cli/exportGraph", () => { }); }); - 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 loadGraphResult = { + status: "PLUGIN_FAILURE", + pluginName: "badPlugin", + error: new Error("MockPluginFailure"), + }; + const exportGraph = makeExportGraph( + (_unused_repoId) => new Promise((resolve) => resolve(loadGraphResult)) + ); 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", - ], + stderr: ['fatal: plugin "badPlugin" errored: MockPluginFailure'], }); }); });