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); + }); +});