Add GitHub `RelationalView` (#411)

The `RelationalView` maps the GitHub GraphQL response data into a View
class, which makes it easy to access pieces of GitHub data by their
corresponding `StructuredAddress`.

This will be a valuable companion to the graph, making it possible to
access GitHub node data like the title or body of an issue via the
issue's address. This basically is the supplement to the GitHub graph
that includes the "payloads" from our v1 Graph.

It will also make creating the GitHub graph a lot more convenient,
although I've left that for another commit.

Designed with feedback from @wchargin.

Note: The `RelationalView` objects have a `nominalAuthor` rather than
`author`, so as to distinguish between authorship in the GitHub data
model (entities have at most one author) and in the SourceCred model
(entities may have multiple authors).

Test plan:
Inspect the included snapshots for reasonability, and run unit tests.
This commit is contained in:
Dandelion Mané 2018-06-22 20:32:07 -07:00 committed by GitHub
parent 2dec8868db
commit a470f28204
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 670 additions and 0 deletions

View File

@ -0,0 +1,374 @@
// 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 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 pull matches snapshot 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`] = `
Object {
"address": Object {
"login": "decentralion",
"type": "USERLIKE",
},
"url": "https://github.com/decentralion",
}
`;

View File

@ -0,0 +1,226 @@
// @flow
import * as N from "./nodes";
import * as Q from "./graphql";
import * as GitNode from "../git/nodes";
import {
reviewUrlToId,
issueCommentUrlToId,
pullCommentUrlToId,
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<N.RawAddress, RepoEntry>;
_issues: Map<N.RawAddress, IssueEntry>;
_pulls: Map<N.RawAddress, PullEntry>;
_comments: Map<N.RawAddress, CommentEntry>;
_reviews: Map<N.RawAddress, ReviewEntry>;
_userlikes: Map<N.RawAddress, UserlikeEntry>;
constructor(data: Q.GithubResponseJSON) {
this._repos = new Map();
this._issues = new Map();
this._pulls = new Map();
this._comments = new Map();
this._reviews = new Map();
this._userlikes = new Map();
this._addRepo(data.repository);
}
repos(): Iterator<RepoEntry> {
return this._repos.values();
}
repo(address: N.RepoAddress): ?RepoEntry {
return this._repos.get(N.toRaw(address));
}
issue(address: N.IssueAddress): ?IssueEntry {
return this._issues.get(N.toRaw(address));
}
pull(address: N.PullAddress): ?PullEntry {
return this._pulls.get(N.toRaw(address));
}
comment(address: N.CommentAddress): ?CommentEntry {
return this._comments.get(N.toRaw(address));
}
review(address: N.ReviewAddress): ?ReviewEntry {
return this._reviews.get(N.toRaw(address));
}
userlike(address: N.UserlikeAddress): ?UserlikeEntry {
return this._userlikes.get(N.toRaw(address));
}
_addRepo(json: Q.RepositoryJSON) {
const address: N.RepoAddress = {
type: N.REPO_TYPE,
owner: json.owner.login,
name: json.name,
};
const entry: RepoEntry = {
address,
url: json.url,
issues: json.issues.nodes.map((x) => this._addIssue(address, x)),
pulls: json.pulls.nodes.map((x) => this._addPull(address, x)),
};
const raw = N.toRaw(address);
this._repos.set(raw, entry);
}
_addIssue(repo: N.RepoAddress, json: Q.IssueJSON): N.IssueAddress {
const address: N.IssueAddress = {
type: N.ISSUE_TYPE,
number: String(json.number),
repo,
};
const entry: IssueEntry = {
address,
url: json.url,
comments: json.comments.nodes.map((x) => this._addComment(address, x)),
nominalAuthor: this._addNullableAuthor(json.author),
body: json.body,
title: json.title,
};
this._issues.set(N.toRaw(address), entry);
return address;
}
_addPull(repo: N.RepoAddress, json: Q.PullJSON): N.PullAddress {
const address: N.PullAddress = {
type: N.PULL_TYPE,
number: String(json.number),
repo,
};
const mergedAs =
json.mergeCommit == null
? null
: {
type: GitNode.COMMIT_TYPE,
hash: json.mergeCommit.oid,
};
const entry: PullEntry = {
address,
url: json.url,
comments: json.comments.nodes.map((x) => this._addComment(address, x)),
reviews: json.reviews.nodes.map((x) => this._addReview(address, x)),
nominalAuthor: this._addNullableAuthor(json.author),
body: json.body,
title: json.title,
mergedAs,
};
this._pulls.set(N.toRaw(address), entry);
return address;
}
_addReview(pull: N.PullAddress, json: Q.ReviewJSON): N.ReviewAddress {
const address: N.ReviewAddress = {
type: N.REVIEW_TYPE,
id: reviewUrlToId(json.url),
pull,
};
const entry: ReviewEntry = {
address,
url: json.url,
state: json.state,
comments: json.comments.nodes.map((x) => this._addComment(address, x)),
body: json.body,
nominalAuthor: this._addNullableAuthor(json.author),
};
this._reviews.set(N.toRaw(address), entry);
return address;
}
_addComment(
parent: N.IssueAddress | N.PullAddress | N.ReviewAddress,
json: Q.CommentJSON
): N.CommentAddress {
const id = (function() {
switch (parent.type) {
case N.ISSUE_TYPE:
return issueCommentUrlToId(json.url);
case N.PULL_TYPE:
return pullCommentUrlToId(json.url);
case N.REVIEW_TYPE:
return reviewCommentUrlToId(json.url);
default:
// eslint-disable-next-line no-unused-expressions
(parent.type: empty);
throw new Error(`Unexpected comment parent type: ${parent.type}`);
}
})();
const address: N.CommentAddress = {type: N.COMMENT_TYPE, id, parent};
const entry: CommentEntry = {
address,
url: json.url,
nominalAuthor: this._addNullableAuthor(json.author),
body: json.body,
};
this._comments.set(N.toRaw(address), entry);
return address;
}
_addNullableAuthor(json: Q.NullableAuthorJSON): ?N.UserlikeAddress {
if (json == null) {
return null;
} else {
const address: N.UserlikeAddress = {
type: N.USERLIKE_TYPE,
login: json.login,
};
const entry: UserlikeEntry = {address, url: json.url};
this._userlikes.set(N.toRaw(address), entry);
return address;
}
}
}

View File

@ -0,0 +1,70 @@
// @flow
import * as R from "./relationalView";
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<T>(x: ?T): T {
if (x == null) {
throw new Error(`Assertion fail: ${String(x)} `);
}
return x;
}
const repos = () => Array.from(view.repos());
it("there is one repository", () => {
expect(repos()).toHaveLength(1);
});
const repo = () => assertNotNull(repos()[0]);
it("repo matches snapshot", () => {
expect(repo()).toMatchSnapshot();
});
it("repo retrievable by address", () => {
expect(view.repo(repo().address)).toEqual(repo());
});
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 = () => 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 = () => 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 = () => 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 = () =>
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());
});
});