From 7158deaad371fa42aa5b06e22ff966c7391774a1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Dandelion=20Man=C3=A9?=
Date: Fri, 27 Apr 2018 21:45:30 -0700
Subject: [PATCH] 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.
---
.../github/__snapshots__/api.test.js.snap | 68 ++++++++
src/plugins/github/api.js | 153 ++++++++++++++++++
src/plugins/github/api.test.js | 81 ++++++++++
3 files changed, 302 insertions(+)
create mode 100644 src/plugins/github/__snapshots__/api.test.js.snap
create mode 100644 src/plugins/github/api.js
create mode 100644 src/plugins/github/api.test.js
diff --git a/src/plugins/github/__snapshots__/api.test.js.snap b/src/plugins/github/__snapshots__/api.test.js.snap
new file mode 100644
index 0000000..02ee5c2
--- /dev/null
+++ b/src/plugins/github/__snapshots__/api.test.js.snap
@@ -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",
+ },
+}
+`;
diff --git a/src/plugins/github/api.js b/src/plugins/github/api.js
new file mode 100644
index 0000000..9489cb5
--- /dev/null
+++ b/src/plugins/github/api.js
@@ -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;
+
+ constructor(repositoryName: string, graph: Graph) {
+ 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 {
+ graph: Graph;
+ nodeAddress: Address;
+
+ constructor(graph: Graph, nodeAddress: Address) {
+ this.graph = graph;
+ this.nodeAddress = nodeAddress;
+ }
+
+ node(): Node {
+ return (this.graph.node(this.nodeAddress): Node);
+ }
+
+ 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 {
+ 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 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 {
+ login(): string {
+ return this.node().payload.login;
+ }
+
+ subtype(): AuthorSubtype {
+ return this.node().payload.subtype;
+ }
+}
+
+export class PullRequest extends Commentable {
+ number(): number {
+ return this.node().payload.number;
+ }
+ title(): string {
+ return this.node().payload.title;
+ }
+}
+
+export class Issue extends Commentable {
+ number(): number {
+ return this.node().payload.number;
+ }
+ title(): string {
+ return this.node().payload.title;
+ }
+}
+
+export class Comment extends Post {}
diff --git a/src/plugins/github/api.test.js b/src/plugins/github/api.test.js
new file mode 100644
index 0000000..26ce7b6
--- /dev/null
+++ b/src/plugins/github/api.test.js
@@ -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);
+ });
+});