Add a porcelain api for Github data (#170)

Interacting with raw contribution graphs is cumbersome. We'll need
more fluent and convenient ways to retrieve data from them; we can do
this by creating porcelain APIs that wrap the underlying graph.

This commit adds a simple porcelain API for the GitHub data. It creates
the following classes:

* `api.Repository`
* `api.Issue`
* `api.PullRequest`
* `api.Comment`
* `api.Author`

The classes all wrap a graph and a nodeAddress. They provide read-only
functions for retreiving data from the graph; that data might be a part
of the node payload, or it might do some graph traversal under the hood.

The choice to have the wrapper hold onto the Address rather than the
node itself was deliberate; in the future, the graph may contain nodes
that are not synchronously reachable, so this approach allows us to
create wrappers for nodes we can't synchronously reach. When this comes
up in practice, we can then add async methods to the wrapper.

Note that some data already included in our graph, such as
PullRequestReviews and PullRequestReviewComments, were deliberately
excluded, so as to allow the core ideas to be reviewed without
unnecessary clutter.

Test plan:
Check that the unit tests appropriately test the behavior, and that the
API seems pleasant to use.
This commit is contained in:
Dandelion Mané 2018-04-27 21:45:30 -07:00 committed by GitHub
parent 1c28c75e39
commit 7158deaad3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 302 additions and 0 deletions

View File

@ -0,0 +1,68 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GitHub porcelain API Author 1`] = `2`;
exports[`GitHub porcelain API Author 2`] = `
Object {
"address": Object {
"id": "https://github.com/decentralion",
"pluginName": "sourcecred/github-beta",
"repositoryName": "sourcecred/example-repo",
"type": "AUTHOR",
},
"payload": Object {
"login": "decentralion",
"subtype": "USER",
"url": "https://github.com/decentralion",
},
}
`;
exports[`GitHub porcelain API Comment 1`] = `
Object {
"address": Object {
"id": "https://github.com/sourcecred/example-repo/issues/6#issuecomment-373768442",
"pluginName": "sourcecred/github-beta",
"repositoryName": "sourcecred/example-repo",
"type": "COMMENT",
},
"payload": Object {
"body": "A wild COMMENT appeared!",
"url": "https://github.com/sourcecred/example-repo/issues/6#issuecomment-373768442",
},
}
`;
exports[`GitHub porcelain API Issue 1`] = `
Object {
"address": Object {
"id": "https://github.com/sourcecred/example-repo/issues/1",
"pluginName": "sourcecred/github-beta",
"repositoryName": "sourcecred/example-repo",
"type": "ISSUE",
},
"payload": Object {
"body": "This is just an example issue.",
"number": 1,
"title": "An example issue.",
"url": "https://github.com/sourcecred/example-repo/issues/1",
},
}
`;
exports[`GitHub porcelain API PullRequest 1`] = `
Object {
"address": Object {
"id": "https://github.com/sourcecred/example-repo/pull/3",
"pluginName": "sourcecred/github-beta",
"repositoryName": "sourcecred/example-repo",
"type": "PULL_REQUEST",
},
"payload": Object {
"body": "Oh look, it's a pull request.",
"number": 3,
"title": "Add README, merge via PR.",
"url": "https://github.com/sourcecred/example-repo/pull/3",
},
}
`;

153
src/plugins/github/api.js Normal file
View File

@ -0,0 +1,153 @@
// @flow
import {Graph} from "../../core/graph";
import type {Node} from "../../core/graph";
import type {Address} from "../../core/address";
import type {
NodePayload,
EdgePayload,
NodeType,
IssueNodePayload,
PullRequestNodePayload,
CommentNodePayload,
AuthorNodePayload,
AuthorSubtype,
} from "./types";
import {
CONTAINS_EDGE_TYPE,
COMMENT_NODE_TYPE,
AUTHORS_EDGE_TYPE,
AUTHOR_NODE_TYPE,
ISSUE_NODE_TYPE,
PULL_REQUEST_NODE_TYPE,
} from "./types";
export class Repository {
repositoryName: string;
graph: Graph<NodePayload, EdgePayload>;
constructor(repositoryName: string, graph: Graph<NodePayload, EdgePayload>) {
this.repositoryName = repositoryName;
this.graph = graph;
}
issueOrPRByNumber(number: number): ?(Issue | PullRequest) {
let result: Issue | PullRequest;
this.graph.nodes({type: ISSUE_NODE_TYPE}).forEach((n) => {
if (n.payload.number === number) {
result = new Issue(this.graph, n.address);
}
});
this.graph.nodes({type: PULL_REQUEST_NODE_TYPE}).forEach((n) => {
if (n.payload.number === number) {
result = new PullRequest(this.graph, n.address);
}
});
return result;
}
issues(): Issue[] {
return this.graph
.nodes({type: ISSUE_NODE_TYPE})
.map((n) => new Issue(this.graph, n.address));
}
pullRequests(): PullRequest[] {
return this.graph
.nodes({type: PULL_REQUEST_NODE_TYPE})
.map((n) => new PullRequest(this.graph, n.address));
}
authors(): Author[] {
return this.graph
.nodes({type: AUTHOR_NODE_TYPE})
.map((n) => new Author(this.graph, n.address));
}
}
class GithubEntity<T: NodePayload> {
graph: Graph<NodePayload, EdgePayload>;
nodeAddress: Address;
constructor(graph: Graph<NodePayload, EdgePayload>, nodeAddress: Address) {
this.graph = graph;
this.nodeAddress = nodeAddress;
}
node(): Node<T> {
return (this.graph.node(this.nodeAddress): Node<any>);
}
url(): string {
return this.node().payload.url;
}
type(): NodeType {
return (this.nodeAddress.type: any);
}
address(): Address {
return this.nodeAddress;
}
}
class Post<
T: IssueNodePayload | PullRequestNodePayload | CommentNodePayload
> extends GithubEntity<T> {
authors(): Author[] {
return this.graph
.neighborhood(this.nodeAddress, {
edgeType: AUTHORS_EDGE_TYPE,
nodeType: AUTHOR_NODE_TYPE,
})
.map(({neighborAddress}) => new Author(this.graph, neighborAddress));
}
body(): string {
return this.node().payload.body;
}
}
class Commentable<T: IssueNodePayload | PullRequestNodePayload> extends Post<
T
> {
comments(): Comment[] {
return this.graph
.neighborhood(this.nodeAddress, {
edgeType: CONTAINS_EDGE_TYPE,
nodeType: COMMENT_NODE_TYPE,
})
.map(({neighborAddress}) => new Comment(this.graph, neighborAddress));
}
}
export class Author extends GithubEntity<AuthorNodePayload> {
login(): string {
return this.node().payload.login;
}
subtype(): AuthorSubtype {
return this.node().payload.subtype;
}
}
export class PullRequest extends Commentable<PullRequestNodePayload> {
number(): number {
return this.node().payload.number;
}
title(): string {
return this.node().payload.title;
}
}
export class Issue extends Commentable<IssueNodePayload> {
number(): number {
return this.node().payload.number;
}
title(): string {
return this.node().payload.title;
}
}
export class Comment extends Post<CommentNodePayload> {}

View File

@ -0,0 +1,81 @@
// @flow
import {parse} from "./parser";
import exampleRepoData from "./demoData/example-repo.json";
import {Repository, Issue, PullRequest, Comment, Author} from "./api";
import {
AUTHOR_NODE_TYPE,
COMMENT_NODE_TYPE,
ISSUE_NODE_TYPE,
PULL_REQUEST_NODE_TYPE,
} from "./types";
describe("GitHub porcelain API", () => {
const graph = parse("sourcecred/example-repo", exampleRepoData);
const repo = new Repository("sourcecred/example-repo", graph);
it("Issue", () => {
const issue = repo.issueOrPRByNumber(1);
if (issue == null) {
throw new Error("Issue reaching issue!");
}
expect(issue.title()).toBe("An example issue.");
expect(issue.body()).toBe("This is just an example issue.");
expect(issue.number()).toBe(1);
expect(issue.type()).toBe(ISSUE_NODE_TYPE);
expect(issue.url()).toBe(
"https://github.com/sourcecred/example-repo/issues/1"
);
expect(issue.node()).toMatchSnapshot();
expect(issue.address()).toEqual(issue.node().address);
expect(issue.authors().map((x) => x.login())).toEqual(["decentralion"]);
});
it("PullRequest", () => {
const pullRequest = repo.issueOrPRByNumber(3);
if (pullRequest == null) {
throw new Error("Issue reaching PR!");
}
expect(pullRequest.body()).toBe("Oh look, it's a pull request.");
expect(pullRequest.url()).toBe(
"https://github.com/sourcecred/example-repo/pull/3"
);
expect(pullRequest.number()).toBe(3);
expect(pullRequest.type()).toBe(PULL_REQUEST_NODE_TYPE);
expect(pullRequest.node()).toMatchSnapshot();
expect(pullRequest.address()).toEqual(pullRequest.node().address);
});
it("Comment", () => {
const issue = repo.issueOrPRByNumber(6);
if (issue == null) {
throw new Error("Issue reaching issue!");
}
const comments = issue.comments();
expect(comments).toHaveLength(2);
const comment = comments[0];
expect(comment.type()).toBe(COMMENT_NODE_TYPE);
expect(comment.body()).toBe("A wild COMMENT appeared!");
expect(comment.url()).toBe(
"https://github.com/sourcecred/example-repo/issues/6#issuecomment-373768442"
);
expect(comment.node()).toMatchSnapshot();
expect(comment.address()).toEqual(comment.node().address);
expect(comment.authors().map((x) => x.login())).toEqual(["decentralion"]);
});
it("Author", () => {
const authors = repo.authors();
// So we don't need to manually update the test if a new person posts
expect(authors.length).toMatchSnapshot();
const decentralion = authors.find((x) => x.login() === "decentralion");
if (decentralion == null) {
throw new Error("Who let the lions out?");
}
expect(decentralion.url()).toBe("https://github.com/decentralion");
expect(decentralion.type()).toBe(AUTHOR_NODE_TYPE);
expect(decentralion.subtype()).toBe("USER");
expect(decentralion.node()).toMatchSnapshot();
expect(decentralion.address()).toEqual(decentralion.node().address);
});
});