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:
parent
1c28c75e39
commit
7158deaad3
|
@ -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",
|
||||
},
|
||||
}
|
||||
`;
|
|
@ -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> {}
|
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue