From 6235febdac242a8b450b7ea41e1f833575e55253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dandelion=20Man=C3=A9?= Date: Wed, 27 Jun 2018 15:25:20 -0700 Subject: [PATCH] Add porcelain-style classes to `RelationalView` (#424) Based on offline design discussion with @wchargin, we've decided to upgrade the `RelationalView` to be *the* comprehensive source for GitHub data inside SourceCred. The `RelationalView` will contain the full dataset, including parsed relational information (such as cross-references between GitHub entities). Then, we will project our GitHub graph out of the `RelationalView`. To that end, the `RelationalView` no longer exports raw data blobs. Instead, it exports nice classes: `Repo`, `Issue`, `Pull`, `Review`, and `Userlike`. These classes have convenient methods for accessing both their own data and related entities, e.g. `repo.issues()` yields all the issues in that repo. This is effectively a port of #170 into the v3 API. The main difference is that in v1, the Graph contained this data store, whereas in v3, we will use this data store to generate the graph. This supersedes #418. Test plan: The snapshot tests are quite readable. --- .../__snapshots__/relationalView.test.js.snap | 587 +++++++----------- src/v3/plugins/github/relationalView.js | 401 +++++++++--- src/v3/plugins/github/relationalView.test.js | 172 +++-- 3 files changed, 681 insertions(+), 479 deletions(-) diff --git a/src/v3/plugins/github/__snapshots__/relationalView.test.js.snap b/src/v3/plugins/github/__snapshots__/relationalView.test.js.snap index fb9ade8..226f1fa 100644 --- a/src/v3/plugins/github/__snapshots__/relationalView.test.js.snap +++ b/src/v3/plugins/github/__snapshots__/relationalView.test.js.snap @@ -1,374 +1,239 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`plugins/github/relationalView comment matches snapshot 1`] = ` -Object { - "address": Object { - "id": "373768703", - "parent": Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - "type": "COMMENT", - }, - "body": "It should also be possible to reference by exact url: https://github.com/sourcecred/example-github/issues/6", - "nominalAuthor": Object { - "login": "decentralion", - "type": "USERLIKE", - }, - "url": "https://github.com/sourcecred/example-github/issues/2#issuecomment-373768703", -} +exports[`plugins/github/relationalView Comment authors has expected number of authors 1`] = `1`; + +exports[`plugins/github/relationalView Comment authors have expected urls 1`] = ` +Array [ + "https://github.com/wchargin", +] `; -exports[`plugins/github/relationalView issue matches snapshot 1`] = ` -Object { - "address": Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - "body": "This issue references another issue, namely #1", - "comments": Array [ - Object { - "id": "373768703", - "parent": Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - "type": "COMMENT", - }, - Object { - "id": "373768850", - "parent": Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - "type": "COMMENT", - }, - Object { - "id": "385576185", - "parent": Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - "type": "COMMENT", - }, - Object { - "id": "385576220", - "parent": Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - "type": "COMMENT", - }, - Object { - "id": "385576248", - "parent": Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - "type": "COMMENT", - }, - Object { - "id": "385576273", - "parent": Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - "type": "COMMENT", - }, - Object { - "id": "385576920", - "parent": Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - "type": "COMMENT", - }, - Object { - "id": "385576936", - "parent": Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - "type": "COMMENT", - }, - ], - "nominalAuthor": Object { - "login": "decentralion", - "type": "USERLIKE", - }, - "title": "A referencing issue.", - "url": "https://github.com/sourcecred/example-github/issues/2", -} -`; +exports[`plugins/github/relationalView Comment has body 1`] = `"seems a bit capricious"`; -exports[`plugins/github/relationalView pull matches snapshot 1`] = ` +exports[`plugins/github/relationalView Comment has parent 1`] = ` Object { - "address": Object { - "number": "5", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "PULL", - }, - "body": "@wchargin could you please do the following: -- add a commit comment -- add a review comment requesting some trivial change -- i'll change it -- then approve the pr", - "comments": Array [ - Object { - "id": "396430464", - "parent": Object { - "number": "5", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "PULL", - }, - "type": "COMMENT", - }, - ], - "mergedAs": Object { - "hash": "6d5b3aa31ebb68a06ceb46bbd6cf49b6ccd6f5e6", - "type": "COMMIT", - }, - "nominalAuthor": Object { - "login": "decentralion", - "type": "USERLIKE", - }, - "reviews": Array [ - Object { - "id": "100313899", - "pull": Object { - "number": "5", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "PULL", - }, - "type": "REVIEW", - }, - Object { - "id": "100314038", - "pull": Object { - "number": "5", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "PULL", - }, - "type": "REVIEW", - }, - ], - "title": "This pull request will be more contentious. I can feel it...", - "url": "https://github.com/sourcecred/example-github/pull/5", -} -`; - -exports[`plugins/github/relationalView repo matches snapshot 1`] = ` -Object { - "address": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "issues": Array [ - Object { - "number": "1", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - Object { - "number": "2", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - Object { - "number": "4", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - Object { - "number": "6", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - Object { - "number": "7", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - Object { - "number": "8", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "ISSUE", - }, - ], - "pulls": Array [ - Object { - "number": "3", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "PULL", - }, - Object { - "number": "5", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "PULL", - }, - Object { - "number": "9", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "PULL", - }, - ], - "url": "https://github.com/sourcecred/example-github", -} -`; - -exports[`plugins/github/relationalView review matches snapshot 1`] = ` -Object { - "address": Object { - "id": "100313899", - "pull": Object { - "number": "5", - "repo": Object { - "name": "example-github", - "owner": "sourcecred", - "type": "REPO", - }, - "type": "PULL", - }, - "type": "REVIEW", - }, - "body": "hmmm.jpg", - "comments": Array [ - 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", - }, - ], - "nominalAuthor": Object { - "login": "wchargin", - "type": "USERLIKE", - }, - "state": "CHANGES_REQUESTED", "url": "https://github.com/sourcecred/example-github/pull/5#pullrequestreview-100313899", } `; -exports[`plugins/github/relationalView userlike matches snapshot 1`] = ` +exports[`plugins/github/relationalView Comment has url 1`] = `"https://github.com/sourcecred/example-github/pull/5#discussion_r171460198"`; + +exports[`plugins/github/relationalView Issue authors has expected number of authors 1`] = `1`; + +exports[`plugins/github/relationalView Issue authors have expected urls 1`] = ` +Array [ + "https://github.com/decentralion", +] +`; + +exports[`plugins/github/relationalView Issue comments has expected number of comments 1`] = `8`; + +exports[`plugins/github/relationalView Issue comments have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github/issues/2#issuecomment-373768703", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-373768850", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576185", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576220", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576248", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576273", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576920", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576936", +] +`; + +exports[`plugins/github/relationalView Issue has body 1`] = `"This issue references another issue, namely #1"`; + +exports[`plugins/github/relationalView Issue has number 1`] = `"2"`; + +exports[`plugins/github/relationalView Issue has parent 1`] = ` Object { - "address": Object { - "login": "decentralion", - "type": "USERLIKE", - }, - "url": "https://github.com/decentralion", + "url": "https://github.com/sourcecred/example-github", } `; + +exports[`plugins/github/relationalView Issue has title 1`] = `"A referencing issue."`; + +exports[`plugins/github/relationalView Issue has url 1`] = `"https://github.com/sourcecred/example-github/issues/2"`; + +exports[`plugins/github/relationalView Pull authors has expected number of authors 1`] = `1`; + +exports[`plugins/github/relationalView Pull authors have expected urls 1`] = ` +Array [ + "https://github.com/decentralion", +] +`; + +exports[`plugins/github/relationalView Pull comments has expected number of comments 1`] = `1`; + +exports[`plugins/github/relationalView Pull comments have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github/pull/5#issuecomment-396430464", +] +`; + +exports[`plugins/github/relationalView Pull has body 1`] = ` +"@wchargin could you please do the following: +- add a commit comment +- add a review comment requesting some trivial change +- i'll change it +- then approve the pr" +`; + +exports[`plugins/github/relationalView Pull has mergedAs 1`] = ` +Object { + "hash": "6d5b3aa31ebb68a06ceb46bbd6cf49b6ccd6f5e6", + "type": "COMMIT", +} +`; + +exports[`plugins/github/relationalView Pull has number 1`] = `"5"`; + +exports[`plugins/github/relationalView Pull has parent 1`] = ` +Object { + "url": "https://github.com/sourcecred/example-github", +} +`; + +exports[`plugins/github/relationalView Pull has title 1`] = `"This pull request will be more contentious. I can feel it..."`; + +exports[`plugins/github/relationalView Pull has url 1`] = `"https://github.com/sourcecred/example-github/pull/5"`; + +exports[`plugins/github/relationalView Pull reviews has expected number of reviews 1`] = `2`; + +exports[`plugins/github/relationalView Pull reviews have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github/pull/5#pullrequestreview-100313899", + "https://github.com/sourcecred/example-github/pull/5#pullrequestreview-100314038", +] +`; + +exports[`plugins/github/relationalView RelationalView entity: comments has expected number of them 1`] = `14`; + +exports[`plugins/github/relationalView RelationalView entity: comments they have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github/issues/2#issuecomment-373768703", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-373768850", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576185", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576220", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576248", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576273", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576920", + "https://github.com/sourcecred/example-github/issues/2#issuecomment-385576936", + "https://github.com/sourcecred/example-github/issues/6#issuecomment-373768442", + "https://github.com/sourcecred/example-github/issues/6#issuecomment-373768538", + "https://github.com/sourcecred/example-github/issues/6#issuecomment-385223316", + "https://github.com/sourcecred/example-github/pull/3#issuecomment-369162222", + "https://github.com/sourcecred/example-github/pull/5#issuecomment-396430464", + "https://github.com/sourcecred/example-github/pull/5#discussion_r171460198", +] +`; + +exports[`plugins/github/relationalView RelationalView entity: issues has expected number of them 1`] = `6`; + +exports[`plugins/github/relationalView RelationalView entity: issues they have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github/issues/1", + "https://github.com/sourcecred/example-github/issues/2", + "https://github.com/sourcecred/example-github/issues/4", + "https://github.com/sourcecred/example-github/issues/6", + "https://github.com/sourcecred/example-github/issues/7", + "https://github.com/sourcecred/example-github/issues/8", +] +`; + +exports[`plugins/github/relationalView RelationalView entity: pulls has expected number of them 1`] = `3`; + +exports[`plugins/github/relationalView RelationalView entity: pulls they have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github/pull/3", + "https://github.com/sourcecred/example-github/pull/5", + "https://github.com/sourcecred/example-github/pull/9", +] +`; + +exports[`plugins/github/relationalView RelationalView entity: repos has expected number of them 1`] = `1`; + +exports[`plugins/github/relationalView RelationalView entity: repos they have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github", +] +`; + +exports[`plugins/github/relationalView RelationalView entity: reviews has expected number of them 1`] = `2`; + +exports[`plugins/github/relationalView RelationalView entity: reviews they have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github/pull/5#pullrequestreview-100313899", + "https://github.com/sourcecred/example-github/pull/5#pullrequestreview-100314038", +] +`; + +exports[`plugins/github/relationalView RelationalView entity: userlikes has expected number of them 1`] = `2`; + +exports[`plugins/github/relationalView RelationalView entity: userlikes they have expected urls 1`] = ` +Array [ + "https://github.com/decentralion", + "https://github.com/wchargin", +] +`; + +exports[`plugins/github/relationalView Repo has name 1`] = `"example-github"`; + +exports[`plugins/github/relationalView Repo has owner 1`] = `"sourcecred"`; + +exports[`plugins/github/relationalView Repo has url 1`] = `"https://github.com/sourcecred/example-github"`; + +exports[`plugins/github/relationalView Repo issues has expected number of issues 1`] = `6`; + +exports[`plugins/github/relationalView Repo issues have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github/issues/1", + "https://github.com/sourcecred/example-github/issues/2", + "https://github.com/sourcecred/example-github/issues/4", + "https://github.com/sourcecred/example-github/issues/6", + "https://github.com/sourcecred/example-github/issues/7", + "https://github.com/sourcecred/example-github/issues/8", +] +`; + +exports[`plugins/github/relationalView Repo pulls has expected number of pulls 1`] = `3`; + +exports[`plugins/github/relationalView Repo pulls have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github/pull/3", + "https://github.com/sourcecred/example-github/pull/5", + "https://github.com/sourcecred/example-github/pull/9", +] +`; + +exports[`plugins/github/relationalView Review authors has expected number of authors 1`] = `1`; + +exports[`plugins/github/relationalView Review authors have expected urls 1`] = ` +Array [ + "https://github.com/wchargin", +] +`; + +exports[`plugins/github/relationalView Review comments has expected number of comments 1`] = `1`; + +exports[`plugins/github/relationalView Review comments have expected urls 1`] = ` +Array [ + "https://github.com/sourcecred/example-github/pull/5#discussion_r171460198", +] +`; + +exports[`plugins/github/relationalView Review has body 1`] = `"hmmm.jpg"`; + +exports[`plugins/github/relationalView Review has parent 1`] = ` +Object { + "url": "https://github.com/sourcecred/example-github/pull/5", +} +`; + +exports[`plugins/github/relationalView Review has state 1`] = `"CHANGES_REQUESTED"`; + +exports[`plugins/github/relationalView Review has url 1`] = `"https://github.com/sourcecred/example-github/pull/5#pullrequestreview-100313899"`; + +exports[`plugins/github/relationalView Userlike has login 1`] = `"wchargin"`; + +exports[`plugins/github/relationalView Userlike has url 1`] = `"https://github.com/wchargin"`; diff --git a/src/v3/plugins/github/relationalView.js b/src/v3/plugins/github/relationalView.js index 5d67ad6..a01a7f7 100644 --- a/src/v3/plugins/github/relationalView.js +++ b/src/v3/plugins/github/relationalView.js @@ -1,6 +1,16 @@ // @flow +import stringify from "json-stable-stringify"; import * as N from "./nodes"; +// Workaround for https://github.com/facebook/flow/issues/6538 +import type { + RepoAddress, + IssueAddress, + PullAddress, + ReviewAddress, + CommentAddress, + UserlikeAddress, +} from "./nodes"; import * as Q from "./graphql"; import * as GitNode from "../git/nodes"; @@ -11,54 +21,6 @@ import { reviewCommentUrlToId, } from "./urlIdParse"; -export type RepoEntry = {| - +address: N.RepoAddress, - +url: string, - +issues: N.IssueAddress[], - +pulls: N.PullAddress[], -|}; - -export type IssueEntry = {| - +address: N.IssueAddress, - +title: string, - +body: string, - +url: string, - +comments: N.CommentAddress[], - +nominalAuthor: ?N.UserlikeAddress, -|}; - -export type PullEntry = {| - +address: N.PullAddress, - +title: string, - +body: string, - +url: string, - +comments: N.CommentAddress[], - +reviews: N.ReviewAddress[], - +mergedAs: ?GitNode.CommitAddress, - +nominalAuthor: ?N.UserlikeAddress, -|}; - -export type ReviewEntry = {| - +address: N.ReviewAddress, - +body: string, - +url: string, - +comments: N.CommentAddress[], - +state: Q.ReviewState, - +nominalAuthor: ?N.UserlikeAddress, -|}; - -export type CommentEntry = {| - +address: N.CommentAddress, - +body: string, - +url: string, - +nominalAuthor: ?N.UserlikeAddress, -|}; - -export type UserlikeEntry = {| - +address: N.UserlikeAddress, - +url: string, -|}; - export class RelationalView { _repos: Map; _issues: Map; @@ -77,32 +39,74 @@ export class RelationalView { this._addRepo(data.repository); } - repos(): Iterator { - return this._repos.values(); + *repos(): Iterator { + for (const entry of this._repos.values()) { + yield new Repo(this, entry); + } } - repo(address: N.RepoAddress): ?RepoEntry { - return this._repos.get(N.toRaw(address)); + repo(address: RepoAddress): ?Repo { + const entry = this._repos.get(N.toRaw(address)); + return entry == null ? entry : new Repo(this, entry); } - issue(address: N.IssueAddress): ?IssueEntry { - return this._issues.get(N.toRaw(address)); + *issues(): Iterator { + for (const entry of this._issues.values()) { + yield new Issue(this, entry); + } } - pull(address: N.PullAddress): ?PullEntry { - return this._pulls.get(N.toRaw(address)); + + issue(address: IssueAddress): ?Issue { + const entry = this._issues.get(N.toRaw(address)); + return entry == null ? entry : new Issue(this, entry); } - comment(address: N.CommentAddress): ?CommentEntry { - return this._comments.get(N.toRaw(address)); + + *pulls(): Iterator { + for (const entry of this._pulls.values()) { + yield new Pull(this, entry); + } } - review(address: N.ReviewAddress): ?ReviewEntry { - return this._reviews.get(N.toRaw(address)); + + pull(address: PullAddress): ?Pull { + const entry = this._pulls.get(N.toRaw(address)); + return entry == null ? entry : new Pull(this, entry); } - userlike(address: N.UserlikeAddress): ?UserlikeEntry { - return this._userlikes.get(N.toRaw(address)); + + *comments(): Iterator { + for (const entry of this._comments.values()) { + yield new Comment(this, entry); + } + } + + comment(address: CommentAddress): ?Comment { + const entry = this._comments.get(N.toRaw(address)); + return entry == null ? entry : new Comment(this, entry); + } + + *reviews(): Iterator { + for (const entry of this._reviews.values()) { + yield new Review(this, entry); + } + } + + review(address: ReviewAddress): ?Review { + const entry = this._reviews.get(N.toRaw(address)); + return entry == null ? entry : new Review(this, entry); + } + + *userlikes(): Iterator { + for (const entry of this._userlikes.values()) { + yield new Userlike(this, entry); + } + } + + userlike(address: UserlikeAddress): ?Userlike { + const entry = this._userlikes.get(N.toRaw(address)); + return entry == null ? entry : new Userlike(this, entry); } _addRepo(json: Q.RepositoryJSON) { - const address: N.RepoAddress = { + const address: RepoAddress = { type: N.REPO_TYPE, owner: json.owner.login, name: json.name, @@ -117,8 +121,8 @@ export class RelationalView { this._repos.set(raw, entry); } - _addIssue(repo: N.RepoAddress, json: Q.IssueJSON): N.IssueAddress { - const address: N.IssueAddress = { + _addIssue(repo: RepoAddress, json: Q.IssueJSON): IssueAddress { + const address: IssueAddress = { type: N.ISSUE_TYPE, number: String(json.number), repo, @@ -135,8 +139,8 @@ export class RelationalView { return address; } - _addPull(repo: N.RepoAddress, json: Q.PullJSON): N.PullAddress { - const address: N.PullAddress = { + _addPull(repo: RepoAddress, json: Q.PullJSON): PullAddress { + const address: PullAddress = { type: N.PULL_TYPE, number: String(json.number), repo, @@ -163,8 +167,8 @@ export class RelationalView { return address; } - _addReview(pull: N.PullAddress, json: Q.ReviewJSON): N.ReviewAddress { - const address: N.ReviewAddress = { + _addReview(pull: PullAddress, json: Q.ReviewJSON): ReviewAddress { + const address: ReviewAddress = { type: N.REVIEW_TYPE, id: reviewUrlToId(json.url), pull, @@ -182,9 +186,9 @@ export class RelationalView { } _addComment( - parent: N.IssueAddress | N.PullAddress | N.ReviewAddress, + parent: IssueAddress | PullAddress | ReviewAddress, json: Q.CommentJSON - ): N.CommentAddress { + ): CommentAddress { const id = (function() { switch (parent.type) { case N.ISSUE_TYPE: @@ -199,7 +203,7 @@ export class RelationalView { throw new Error(`Unexpected comment parent type: ${parent.type}`); } })(); - const address: N.CommentAddress = {type: N.COMMENT_TYPE, id, parent}; + const address: CommentAddress = {type: N.COMMENT_TYPE, id, parent}; const entry: CommentEntry = { address, url: json.url, @@ -210,11 +214,11 @@ export class RelationalView { return address; } - _addNullableAuthor(json: Q.NullableAuthorJSON): ?N.UserlikeAddress { + _addNullableAuthor(json: Q.NullableAuthorJSON): ?UserlikeAddress { if (json == null) { return null; } else { - const address: N.UserlikeAddress = { + const address: UserlikeAddress = { type: N.USERLIKE_TYPE, login: json.login, }; @@ -224,3 +228,252 @@ export class RelationalView { } } } + +type Entry = + | RepoEntry + | IssueEntry + | PullEntry + | ReviewEntry + | CommentEntry + | UserlikeEntry; + +export class Entity<+T: Entry> { + +_view: RelationalView; + +_entry: T; + constructor(view: RelationalView, entry: T) { + this._view = view; + this._entry = entry; + } + address(): $ElementType { + return this._entry.address; + } + url(): string { + return this._entry.url; + } +} + +type RepoEntry = {| + +address: RepoAddress, + +url: string, + +issues: IssueAddress[], + +pulls: PullAddress[], +|}; + +export class Repo extends Entity { + constructor(view: RelationalView, entry: RepoEntry) { + super(view, entry); + } + name(): string { + return this._entry.address.name; + } + owner(): string { + return this._entry.address.owner; + } + *issues(): Iterator { + for (const address of this._entry.issues) { + const issue = this._view.issue(address); + yield assertExists(issue, address); + } + } + *pulls(): Iterator { + for (const address of this._entry.pulls) { + const pull = this._view.pull(address); + yield assertExists(pull, address); + } + } +} + +type IssueEntry = {| + +address: IssueAddress, + +title: string, + +body: string, + +url: string, + +comments: CommentAddress[], + +nominalAuthor: ?UserlikeAddress, +|}; + +export class Issue extends Entity { + constructor(view: RelationalView, entry: IssueEntry) { + super(view, entry); + } + parent(): Repo { + const address = this.address().repo; + const repo = this._view.repo(address); + return assertExists(repo, address); + } + number(): string { + return this._entry.address.number; + } + title(): string { + return this._entry.title; + } + body(): string { + return this._entry.body; + } + *comments(): Iterator { + for (const address of this._entry.comments) { + const comment = this._view.comment(address); + yield assertExists(comment, address); + } + } + authors(): Iterator { + return getAuthors(this._view, this._entry); + } +} + +type PullEntry = {| + +address: PullAddress, + +title: string, + +body: string, + +url: string, + +comments: CommentAddress[], + +reviews: ReviewAddress[], + +mergedAs: ?GitNode.CommitAddress, + +nominalAuthor: ?UserlikeAddress, +|}; + +export class Pull extends Entity { + constructor(view: RelationalView, entry: PullEntry) { + super(view, entry); + } + parent(): Repo { + const address = this.address().repo; + const repo = this._view.repo(address); + return assertExists(repo, address); + } + number(): string { + return this._entry.address.number; + } + title(): string { + return this._entry.title; + } + body(): string { + return this._entry.body; + } + mergedAs(): ?GitNode.CommitAddress { + return this._entry.mergedAs; + } + *reviews(): Iterator { + for (const address of this._entry.reviews) { + const review = this._view.review(address); + yield assertExists(review, address); + } + } + *comments(): Iterator { + for (const address of this._entry.comments) { + const comment = this._view.comment(address); + yield assertExists(comment, address); + } + } + authors(): Iterator { + return getAuthors(this._view, this._entry); + } +} + +type ReviewEntry = {| + +address: ReviewAddress, + +body: string, + +url: string, + +comments: CommentAddress[], + +state: Q.ReviewState, + +nominalAuthor: ?UserlikeAddress, +|}; + +export class Review extends Entity { + constructor(view: RelationalView, entry: ReviewEntry) { + super(view, entry); + } + parent(): Pull { + const address = this.address().pull; + const pull = this._view.pull(address); + return assertExists(pull, address); + } + body(): string { + return this._entry.body; + } + state(): string { + return this._entry.state; + } + *comments(): Iterator { + for (const address of this._entry.comments) { + const comment = this._view.comment(address); + yield assertExists(comment, address); + } + } + authors(): Iterator { + return getAuthors(this._view, this._entry); + } +} + +type CommentEntry = {| + +address: CommentAddress, + +body: string, + +url: string, + +nominalAuthor: ?UserlikeAddress, +|}; + +export class Comment extends Entity { + constructor(view: RelationalView, entry: CommentEntry) { + super(view, entry); + } + parent(): Pull | Issue | Review { + const address = this.address().parent; + let parent: ?Pull | ?Issue | ?Review; + switch (address.type) { + case "PULL": + parent = this._view.pull(address); + break; + case "ISSUE": + parent = this._view.issue(address); + break; + case "REVIEW": + parent = this._view.review(address); + break; + default: + // eslint-disable-next-line no-unused-expressions + (address.type: empty); + throw new Error(`Unexpected parent address: ${stringify(address)}`); + } + return assertExists(parent, address); + } + body(): string { + return this._entry.body; + } + authors(): Iterator { + return getAuthors(this._view, this._entry); + } +} + +type UserlikeEntry = {| + +address: UserlikeAddress, + +url: string, +|}; + +export class Userlike extends Entity { + constructor(view: RelationalView, entry: UserlikeEntry) { + super(view, entry); + } + login(): string { + return this.address().login; + } +} + +function assertExists(item: ?T, address: N.StructuredAddress): T { + if (item == null) { + throw new Error( + `Invariant violation: Expected entity for ${stringify(address)}` + ); + } + return item; +} + +function* getAuthors( + view: RelationalView, + entry: IssueEntry | PullEntry | ReviewEntry | CommentEntry +) { + const address = entry.nominalAuthor; + if (address != null) { + const author = view.userlike(address); + yield assertExists(author, address); + } +} diff --git a/src/v3/plugins/github/relationalView.test.js b/src/v3/plugins/github/relationalView.test.js index 4665811..1fb2add 100644 --- a/src/v3/plugins/github/relationalView.test.js +++ b/src/v3/plugins/github/relationalView.test.js @@ -1,70 +1,154 @@ // @flow import * as R from "./relationalView"; +import * as N from "./nodes"; describe("plugins/github/relationalView", () => { const data = require("./demoData/example-github"); // Sharing this state is OK because it's just a view - no mutation allowed! const view = new R.RelationalView(data); - function assertNotNull(x: ?T): T { - if (x == null) { - throw new Error(`Assertion fail: ${String(x)} `); - } - return x; + function hasEntities(name, method) { + describe(name, () => { + const all = Array.from(method()); + it(`has expected number of ${name}`, () => { + expect(all.length).toMatchSnapshot(); + }); + it("have expected urls", () => { + expect(all.map((x) => x.url())).toMatchSnapshot(); + }); + }); } - const repos = () => Array.from(view.repos()); - it("there is one repository", () => { - expect(repos()).toHaveLength(1); + + function has(name, method) { + it(`has ${name}`, () => { + const element = method(); + let snapshot; + if (element instanceof R.Entity) { + // element is an Entity. Entities have pointers to the RelationalView, + // and it would pollute our snapshot horribly. Just show the url. + snapshot = {url: element.url()}; + } else { + snapshot = element; + } + expect(snapshot).toMatchSnapshot(); + }); + } + + describe("RelationalView", () => { + function hasEntityMethods>( + name, + getAll: () => Iterator, + get: (x: $Call<$PropertyType>) => ?T + ) { + describe(`entity: ${name}`, () => { + const all = Array.from(getAll()); + it("has expected number of them", () => { + expect(all.length).not.toEqual(0); + expect(all.length).toMatchSnapshot(); + }); + const one = all[0]; + it("they are retrievable by address", () => { + expect(get(one.address())).toEqual(one); + }); + it("they have expected urls", () => { + expect(all.map((x) => x.url())).toMatchSnapshot(); + }); + }); + } + hasEntityMethods("repos", () => view.repos(), (x) => view.repo(x)); + hasEntityMethods("issues", () => view.issues(), (x) => view.issue(x)); + hasEntityMethods("pulls", () => view.pulls(), (x) => view.pull(x)); + hasEntityMethods("reviews", () => view.reviews(), (x) => view.review(x)); + hasEntityMethods("comments", () => view.comments(), (x) => view.comment(x)); + hasEntityMethods( + "userlikes", + () => view.userlikes(), + (x) => view.userlike(x) + ); }); - const repo = () => assertNotNull(repos()[0]); - it("repo matches snapshot", () => { - expect(repo()).toMatchSnapshot(); + const repo = view.repo({ + type: N.REPO_TYPE, + owner: "sourcecred", + name: "example-github", + }); + if (repo == null) { + throw new Error("Error: sourcecred/example-github must exist!"); + } + describe("Repo", () => { + const entity = repo; + has("owner", () => entity.owner()); + has("name", () => entity.name()); + has("url", () => entity.url()); + hasEntities("issues", () => entity.issues()); + hasEntities("pulls", () => entity.pulls()); }); - it("repo retrievable by address", () => { - expect(view.repo(repo().address)).toEqual(repo()); + const issue = Array.from(repo.issues())[1]; + describe("Issue", () => { + const entity = issue; + has("number", () => entity.number()); + has("body", () => entity.body()); + has("title", () => entity.title()); + has("url", () => entity.url()); + has("parent", () => entity.parent()); + hasEntities("comments", () => entity.comments()); + hasEntities("authors", () => entity.authors()); }); - const issue = () => assertNotNull(view.issue(repo().issues[1])); - it("issue matches snapshot", () => { - expect(issue()).toMatchSnapshot(); - }); - it("issue retrievable by address", () => { - expect(view.issue(issue().address)).toEqual(issue()); + const pull = Array.from(repo.pulls())[1]; + describe("Pull", () => { + const entity = pull; + has("number", () => entity.number()); + has("body", () => entity.body()); + has("title", () => entity.title()); + has("url", () => entity.url()); + has("parent", () => entity.parent()); + has("mergedAs", () => entity.mergedAs()); + hasEntities("reviews", () => entity.reviews()); + hasEntities("comments", () => entity.comments()); + hasEntities("authors", () => entity.authors()); }); - const pull = () => assertNotNull(view.pull(repo().pulls[1])); - it("pull matches snapshot", () => { - expect(pull()).toMatchSnapshot(); - }); - it("pull retrievable by address", () => { - expect(view.pull(pull().address)).toEqual(pull()); + const review = Array.from(pull.reviews())[0]; + describe("Review", () => { + const entity = review; + has("body", () => entity.body()); + has("url", () => entity.url()); + has("state", () => entity.state()); + has("parent", () => entity.parent()); + hasEntities("comments", () => entity.comments()); + hasEntities("authors", () => entity.authors()); }); - const review = () => assertNotNull(view.review(pull().reviews[0])); - it("review matches snapshot", () => { - expect(review()).toMatchSnapshot(); - }); - it("review retrievable by address", () => { - expect(view.review(review().address)).toEqual(review()); + const comment = Array.from(review.comments())[0]; + describe("Comment", () => { + const entity = comment; + has("body", () => entity.body()); + has("url", () => entity.url()); + has("parent", () => entity.parent()); + hasEntities("authors", () => entity.authors()); }); - const comment = () => assertNotNull(view.comment(issue().comments[0])); - it("comment matches snapshot", () => { - expect(comment()).toMatchSnapshot(); - }); - it("comment retrievable by address", () => { - expect(view.comment(comment().address)).toEqual(comment()); + const userlike = Array.from(review.authors())[0]; + describe("Userlike", () => { + const entity = userlike; + has("login", () => entity.login()); + has("url", () => entity.url()); }); - const userlike = () => - assertNotNull(view.userlike(assertNotNull(issue().nominalAuthor))); - it("userlike matches snapshot", () => { - expect(userlike()).toMatchSnapshot(); - }); - it("userlike retrievable by address", () => { - expect(view.userlike(userlike().address)).toEqual(userlike()); + describe("comment parent differentiation", () => { + function hasCorrectParent(name, parent) { + it(name, () => { + const comment = Array.from(parent.comments())[0]; + expect(comment).toEqual(expect.anything()); + const actualParent = comment.parent(); + expect(parent.address()).toEqual(actualParent.address()); + }); + } + hasCorrectParent("issue", issue); + hasCorrectParent("pull", pull); + hasCorrectParent("review", review); }); });