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:
Dandelion Mané 2018-06-22 13:10:19 -07:00 committed by GitHub
parent 24a7547e16
commit a209caeec2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 2119 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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",
}
`;

View File

@ -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));
}
}

View File

@ -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.
});
});

View File

@ -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`
);
}
}
}
}

View File

@ -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");
});
}
});
});
});