create the GitHub graph (#405)
This commit: - adds `github/createGraph.js` - which ingests GitHub GraphQL response - and creates a GitHub graph - adds `github/graphView.js`, - which takes a Graph - and validates that all GitHub specific node and edge invariants hold - every github node may be parsed by `github/node/fromRaw` - with the right node type - every github edge may be parsed by `github/edge/fromRaw` - with the right edge type - with the right src address prefix - with the right dst address prefix - every child node has exactly one parent - of the right type - and provides convenient porcelain methods for - finding repos in the graph - finding issues of a repo - finding pulls of a repo - finding reviews of a pull - finding comments of a Commentable - finding authors of Authorables - finding parent of a ChildAddress - tests `createGraph` - via snapshot testing - by checking the GraphView invariants hold - tests `graphView` - by checking individual entities in the example-git repository have the proper relationships - by checking that for every class of invariant, errors are thrown if the invariant is violated Test plan: - Extensive unit and snapshot tests added. `yarn travis` passes.
This commit is contained in:
parent
24a7547e16
commit
a209caeec2
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,113 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`plugins/github/graphView issues /#2 /comment #1 matches snapshot 1`] = `
|
||||
Object {
|
||||
"id": "373768703",
|
||||
"parent": Object {
|
||||
"number": "2",
|
||||
"repo": Object {
|
||||
"name": "example-github",
|
||||
"owner": "sourcecred",
|
||||
"type": "REPO",
|
||||
},
|
||||
"type": "ISSUE",
|
||||
},
|
||||
"type": "COMMENT",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`plugins/github/graphView issues /#2 matches snapshot 1`] = `
|
||||
Object {
|
||||
"number": "2",
|
||||
"repo": Object {
|
||||
"name": "example-github",
|
||||
"owner": "sourcecred",
|
||||
"type": "REPO",
|
||||
},
|
||||
"type": "ISSUE",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`plugins/github/graphView issues /#2 number of comments matches snapshot 1`] = `8`;
|
||||
|
||||
exports[`plugins/github/graphView issues number of issues matches snapshot 1`] = `6`;
|
||||
|
||||
exports[`plugins/github/graphView pulls /#5 /comment #1 matches snapshot 1`] = `
|
||||
Object {
|
||||
"id": "396430464",
|
||||
"parent": Object {
|
||||
"number": "5",
|
||||
"repo": Object {
|
||||
"name": "example-github",
|
||||
"owner": "sourcecred",
|
||||
"type": "REPO",
|
||||
},
|
||||
"type": "PULL",
|
||||
},
|
||||
"type": "COMMENT",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`plugins/github/graphView pulls /#5 /review #1 /comment #1 matches snapshot 1`] = `
|
||||
Object {
|
||||
"id": "171460198",
|
||||
"parent": Object {
|
||||
"id": "100313899",
|
||||
"pull": Object {
|
||||
"number": "5",
|
||||
"repo": Object {
|
||||
"name": "example-github",
|
||||
"owner": "sourcecred",
|
||||
"type": "REPO",
|
||||
},
|
||||
"type": "PULL",
|
||||
},
|
||||
"type": "REVIEW",
|
||||
},
|
||||
"type": "COMMENT",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`plugins/github/graphView pulls /#5 /review #1 has the right number of review comments 1`] = `1`;
|
||||
|
||||
exports[`plugins/github/graphView pulls /#5 /review #1 matches snapshot 1`] = `
|
||||
Object {
|
||||
"id": "100313899",
|
||||
"pull": Object {
|
||||
"number": "5",
|
||||
"repo": Object {
|
||||
"name": "example-github",
|
||||
"owner": "sourcecred",
|
||||
"type": "REPO",
|
||||
},
|
||||
"type": "PULL",
|
||||
},
|
||||
"type": "REVIEW",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`plugins/github/graphView pulls /#5 matches snapshot 1`] = `
|
||||
Object {
|
||||
"number": "5",
|
||||
"repo": Object {
|
||||
"name": "example-github",
|
||||
"owner": "sourcecred",
|
||||
"type": "REPO",
|
||||
},
|
||||
"type": "PULL",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`plugins/github/graphView pulls /#5 number of comments matches snapshot 1`] = `1`;
|
||||
|
||||
exports[`plugins/github/graphView pulls /#5 number of reviews matches snapshot 1`] = `2`;
|
||||
|
||||
exports[`plugins/github/graphView pulls number of pulls matches snapshot 1`] = `3`;
|
||||
|
||||
exports[`plugins/github/graphView repo matches snapshot 1`] = `
|
||||
Object {
|
||||
"name": "example-github",
|
||||
"owner": "sourcecred",
|
||||
"type": "REPO",
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,161 @@
|
|||
// @flow
|
||||
|
||||
import {Graph} from "../../core/graph";
|
||||
import type {
|
||||
GithubResponseJSON,
|
||||
RepositoryJSON,
|
||||
ReviewJSON,
|
||||
PullJSON,
|
||||
IssueJSON,
|
||||
CommentJSON,
|
||||
ReviewCommentJSON,
|
||||
NullableAuthorJSON,
|
||||
} from "./graphql";
|
||||
|
||||
import type {
|
||||
RepoAddress,
|
||||
IssueAddress,
|
||||
PullAddress,
|
||||
ReviewAddress,
|
||||
UserlikeAddress,
|
||||
StructuredAddress,
|
||||
AuthorableAddress,
|
||||
ChildAddress,
|
||||
ParentAddress,
|
||||
} from "./nodes";
|
||||
import {toRaw} from "./nodes";
|
||||
|
||||
import {createEdge} from "./edges";
|
||||
|
||||
import {COMMIT_TYPE, toRaw as gitToRaw} from "../git/nodes";
|
||||
|
||||
import {
|
||||
reviewUrlToId,
|
||||
issueCommentUrlToId,
|
||||
pullCommentUrlToId,
|
||||
reviewCommentUrlToId,
|
||||
} from "./urlIdParse";
|
||||
|
||||
export function createGraph(data: GithubResponseJSON): Graph {
|
||||
const creator = new GraphCreator();
|
||||
creator.addData(data);
|
||||
return creator.graph;
|
||||
}
|
||||
|
||||
class GraphCreator {
|
||||
graph: Graph;
|
||||
|
||||
constructor() {
|
||||
this.graph = new Graph();
|
||||
}
|
||||
|
||||
addNode(addr: StructuredAddress) {
|
||||
this.graph.addNode(toRaw(addr));
|
||||
}
|
||||
|
||||
addData(data: GithubResponseJSON) {
|
||||
this.addRepository(data.repository);
|
||||
}
|
||||
|
||||
addRepository(repoJSON: RepositoryJSON) {
|
||||
const repo: RepoAddress = {
|
||||
type: "REPO",
|
||||
owner: repoJSON.owner.login,
|
||||
name: repoJSON.name,
|
||||
};
|
||||
this.addNode(repo);
|
||||
repoJSON.issues.nodes.forEach((issue) => this.addIssue(repo, issue));
|
||||
repoJSON.pulls.nodes.forEach((pull) => this.addPull(repo, pull));
|
||||
}
|
||||
|
||||
addIssue(repo: RepoAddress, issueJSON: IssueJSON) {
|
||||
const issue: IssueAddress = {
|
||||
type: "ISSUE",
|
||||
repo,
|
||||
number: String(issueJSON.number),
|
||||
};
|
||||
this.addNode(issue);
|
||||
this.addAuthors(issue, issueJSON.author);
|
||||
this.addHasParent(issue, repo);
|
||||
issueJSON.comments.nodes.forEach((comment) =>
|
||||
this.addComment(issue, comment)
|
||||
);
|
||||
}
|
||||
|
||||
addPull(repo: RepoAddress, pullJSON: PullJSON) {
|
||||
const pull: PullAddress = {
|
||||
type: "PULL",
|
||||
repo,
|
||||
number: String(pullJSON.number),
|
||||
};
|
||||
this.addNode(pull);
|
||||
this.addAuthors(pull, pullJSON.author);
|
||||
this.addHasParent(pull, repo);
|
||||
pullJSON.comments.nodes.forEach((c) => this.addComment(pull, c));
|
||||
pullJSON.reviews.nodes.forEach((review) => this.addReview(pull, review));
|
||||
if (pullJSON.mergeCommit != null) {
|
||||
const commitHash = pullJSON.mergeCommit.oid;
|
||||
const commit = {type: COMMIT_TYPE, hash: commitHash};
|
||||
this.graph.addNode(gitToRaw(commit));
|
||||
this.graph.addEdge(createEdge.mergedAs(pull, commit));
|
||||
}
|
||||
}
|
||||
|
||||
addReview(pull: PullAddress, reviewJSON: ReviewJSON) {
|
||||
const id = reviewUrlToId(reviewJSON.url);
|
||||
const review = {
|
||||
type: "REVIEW",
|
||||
pull,
|
||||
id,
|
||||
};
|
||||
this.addNode(review);
|
||||
reviewJSON.comments.nodes.forEach((c) => this.addComment(review, c));
|
||||
this.addAuthors(review, reviewJSON.author);
|
||||
this.addHasParent(review, pull);
|
||||
}
|
||||
|
||||
addComment(
|
||||
parent: IssueAddress | PullAddress | ReviewAddress,
|
||||
commentJSON: CommentJSON | ReviewCommentJSON
|
||||
) {
|
||||
const id = (function() {
|
||||
switch (parent.type) {
|
||||
case "ISSUE":
|
||||
return issueCommentUrlToId(commentJSON.url);
|
||||
case "PULL":
|
||||
return pullCommentUrlToId(commentJSON.url);
|
||||
case "REVIEW":
|
||||
return reviewCommentUrlToId(commentJSON.url);
|
||||
default:
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
(parent.type: empty);
|
||||
throw new Error(`Unexpected comment parent type: ${parent.type}`);
|
||||
}
|
||||
})();
|
||||
const comment = {
|
||||
type: "COMMENT",
|
||||
parent,
|
||||
id,
|
||||
};
|
||||
this.addNode(comment);
|
||||
this.addAuthors(comment, commentJSON.author);
|
||||
this.addHasParent(comment, parent);
|
||||
}
|
||||
|
||||
addAuthors(content: AuthorableAddress, authorJSON: NullableAuthorJSON) {
|
||||
// author may be null, as not all posts have authors
|
||||
if (authorJSON == null) {
|
||||
return;
|
||||
}
|
||||
const author: UserlikeAddress = {
|
||||
type: "USERLIKE",
|
||||
login: authorJSON.login,
|
||||
};
|
||||
this.addNode(author);
|
||||
this.graph.addEdge(createEdge.authors(author, content));
|
||||
}
|
||||
|
||||
addHasParent(child: ChildAddress, parent: ParentAddress) {
|
||||
this.graph.addEdge(createEdge.hasParent(child, parent));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
// @flow
|
||||
|
||||
import {createGraph} from "./createGraph";
|
||||
import {GraphView} from "./graphView";
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
|
||||
function exampleGraph() {
|
||||
const data = cloneDeep(require("./demoData/example-github"));
|
||||
return createGraph(data);
|
||||
}
|
||||
|
||||
describe("plugins/github/createGraph", () => {
|
||||
it("example graph matches snapshot", () => {
|
||||
expect(exampleGraph()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("passes all GraphView invariants", () => {
|
||||
const graph = exampleGraph();
|
||||
const view = new GraphView(graph);
|
||||
// This test is high leverage. It checks:
|
||||
// - that every node starting with a GitHub prefix
|
||||
// - can be structured using fromRaw
|
||||
// - has the correct type
|
||||
// - that every edge starting with a GitHub prefix
|
||||
// - can be structured using fromRaw
|
||||
// - and has the correct type,
|
||||
// - and that its src has an expected prefix,
|
||||
// - and that its dst has an expected prefix,
|
||||
// - that every child node
|
||||
// - has exactly one parent
|
||||
// - has a parent with the correct type
|
||||
view.checkInvariants();
|
||||
// as currently written, GV checks invariants on construction.
|
||||
// we call the method explicitly as a defensive step.
|
||||
});
|
||||
});
|
|
@ -0,0 +1,291 @@
|
|||
// @flow
|
||||
|
||||
import stringify from "json-stable-stringify";
|
||||
import deepEqual from "lodash.isequal";
|
||||
|
||||
import * as GN from "./nodes";
|
||||
import * as GE from "./edges";
|
||||
|
||||
import {_Prefix as _GitPrefix} from "../git/nodes";
|
||||
|
||||
import {
|
||||
Graph,
|
||||
type NodeAddressT,
|
||||
Direction,
|
||||
type NeighborsOptions,
|
||||
NodeAddress,
|
||||
edgeToString,
|
||||
} from "../../core/graph";
|
||||
|
||||
export class GraphView {
|
||||
_graph: Graph;
|
||||
_isCheckingInvariants: boolean;
|
||||
|
||||
constructor(graph: Graph) {
|
||||
this._graph = graph;
|
||||
this._isCheckingInvariants = false;
|
||||
this._maybeCheckInvariants();
|
||||
}
|
||||
|
||||
graph(): Graph {
|
||||
this._maybeCheckInvariants();
|
||||
return this._graph;
|
||||
}
|
||||
|
||||
*_nodes<T: GN.StructuredAddress>(prefix: GN.RawAddress): Iterator<T> {
|
||||
for (const n of this._graph.nodes({prefix})) {
|
||||
const structured = GN.fromRaw((n: any));
|
||||
this._maybeCheckInvariants();
|
||||
yield (structured: any);
|
||||
}
|
||||
this._maybeCheckInvariants();
|
||||
}
|
||||
|
||||
*_neighbors<T: GN.StructuredAddress>(
|
||||
node: GN.StructuredAddress,
|
||||
options: NeighborsOptions
|
||||
): Iterator<T> {
|
||||
if (!NodeAddress.hasPrefix(options.nodePrefix, GN._Prefix.base)) {
|
||||
throw new Error(`_neighbors must filter to GitHub nodes`);
|
||||
}
|
||||
const rawNode = GN.toRaw(node);
|
||||
for (const neighbor of this._graph.neighbors(rawNode, options)) {
|
||||
this._maybeCheckInvariants();
|
||||
yield (GN.fromRaw((neighbor.node: any)): any);
|
||||
}
|
||||
this._maybeCheckInvariants();
|
||||
}
|
||||
|
||||
_children<T: GN.StructuredAddress>(
|
||||
node: GN.StructuredAddress,
|
||||
nodePrefix: GN.RawAddress
|
||||
): Iterator<T> {
|
||||
const options = {
|
||||
nodePrefix,
|
||||
edgePrefix: GE._Prefix.hasParent,
|
||||
direction: Direction.IN,
|
||||
};
|
||||
return this._neighbors(node, options);
|
||||
}
|
||||
|
||||
repos(): Iterator<GN.RepoAddress> {
|
||||
return this._nodes(GN._Prefix.repo);
|
||||
}
|
||||
|
||||
issues(repo: GN.RepoAddress): Iterator<GN.IssueAddress> {
|
||||
return this._children(repo, GN._Prefix.issue);
|
||||
}
|
||||
|
||||
pulls(repo: GN.RepoAddress): Iterator<GN.PullAddress> {
|
||||
return this._children(repo, GN._Prefix.pull);
|
||||
}
|
||||
|
||||
comments(commentable: GN.CommentableAddress): Iterator<GN.CommentAddress> {
|
||||
return this._children(commentable, GN._Prefix.comment);
|
||||
}
|
||||
|
||||
reviews(pull: GN.PullAddress): Iterator<GN.ReviewAddress> {
|
||||
return this._children(pull, GN._Prefix.review);
|
||||
}
|
||||
|
||||
// TODO(@wchrgin) figure out how to overload this fn signature
|
||||
parent(child: GN.ChildAddress): GN.ParentAddress {
|
||||
const options = {
|
||||
direction: Direction.OUT,
|
||||
edgePrefix: GE._Prefix.hasParent,
|
||||
nodePrefix: GN._Prefix.base,
|
||||
};
|
||||
const parents: GN.ParentAddress[] = Array.from(
|
||||
this._neighbors(child, options)
|
||||
);
|
||||
if (parents.length !== 1) {
|
||||
throw new Error(
|
||||
`Parent invariant violated for child: ${stringify(child)}`
|
||||
);
|
||||
}
|
||||
return parents[0];
|
||||
}
|
||||
|
||||
authors(content: GN.AuthorableAddress): Iterator<GN.UserlikeAddress> {
|
||||
const options = {
|
||||
direction: Direction.IN,
|
||||
edgePrefix: GE._Prefix.authors,
|
||||
nodePrefix: GN._Prefix.userlike,
|
||||
};
|
||||
return this._neighbors(content, options);
|
||||
}
|
||||
|
||||
_maybeCheckInvariants() {
|
||||
if (this._isCheckingInvariants) {
|
||||
return;
|
||||
}
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
// TODO(perf): If this method becomes really slow, we can disable
|
||||
// it on specific tests wherein we construct large graphs.
|
||||
this.checkInvariants();
|
||||
}
|
||||
}
|
||||
|
||||
checkInvariants() {
|
||||
this._isCheckingInvariants = true;
|
||||
try {
|
||||
this._checkInvariants();
|
||||
} finally {
|
||||
this._isCheckingInvariants = false;
|
||||
}
|
||||
}
|
||||
|
||||
_checkInvariants() {
|
||||
const nodeTypeToParentAccessor = {
|
||||
[GN.REPO_TYPE]: null,
|
||||
[GN.ISSUE_TYPE]: (x) => x.repo,
|
||||
[GN.PULL_TYPE]: (x) => x.repo,
|
||||
[GN.COMMENT_TYPE]: (x) => x.parent,
|
||||
[GN.REVIEW_TYPE]: (x) => x.pull,
|
||||
[GN.USERLIKE_TYPE]: null,
|
||||
};
|
||||
for (const node of this._graph.nodes({prefix: GN._Prefix.base})) {
|
||||
const structuredNode = GN.fromRaw((node: any));
|
||||
const type = structuredNode.type;
|
||||
const parentAccessor = nodeTypeToParentAccessor[type];
|
||||
if (parentAccessor != null) {
|
||||
// this.parent will throw error if there is not exactly 1 parent
|
||||
const parent = this.parent((structuredNode: any));
|
||||
const expectedParent = parentAccessor((structuredNode: any));
|
||||
if (!deepEqual(parent, expectedParent)) {
|
||||
throw new Error(`${stringify(structuredNode)} has the wrong parent`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Hom = {|
|
||||
+srcPrefix: NodeAddressT,
|
||||
+dstPrefix: NodeAddressT,
|
||||
|};
|
||||
function homProduct(
|
||||
srcPrefixes: GN.RawAddress[],
|
||||
dstPrefixes: GN.RawAddress[]
|
||||
): Hom[] {
|
||||
const result = [];
|
||||
for (const srcPrefix of srcPrefixes) {
|
||||
for (const dstPrefix of dstPrefixes) {
|
||||
result.push({srcPrefix, dstPrefix});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
type EdgeInvariant = {|
|
||||
+homs: Hom[],
|
||||
+srcAccessor?: (GE.StructuredAddress) => NodeAddressT,
|
||||
+dstAccessor?: (GE.StructuredAddress) => NodeAddressT,
|
||||
|};
|
||||
const edgeTypeToInvariants: {[type: string]: EdgeInvariant} = {
|
||||
[GE.HAS_PARENT_TYPE]: {
|
||||
homs: [
|
||||
{srcPrefix: GN._Prefix.issue, dstPrefix: GN._Prefix.repo},
|
||||
{srcPrefix: GN._Prefix.pull, dstPrefix: GN._Prefix.repo},
|
||||
{srcPrefix: GN._Prefix.review, dstPrefix: GN._Prefix.pull},
|
||||
{srcPrefix: GN._Prefix.reviewComment, dstPrefix: GN._Prefix.review},
|
||||
{srcPrefix: GN._Prefix.issueComment, dstPrefix: GN._Prefix.issue},
|
||||
{srcPrefix: GN._Prefix.pullComment, dstPrefix: GN._Prefix.pull},
|
||||
],
|
||||
srcAccessor: (x) => GN.toRaw((x: any).child),
|
||||
},
|
||||
[GE.MERGED_AS_TYPE]: {
|
||||
homs: [
|
||||
{
|
||||
srcPrefix: GN._Prefix.pull,
|
||||
dstPrefix: _GitPrefix.commit,
|
||||
},
|
||||
],
|
||||
srcAccessor: (x) => GN.toRaw((x: any).pull),
|
||||
},
|
||||
[GE.REFERENCES_TYPE]: {
|
||||
homs: homProduct(
|
||||
[
|
||||
GN._Prefix.issue,
|
||||
GN._Prefix.pull,
|
||||
GN._Prefix.review,
|
||||
GN._Prefix.comment,
|
||||
],
|
||||
[
|
||||
GN._Prefix.repo,
|
||||
GN._Prefix.issue,
|
||||
GN._Prefix.pull,
|
||||
GN._Prefix.review,
|
||||
GN._Prefix.comment,
|
||||
GN._Prefix.userlike,
|
||||
]
|
||||
),
|
||||
srcAccessor: (x) => GN.toRaw((x: any).referrer),
|
||||
dstAccessor: (x) => GN.toRaw((x: any).referent),
|
||||
},
|
||||
[GE.AUTHORS_TYPE]: {
|
||||
homs: homProduct(
|
||||
[GN._Prefix.userlike],
|
||||
[
|
||||
GN._Prefix.issue,
|
||||
GN._Prefix.review,
|
||||
GN._Prefix.pull,
|
||||
GN._Prefix.comment,
|
||||
]
|
||||
),
|
||||
srcAccessor: (x) => GN.toRaw((x: any).author),
|
||||
dstAccessor: (x) => GN.toRaw((x: any).content),
|
||||
},
|
||||
};
|
||||
|
||||
for (const edge of this._graph.edges({
|
||||
addressPrefix: GE._Prefix.base,
|
||||
srcPrefix: NodeAddress.empty,
|
||||
dstPrefix: NodeAddress.empty,
|
||||
})) {
|
||||
const address: GE.RawAddress = (edge.address: any);
|
||||
const structuredEdge = GE.fromRaw(address);
|
||||
const invariants = edgeTypeToInvariants[structuredEdge.type];
|
||||
if (invariants == null) {
|
||||
throw new Error(
|
||||
`Invariant: Unexpected edge type ${structuredEdge.type}`
|
||||
);
|
||||
}
|
||||
const {homs, srcAccessor, dstAccessor} = invariants;
|
||||
if (srcAccessor) {
|
||||
if (srcAccessor(structuredEdge) !== edge.src) {
|
||||
throw new Error(
|
||||
`Invariant: Expected src on edge ${edgeToString(
|
||||
edge
|
||||
)} to be ${srcAccessor(structuredEdge)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (dstAccessor) {
|
||||
if (dstAccessor(structuredEdge) !== edge.dst) {
|
||||
throw new Error(
|
||||
`Invariant: Expected dst on edge ${edgeToString(
|
||||
edge
|
||||
)} to be ${dstAccessor(structuredEdge)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
let foundHom = false;
|
||||
for (const {srcPrefix, dstPrefix} of homs) {
|
||||
if (
|
||||
NodeAddress.hasPrefix(edge.src, srcPrefix) &&
|
||||
NodeAddress.hasPrefix(edge.dst, dstPrefix)
|
||||
) {
|
||||
foundHom = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundHom) {
|
||||
throw new Error(
|
||||
`Invariant: Edge ${stringify(
|
||||
structuredEdge
|
||||
)} with edge ${edgeToString(
|
||||
edge
|
||||
)} did not satisfy src/dst prefix requirements`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,375 @@
|
|||
// @flow
|
||||
|
||||
import {Graph, type Edge, EdgeAddress} from "../../core/graph";
|
||||
import {createGraph} from "./createGraph";
|
||||
import {GraphView} from "./graphView";
|
||||
import * as GE from "./edges";
|
||||
import * as GN from "./nodes";
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
import {COMMIT_TYPE, toRaw as gitToRaw, TREE_TYPE} from "../git/nodes";
|
||||
|
||||
function exampleView() {
|
||||
const data = cloneDeep(require("./demoData/example-github"));
|
||||
const graph = createGraph(data);
|
||||
return new GraphView(graph);
|
||||
}
|
||||
|
||||
const decentralion = {type: "USERLIKE", login: "decentralion"};
|
||||
const wchargin = {type: "USERLIKE", login: "wchargin"};
|
||||
|
||||
describe("plugins/github/graphView", () => {
|
||||
const view = exampleView();
|
||||
const repos = Array.from(view.repos());
|
||||
it("has one repo", () => {
|
||||
expect(repos).toHaveLength(1);
|
||||
});
|
||||
const repo = repos[0];
|
||||
it("repo matches snapshot", () => {
|
||||
expect(repo).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("issues", () => {
|
||||
const issues = Array.from(view.issues(repo));
|
||||
it("number of issues matches snapshot", () => {
|
||||
expect(issues.length).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("/#2", () => {
|
||||
const issue = issues[1];
|
||||
it("matches snapshot", () => {
|
||||
expect(issue).toMatchSnapshot();
|
||||
});
|
||||
it("is issue #2", () => {
|
||||
expect(issue.number).toBe("2");
|
||||
});
|
||||
it("is authored by decentralion", () => {
|
||||
expect(Array.from(view.authors(issue))).toEqual([decentralion]);
|
||||
});
|
||||
it("has the right parent", () => {
|
||||
expect(view.parent(issue)).toEqual(repo);
|
||||
});
|
||||
const comments = Array.from(view.comments(issue));
|
||||
it("number of comments matches snapshot", () => {
|
||||
expect(comments.length).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("/comment #1", () => {
|
||||
const comment = comments[0];
|
||||
it("matches snapshot", () => {
|
||||
expect(comment).toMatchSnapshot();
|
||||
});
|
||||
it("has the right parent", () => {
|
||||
expect(view.parent(comment)).toEqual(issue);
|
||||
});
|
||||
it("is authored by decentralion", () => {
|
||||
expect(Array.from(view.authors(comment))).toEqual([decentralion]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pulls", () => {
|
||||
const pulls = Array.from(view.pulls(repo));
|
||||
it("number of pulls matches snapshot", () => {
|
||||
expect(pulls.length).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("/#5", () => {
|
||||
const pull = pulls[1];
|
||||
it("matches snapshot", () => {
|
||||
expect(pull).toMatchSnapshot();
|
||||
});
|
||||
it("is pull #5", () => {
|
||||
expect(pull.number).toBe("5");
|
||||
});
|
||||
it("is authored by decentralion", () => {
|
||||
expect(Array.from(view.authors(pull))).toEqual([decentralion]);
|
||||
});
|
||||
it("has the right parent", () => {
|
||||
expect(view.parent(pull)).toEqual(repo);
|
||||
});
|
||||
const comments = Array.from(view.comments(pull));
|
||||
it("number of comments matches snapshot", () => {
|
||||
expect(comments.length).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("/comment #1", () => {
|
||||
const comment = comments[0];
|
||||
it("matches snapshot", () => {
|
||||
expect(comment).toMatchSnapshot();
|
||||
});
|
||||
it("has the right parent", () => {
|
||||
expect(view.parent(comment)).toEqual(pull);
|
||||
});
|
||||
it("is authored by wchargin", () => {
|
||||
expect(Array.from(view.authors(comment))).toEqual([wchargin]);
|
||||
});
|
||||
});
|
||||
const reviews = Array.from(view.reviews(pull));
|
||||
it("number of reviews matches snapshot", () => {
|
||||
expect(reviews.length).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("/review #1", () => {
|
||||
const review = reviews[0];
|
||||
it("matches snapshot", () => {
|
||||
expect(review).toMatchSnapshot();
|
||||
});
|
||||
it("has the right parent", () => {
|
||||
expect(view.parent(review)).toEqual(pull);
|
||||
});
|
||||
it("is authored by wchargin", () => {
|
||||
expect(Array.from(view.authors(review))).toEqual([wchargin]);
|
||||
});
|
||||
const reviewComments = Array.from(view.comments(review));
|
||||
it("has the right number of review comments", () => {
|
||||
expect(reviewComments.length).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("/comment #1", () => {
|
||||
const reviewComment = reviewComments[0];
|
||||
it("is authored by wchargin", () => {
|
||||
expect(Array.from(view.authors(reviewComment))).toEqual([wchargin]);
|
||||
});
|
||||
it("matches snapshot", () => {
|
||||
expect(reviewComment).toMatchSnapshot();
|
||||
});
|
||||
it("has the right parent", () => {
|
||||
expect(view.parent(reviewComment)).toEqual(review);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("invariants", () => {
|
||||
const userlike: GN.UserlikeAddress = {
|
||||
type: "USERLIKE",
|
||||
login: "decentralion",
|
||||
};
|
||||
const repo: GN.RepoAddress = {
|
||||
type: "REPO",
|
||||
owner: "sourcecred",
|
||||
name: "example-github",
|
||||
};
|
||||
const issue: GN.IssueAddress = {type: "ISSUE", repo, number: "11"};
|
||||
const pull: GN.PullAddress = {type: "PULL", repo, number: "12"};
|
||||
const review: GN.ReviewAddress = {type: "REVIEW", pull, id: "foo"};
|
||||
const issueComment: GN.CommentAddress = {
|
||||
type: "COMMENT",
|
||||
parent: issue,
|
||||
id: "bar",
|
||||
};
|
||||
const pullComment: GN.CommentAddress = {
|
||||
type: "COMMENT",
|
||||
parent: pull,
|
||||
id: "bar1",
|
||||
};
|
||||
const reviewComment: GN.CommentAddress = {
|
||||
type: "COMMENT",
|
||||
parent: review,
|
||||
id: "bar2",
|
||||
};
|
||||
const nameToAddress = {
|
||||
userlike: userlike,
|
||||
repo: repo,
|
||||
issue: issue,
|
||||
pull: pull,
|
||||
review: review,
|
||||
issueComment: issueComment,
|
||||
pullComment: pullComment,
|
||||
reviewComment: reviewComment,
|
||||
};
|
||||
describe("there must be parents for", () => {
|
||||
function needParentFor(name: string) {
|
||||
it(name, () => {
|
||||
const g = new Graph();
|
||||
const example = nameToAddress[name];
|
||||
g.addNode(GN.toRaw(example));
|
||||
expect(() => new GraphView(g)).toThrow("Parent invariant");
|
||||
});
|
||||
}
|
||||
needParentFor("issue");
|
||||
needParentFor("pull");
|
||||
needParentFor("review");
|
||||
needParentFor("review");
|
||||
needParentFor("issueComment");
|
||||
needParentFor("pullComment");
|
||||
needParentFor("reviewComment");
|
||||
});
|
||||
|
||||
describe("edge invariants", () => {
|
||||
const exampleWithParents = () => {
|
||||
const g = new Graph()
|
||||
.addNode(GN.toRaw(repo))
|
||||
.addNode(GN.toRaw(issue))
|
||||
.addEdge(GE.createEdge.hasParent(issue, repo))
|
||||
.addNode(GN.toRaw(pull))
|
||||
.addEdge(GE.createEdge.hasParent(pull, repo))
|
||||
.addNode(GN.toRaw(review))
|
||||
.addEdge(GE.createEdge.hasParent(review, pull))
|
||||
.addNode(GN.toRaw(issueComment))
|
||||
.addEdge(GE.createEdge.hasParent(issueComment, issue))
|
||||
.addNode(GN.toRaw(pullComment))
|
||||
.addEdge(GE.createEdge.hasParent(pullComment, pull))
|
||||
.addNode(GN.toRaw(reviewComment))
|
||||
.addEdge(GE.createEdge.hasParent(reviewComment, review))
|
||||
.addNode(GN.toRaw(userlike));
|
||||
return g;
|
||||
};
|
||||
function failsForEdge(edge: Edge) {
|
||||
const g = exampleWithParents()
|
||||
.addNode(edge.src)
|
||||
.addNode(edge.dst)
|
||||
.addEdge(edge);
|
||||
expect(() => new GraphView(g)).toThrow("Invariant: Edge");
|
||||
}
|
||||
describe("authors edges", () => {
|
||||
it("src must be userlike", () => {
|
||||
// $ExpectFlowError
|
||||
const badEdge = GE.createEdge.authors(pull, issue);
|
||||
failsForEdge(badEdge);
|
||||
});
|
||||
it("dst must be authorable", () => {
|
||||
// $ExpectFlowError
|
||||
const badEdge = GE.createEdge.authors(userlike, repo);
|
||||
failsForEdge(badEdge);
|
||||
});
|
||||
it("src must be author in edge address", () => {
|
||||
const otherAuthor = {type: "USERLIKE", login: "wchargin"};
|
||||
const authorsEdge = GE.createEdge.authors(otherAuthor, issue);
|
||||
(authorsEdge: any).src = GN.toRaw(userlike);
|
||||
const g = exampleWithParents().addEdge(authorsEdge);
|
||||
expect(() => new GraphView(g)).toThrow("Invariant: Expected src");
|
||||
});
|
||||
it("dst must be content in edge address", () => {
|
||||
const authorsEdge = GE.createEdge.authors(userlike, issue);
|
||||
(authorsEdge: any).dst = GN.toRaw(pull);
|
||||
const g = exampleWithParents().addEdge(authorsEdge);
|
||||
expect(() => new GraphView(g)).toThrow("Invariant: Expected dst");
|
||||
});
|
||||
});
|
||||
describe("merged as edges", () => {
|
||||
const commit = {type: COMMIT_TYPE, hash: "hash"};
|
||||
it("src must be a pull", () => {
|
||||
// $ExpectFlowError
|
||||
const badEdge = GE.createEdge.mergedAs(issue, commit);
|
||||
failsForEdge(badEdge);
|
||||
});
|
||||
it("dst must be commit address", () => {
|
||||
const tree = {type: TREE_TYPE, hash: "hash"};
|
||||
// $ExpectFlowError
|
||||
const badEdge = GE.createEdge.mergedAs(pull, tree);
|
||||
failsForEdge(badEdge);
|
||||
});
|
||||
it("src must be pull in edge address", () => {
|
||||
const otherPull = {type: "PULL", repo, number: "143"};
|
||||
const mergedAs = GE.createEdge.mergedAs(otherPull, commit);
|
||||
(mergedAs: any).src = GN.toRaw(pull);
|
||||
const g = exampleWithParents()
|
||||
.addNode(gitToRaw(commit))
|
||||
.addEdge(mergedAs);
|
||||
expect(() => new GraphView(g)).toThrow("Invariant: Expected src");
|
||||
});
|
||||
});
|
||||
describe("references edges", () => {
|
||||
it("src must be a TextContentAddress", () => {
|
||||
// $ExpectFlowError
|
||||
const badEdge = GE.createEdge.references(userlike, pull);
|
||||
failsForEdge(badEdge);
|
||||
});
|
||||
it("src must be the referrer in edge address", () => {
|
||||
const references = GE.createEdge.references(issue, review);
|
||||
(references: any).src = GN.toRaw(pull);
|
||||
const g = exampleWithParents().addEdge(references);
|
||||
expect(() => new GraphView(g)).toThrow("Invariant: Expected src");
|
||||
});
|
||||
it("dst must be referent in edge address", () => {
|
||||
const references = GE.createEdge.references(issue, review);
|
||||
(references: any).dst = GN.toRaw(pull);
|
||||
const g = exampleWithParents().addEdge(references);
|
||||
expect(() => new GraphView(g)).toThrow("Invariant: Expected dst");
|
||||
});
|
||||
});
|
||||
describe("has parent edges", () => {
|
||||
it("must satisfy specific hom relationships", () => {
|
||||
const g = new Graph()
|
||||
.addNode(GN.toRaw(repo))
|
||||
// $ExpectFlowError
|
||||
.addEdge(GE.createEdge.hasParent(repo, repo));
|
||||
expect(() => new GraphView(g)).toThrow("Invariant: Edge");
|
||||
});
|
||||
it("must be unique", () => {
|
||||
const otherRepo = {type: "REPO", owner: "foo", name: "bar"};
|
||||
const g = exampleWithParents();
|
||||
g.addNode(GN.toRaw(otherRepo));
|
||||
const otherParent = {
|
||||
src: GN.toRaw(issue),
|
||||
dst: GN.toRaw(otherRepo),
|
||||
address: EdgeAddress.append(GE._Prefix.hasParent, "foobar"),
|
||||
};
|
||||
g.addEdge(otherParent);
|
||||
expect(() => new GraphView(g)).toThrow("Parent invariant");
|
||||
});
|
||||
it("must match the parent specified in the node address", () => {
|
||||
const otherRepo = {type: "REPO", owner: "foo", name: "bar"};
|
||||
const otherParent = GE.createEdge.hasParent(issue, otherRepo);
|
||||
const g = new Graph()
|
||||
.addNode(GN.toRaw(issue))
|
||||
.addNode(GN.toRaw(otherRepo))
|
||||
.addEdge(otherParent);
|
||||
expect(() => new GraphView(g)).toThrow("has the wrong parent");
|
||||
});
|
||||
it("must match child specified in the edge address", () => {
|
||||
const parent = GE.createEdge.hasParent(issue, repo);
|
||||
(parent: any).src = GN.toRaw(pull);
|
||||
const g = new Graph()
|
||||
.addNode(GN.toRaw(pull))
|
||||
.addNode(GN.toRaw(repo))
|
||||
.addEdge(parent);
|
||||
expect(() => new GraphView(g)).toThrow("Invariant: Expected src");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("are properly re-entrant", () => {
|
||||
const g = new Graph();
|
||||
const view = new GraphView(g);
|
||||
// no error, empty graph is fine
|
||||
view.graph();
|
||||
// introduce an invariant violation (no parent)
|
||||
g.addNode(GN.toRaw(issue));
|
||||
try {
|
||||
view.graph();
|
||||
} catch (_) {}
|
||||
expect(() => view.graph()).toThrow("invariant violated");
|
||||
});
|
||||
|
||||
describe("are checked on every public method", () => {
|
||||
const badView = () => {
|
||||
const g = new Graph();
|
||||
const view = new GraphView(g);
|
||||
g.addNode(GN.toRaw(issue));
|
||||
g.addNode(GN.toRaw(pull));
|
||||
g.addNode(GN.toRaw(repo));
|
||||
return view;
|
||||
};
|
||||
const methods = {
|
||||
graph: () => badView().graph(),
|
||||
repos: () => Array.from(badView().repos()),
|
||||
issues: () => Array.from(badView().issues(repo)),
|
||||
pulls: () => Array.from(badView().pulls(repo)),
|
||||
comments: () => Array.from(badView().comments(issue)),
|
||||
reviews: () => Array.from(badView().reviews(pull)),
|
||||
parent: () => badView().parent(pull),
|
||||
authors: () => Array.from(badView().authors(pull)),
|
||||
};
|
||||
|
||||
for (const name of Object.keys(methods)) {
|
||||
it(`including ${name}`, () => {
|
||||
const method = methods[name];
|
||||
expect(() => method()).toThrow("invariant");
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue