From a209caeec21e513d7f2cdd9b893cac2f25241c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dandelion=20Man=C3=A9?= Date: Fri, 22 Jun 2018 13:10:19 -0700 Subject: [PATCH] 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. --- .../__snapshots__/createGraph.test.js.snap | 1143 +++++++++++++++++ .../__snapshots__/graphView.test.js.snap | 113 ++ src/v3/plugins/github/createGraph.js | 161 +++ src/v3/plugins/github/createGraph.test.js | 36 + src/v3/plugins/github/graphView.js | 291 +++++ src/v3/plugins/github/graphView.test.js | 375 ++++++ 6 files changed, 2119 insertions(+) create mode 100644 src/v3/plugins/github/__snapshots__/createGraph.test.js.snap create mode 100644 src/v3/plugins/github/__snapshots__/graphView.test.js.snap create mode 100644 src/v3/plugins/github/createGraph.js create mode 100644 src/v3/plugins/github/createGraph.test.js create mode 100644 src/v3/plugins/github/graphView.js create mode 100644 src/v3/plugins/github/graphView.test.js diff --git a/src/v3/plugins/github/__snapshots__/createGraph.test.js.snap b/src/v3/plugins/github/__snapshots__/createGraph.test.js.snap new file mode 100644 index 0000000..71e982e --- /dev/null +++ b/src/v3/plugins/github/__snapshots__/createGraph.test.js.snap @@ -0,0 +1,1143 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`plugins/github/createGraph example graph matches snapshot 1`] = ` +Array [ + Object { + "type": "sourcecred/graph", + "version": "0.4.0", + }, + Object { + "edges": Array [ + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "4", + "ISSUE", + "sourcecred", + "example-github", + "1", + ], + "dstIndex": 16, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "4", + "ISSUE", + "sourcecred", + "example-github", + "2", + ], + "dstIndex": 17, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "4", + "ISSUE", + "sourcecred", + "example-github", + "4", + ], + "dstIndex": 18, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "4", + "ISSUE", + "sourcecred", + "example-github", + "6", + ], + "dstIndex": 19, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "4", + "ISSUE", + "sourcecred", + "example-github", + "7", + ], + "dstIndex": 20, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "4", + "ISSUE", + "sourcecred", + "example-github", + "8", + ], + "dstIndex": 21, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "4", + "PULL", + "sourcecred", + "example-github", + "3", + ], + "dstIndex": 22, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "4", + "PULL", + "sourcecred", + "example-github", + "5", + ], + "dstIndex": 23, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "4", + "PULL", + "sourcecred", + "example-github", + "9", + ], + "dstIndex": 24, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "373768703", + ], + "dstIndex": 2, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "373768850", + ], + "dstIndex": 3, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576185", + ], + "dstIndex": 4, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576220", + ], + "dstIndex": 5, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576248", + ], + "dstIndex": 6, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576273", + ], + "dstIndex": 7, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576920", + ], + "dstIndex": 8, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576936", + ], + "dstIndex": 9, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "6", + "373768442", + ], + "dstIndex": 10, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "6", + "373768538", + ], + "dstIndex": 11, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "6", + "385223316", + ], + "dstIndex": 12, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "decentralion", + "6", + "COMMENT", + "PULL", + "sourcecred", + "example-github", + "3", + "369162222", + ], + "dstIndex": 13, + "srcIndex": 28, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "wchargin", + "5", + "REVIEW", + "sourcecred", + "example-github", + "5", + "100313899", + ], + "dstIndex": 26, + "srcIndex": 29, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "wchargin", + "5", + "REVIEW", + "sourcecred", + "example-github", + "5", + "100314038", + ], + "dstIndex": 27, + "srcIndex": 29, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "wchargin", + "6", + "COMMENT", + "PULL", + "sourcecred", + "example-github", + "5", + "396430464", + ], + "dstIndex": 14, + "srcIndex": 29, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "AUTHORS", + "2", + "USERLIKE", + "wchargin", + "7", + "COMMENT", + "REVIEW", + "sourcecred", + "example-github", + "5", + "100313899", + "171460198", + ], + "dstIndex": 15, + "srcIndex": 29, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "4", + "ISSUE", + "sourcecred", + "example-github", + "1", + ], + "dstIndex": 25, + "srcIndex": 16, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "4", + "ISSUE", + "sourcecred", + "example-github", + "2", + ], + "dstIndex": 25, + "srcIndex": 17, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "4", + "ISSUE", + "sourcecred", + "example-github", + "4", + ], + "dstIndex": 25, + "srcIndex": 18, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "4", + "ISSUE", + "sourcecred", + "example-github", + "6", + ], + "dstIndex": 25, + "srcIndex": 19, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "4", + "ISSUE", + "sourcecred", + "example-github", + "7", + ], + "dstIndex": 25, + "srcIndex": 20, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "4", + "ISSUE", + "sourcecred", + "example-github", + "8", + ], + "dstIndex": 25, + "srcIndex": 21, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "4", + "PULL", + "sourcecred", + "example-github", + "3", + ], + "dstIndex": 25, + "srcIndex": 22, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "4", + "PULL", + "sourcecred", + "example-github", + "5", + ], + "dstIndex": 25, + "srcIndex": 23, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "4", + "PULL", + "sourcecred", + "example-github", + "9", + ], + "dstIndex": 25, + "srcIndex": 24, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "5", + "REVIEW", + "sourcecred", + "example-github", + "5", + "100313899", + ], + "dstIndex": 23, + "srcIndex": 26, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "5", + "REVIEW", + "sourcecred", + "example-github", + "5", + "100314038", + ], + "dstIndex": 23, + "srcIndex": 27, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "373768703", + ], + "dstIndex": 17, + "srcIndex": 2, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "373768850", + ], + "dstIndex": 17, + "srcIndex": 3, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576185", + ], + "dstIndex": 17, + "srcIndex": 4, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576220", + ], + "dstIndex": 17, + "srcIndex": 5, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576248", + ], + "dstIndex": 17, + "srcIndex": 6, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576273", + ], + "dstIndex": 17, + "srcIndex": 7, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576920", + ], + "dstIndex": 17, + "srcIndex": 8, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576936", + ], + "dstIndex": 17, + "srcIndex": 9, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "6", + "373768442", + ], + "dstIndex": 19, + "srcIndex": 10, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "6", + "373768538", + ], + "dstIndex": 19, + "srcIndex": 11, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "6", + "385223316", + ], + "dstIndex": 19, + "srcIndex": 12, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "PULL", + "sourcecred", + "example-github", + "3", + "369162222", + ], + "dstIndex": 22, + "srcIndex": 13, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "6", + "COMMENT", + "PULL", + "sourcecred", + "example-github", + "5", + "396430464", + ], + "dstIndex": 23, + "srcIndex": 14, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "HAS_PARENT", + "7", + "COMMENT", + "REVIEW", + "sourcecred", + "example-github", + "5", + "100313899", + "171460198", + ], + "dstIndex": 26, + "srcIndex": 15, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "MERGED_AS", + "4", + "PULL", + "sourcecred", + "example-github", + "3", + ], + "dstIndex": 0, + "srcIndex": 22, + }, + Object { + "address": Array [ + "sourcecred", + "github", + "MERGED_AS", + "4", + "PULL", + "sourcecred", + "example-github", + "5", + ], + "dstIndex": 1, + "srcIndex": 23, + }, + ], + "nodes": Array [ + Array [ + "sourcecred", + "git", + "COMMIT", + "0a223346b4e6dec0127b1e6aa892c4ee0424b66a", + ], + Array [ + "sourcecred", + "git", + "COMMIT", + "6d5b3aa31ebb68a06ceb46bbd6cf49b6ccd6f5e6", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "373768703", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "373768850", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576185", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576220", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576248", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576273", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576920", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "2", + "385576936", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "6", + "373768442", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "6", + "373768538", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "ISSUE", + "sourcecred", + "example-github", + "6", + "385223316", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "PULL", + "sourcecred", + "example-github", + "3", + "369162222", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "PULL", + "sourcecred", + "example-github", + "5", + "396430464", + ], + Array [ + "sourcecred", + "github", + "COMMENT", + "REVIEW", + "sourcecred", + "example-github", + "5", + "100313899", + "171460198", + ], + Array [ + "sourcecred", + "github", + "ISSUE", + "sourcecred", + "example-github", + "1", + ], + Array [ + "sourcecred", + "github", + "ISSUE", + "sourcecred", + "example-github", + "2", + ], + Array [ + "sourcecred", + "github", + "ISSUE", + "sourcecred", + "example-github", + "4", + ], + Array [ + "sourcecred", + "github", + "ISSUE", + "sourcecred", + "example-github", + "6", + ], + Array [ + "sourcecred", + "github", + "ISSUE", + "sourcecred", + "example-github", + "7", + ], + Array [ + "sourcecred", + "github", + "ISSUE", + "sourcecred", + "example-github", + "8", + ], + Array [ + "sourcecred", + "github", + "PULL", + "sourcecred", + "example-github", + "3", + ], + Array [ + "sourcecred", + "github", + "PULL", + "sourcecred", + "example-github", + "5", + ], + Array [ + "sourcecred", + "github", + "PULL", + "sourcecred", + "example-github", + "9", + ], + Array [ + "sourcecred", + "github", + "REPO", + "sourcecred", + "example-github", + ], + Array [ + "sourcecred", + "github", + "REVIEW", + "sourcecred", + "example-github", + "5", + "100313899", + ], + Array [ + "sourcecred", + "github", + "REVIEW", + "sourcecred", + "example-github", + "5", + "100314038", + ], + Array [ + "sourcecred", + "github", + "USERLIKE", + "decentralion", + ], + Array [ + "sourcecred", + "github", + "USERLIKE", + "wchargin", + ], + ], + }, +] +`; diff --git a/src/v3/plugins/github/__snapshots__/graphView.test.js.snap b/src/v3/plugins/github/__snapshots__/graphView.test.js.snap new file mode 100644 index 0000000..4f847c8 --- /dev/null +++ b/src/v3/plugins/github/__snapshots__/graphView.test.js.snap @@ -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", +} +`; diff --git a/src/v3/plugins/github/createGraph.js b/src/v3/plugins/github/createGraph.js new file mode 100644 index 0000000..3efa2ab --- /dev/null +++ b/src/v3/plugins/github/createGraph.js @@ -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)); + } +} diff --git a/src/v3/plugins/github/createGraph.test.js b/src/v3/plugins/github/createGraph.test.js new file mode 100644 index 0000000..685b426 --- /dev/null +++ b/src/v3/plugins/github/createGraph.test.js @@ -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. + }); +}); diff --git a/src/v3/plugins/github/graphView.js b/src/v3/plugins/github/graphView.js new file mode 100644 index 0000000..2ed6e5e --- /dev/null +++ b/src/v3/plugins/github/graphView.js @@ -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(prefix: GN.RawAddress): Iterator { + for (const n of this._graph.nodes({prefix})) { + const structured = GN.fromRaw((n: any)); + this._maybeCheckInvariants(); + yield (structured: any); + } + this._maybeCheckInvariants(); + } + + *_neighbors( + node: GN.StructuredAddress, + options: NeighborsOptions + ): Iterator { + 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( + node: GN.StructuredAddress, + nodePrefix: GN.RawAddress + ): Iterator { + const options = { + nodePrefix, + edgePrefix: GE._Prefix.hasParent, + direction: Direction.IN, + }; + return this._neighbors(node, options); + } + + repos(): Iterator { + return this._nodes(GN._Prefix.repo); + } + + issues(repo: GN.RepoAddress): Iterator { + return this._children(repo, GN._Prefix.issue); + } + + pulls(repo: GN.RepoAddress): Iterator { + return this._children(repo, GN._Prefix.pull); + } + + comments(commentable: GN.CommentableAddress): Iterator { + return this._children(commentable, GN._Prefix.comment); + } + + reviews(pull: GN.PullAddress): Iterator { + 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 { + 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` + ); + } + } + } +} diff --git a/src/v3/plugins/github/graphView.test.js b/src/v3/plugins/github/graphView.test.js new file mode 100644 index 0000000..1abc115 --- /dev/null +++ b/src/v3/plugins/github/graphView.test.js @@ -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"); + }); + } + }); + }); +});