diff --git a/src/v3/plugins/github/__snapshots__/edges.test.js.snap b/src/v3/plugins/github/__snapshots__/edges.test.js.snap new file mode 100644 index 0000000..445fd92 --- /dev/null +++ b/src/v3/plugins/github/__snapshots__/edges.test.js.snap @@ -0,0 +1,149 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`plugins/github/edges createEdge works for authors edges 1`] = `"{address: EdgeAddress[\\"sourcecred\\",\\"github\\",\\"authors\\",\\"2\\",\\"userlike\\",\\"decentralion\\",\\"4\\",\\"issue\\",\\"sourcecred\\",\\"example-github\\",\\"2\\"], src: NodeAddress[\\"sourcecred\\",\\"github\\",\\"userlike\\",\\"decentralion\\"], dst: NodeAddress[\\"sourcecred\\",\\"github\\",\\"issue\\",\\"sourcecred\\",\\"example-github\\",\\"2\\"]}"`; + +exports[`plugins/github/edges createEdge works for has-parent edges 1`] = `"{address: EdgeAddress[\\"sourcecred\\",\\"github\\",\\"has_parent\\",\\"7\\",\\"comment\\",\\"review\\",\\"sourcecred\\",\\"example-github\\",\\"5\\",\\"100313899\\",\\"171460198\\"], src: NodeAddress[\\"sourcecred\\",\\"github\\",\\"comment\\",\\"review\\",\\"sourcecred\\",\\"example-github\\",\\"5\\",\\"100313899\\",\\"171460198\\"], dst: NodeAddress[\\"sourcecred\\",\\"github\\",\\"review\\",\\"sourcecred\\",\\"example-github\\",\\"5\\",\\"100313899\\"]}"`; + +exports[`plugins/github/edges createEdge works for merged-as edges 1`] = `"{address: EdgeAddress[\\"sourcecred\\",\\"github\\",\\"merged_as\\",\\"4\\",\\"pull\\",\\"sourcecred\\",\\"example-github\\",\\"5\\"], src: NodeAddress[\\"sourcecred\\",\\"github\\",\\"pull\\",\\"sourcecred\\",\\"example-github\\",\\"5\\"], dst: NodeAddress[\\"git\\",\\"commit\\",\\"123\\"]}"`; + +exports[`plugins/github/edges createEdge works for reference edges 1`] = `"{address: EdgeAddress[\\"sourcecred\\",\\"github\\",\\"references\\",\\"4\\",\\"issue\\",\\"sourcecred\\",\\"example-github\\",\\"2\\",\\"4\\",\\"pull\\",\\"sourcecred\\",\\"example-github\\",\\"5\\"], src: NodeAddress[\\"sourcecred\\",\\"github\\",\\"issue\\",\\"sourcecred\\",\\"example-github\\",\\"2\\"], dst: NodeAddress[\\"sourcecred\\",\\"github\\",\\"pull\\",\\"sourcecred\\",\\"example-github\\",\\"5\\"]}"`; + +exports[`plugins/github/edges snapshots as expected: authors 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "authors", + "2", + "userlike", + "decentralion", + "4", + "pull", + "sourcecred", + "example-github", + "5", + ], + "structured": Object { + "author": Object { + "login": "decentralion", + "type": "USERLIKE", + }, + "content": Object { + "number": "5", + "repo": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, + "type": "PULL", + }, + "type": "AUTHORS", + }, +} +`; + +exports[`plugins/github/edges snapshots as expected: hasParent 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "has_parent", + "7", + "comment", + "review", + "sourcecred", + "example-github", + "5", + "100313899", + "171460198", + ], + "structured": Object { + "child": 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", + }, + "type": "HAS_PARENT", + }, +} +`; + +exports[`plugins/github/edges snapshots as expected: mergedAs 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "merged_as", + "4", + "pull", + "sourcecred", + "example-github", + "5", + ], + "structured": Object { + "pull": Object { + "number": "5", + "repo": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, + "type": "PULL", + }, + "type": "MERGED_AS", + }, +} +`; + +exports[`plugins/github/edges snapshots as expected: references 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "references", + "4", + "issue", + "sourcecred", + "example-github", + "2", + "4", + "issue", + "sourcecred", + "example-github", + "1", + ], + "structured": Object { + "referent": Object { + "number": "1", + "repo": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, + "type": "ISSUE", + }, + "referrer": Object { + "number": "2", + "repo": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, + "type": "ISSUE", + }, + "type": "REFERENCES", + }, +} +`; diff --git a/src/v3/plugins/github/edges.js b/src/v3/plugins/github/edges.js new file mode 100644 index 0000000..7e11e1e --- /dev/null +++ b/src/v3/plugins/github/edges.js @@ -0,0 +1,208 @@ +// @flow + +import { + type Edge, + type EdgeAddressT, + type NodeAddressT, + EdgeAddress, + NodeAddress, +} from "../../core/graph"; +import * as GithubNode from "./nodes"; + +export opaque type RawAddress: EdgeAddressT = EdgeAddressT; + +export type AuthorsAddress = {| + +type: "AUTHORS", + +author: GithubNode.UserlikeAddress, + +content: GithubNode.AuthorableAddress, +|}; +export type MergedAsAddress = {| + +type: "MERGED_AS", + +pull: GithubNode.PullAddress, +|}; +export type HasParentAddress = {| + +type: "HAS_PARENT", + +child: GithubNode.ChildAddress, +|}; +export type ReferencesAddress = {| + +type: "REFERENCES", + +referrer: GithubNode.TextContentAddress, + +referent: GithubNode.ReferentAddress, +|}; + +export type StructuredAddress = + | AuthorsAddress + | MergedAsAddress + | HasParentAddress + | ReferencesAddress; + +export const createEdge = Object.freeze({ + authors: ( + author: GithubNode.UserlikeAddress, + content: GithubNode.AuthorableAddress + ): Edge => ({ + address: toRaw({type: "AUTHORS", author, content}), + src: GithubNode.toRaw(author), + dst: GithubNode.toRaw(content), + }), + mergedAs: ( + pull: GithubNode.PullAddress, + commitAddress: NodeAddressT /* TODO: Make this a Git commit node address. */ + ): Edge => ({ + address: toRaw({type: "MERGED_AS", pull}), + src: GithubNode.toRaw(pull), + dst: commitAddress, + }), + hasParent: ( + child: GithubNode.ChildAddress, + parent: GithubNode.ParentAddress + ): Edge => ({ + address: toRaw({type: "HAS_PARENT", child}), + src: GithubNode.toRaw(child), + dst: GithubNode.toRaw(parent), + }), + references: ( + referrer: GithubNode.TextContentAddress, + referent: GithubNode.ReferentAddress + ): Edge => ({ + address: toRaw({type: "REFERENCES", referrer, referent}), + src: GithubNode.toRaw(referrer), + dst: GithubNode.toRaw(referent), + }), +}); + +const NODE_PREFIX_LENGTH = NodeAddress.toParts(GithubNode._githubAddress()) + .length; + +const GITHUB_PREFIX = EdgeAddress.fromParts(["sourcecred", "github"]); +function githubEdgeAddress(...parts: string[]): RawAddress { + return EdgeAddress.append(GITHUB_PREFIX, ...parts); +} +function lengthEncode(x: GithubNode.RawAddress): $ReadOnlyArray { + const baseParts = NodeAddress.toParts(x).slice(NODE_PREFIX_LENGTH); + return [String(baseParts.length), ...baseParts]; +} +function lengthDecode( + x: $ReadOnlyArray, + fail: () => Error +): {|+parts: $ReadOnlyArray, +rest: $ReadOnlyArray|} { + if (x.length === 0) { + // Not length-encoded. + throw fail(); + } + const [lengthString, ...allParts] = x; + const length = parseInt(lengthString, 10); + if (isNaN(length)) { + throw fail(); + } + if (length > allParts.length) { + // Not enough elements. + throw fail(); + } + return {parts: allParts.slice(0, length), rest: allParts.slice(length)}; +} +function multiLengthDecode(x: $ReadOnlyArray, fail: () => Error) { + let remaining = x; + let partses = []; + while (remaining.length > 0) { + const {parts, rest} = lengthDecode(remaining, fail); + partses.push(parts); + remaining = rest; + } + return partses; +} + +export function fromRaw(x: RawAddress): StructuredAddress { + function fail() { + return new Error(`Bad address: ${EdgeAddress.toString(x)}`); + } + if (!EdgeAddress.hasPrefix(x, GITHUB_PREFIX)) { + throw fail(); + } + const [_unused_sc, _unused_gh, kind, ...rest] = EdgeAddress.toParts(x); + switch (kind) { + case "authors": { + const parts = multiLengthDecode(rest, fail); + if (parts.length !== 2) { + throw fail(); + } + const [authorParts, contentParts] = parts; + const author: GithubNode.UserlikeAddress = (GithubNode.fromRaw( + GithubNode._githubAddress(...authorParts) + ): any); + const content: GithubNode.AuthorableAddress = (GithubNode.fromRaw( + GithubNode._githubAddress(...contentParts) + ): any); + return ({type: "AUTHORS", author, content}: AuthorsAddress); + } + case "merged_as": { + const parts = multiLengthDecode(rest, fail); + if (parts.length !== 1) { + throw fail(); + } + const [pullParts] = parts; + const pull: GithubNode.PullAddress = (GithubNode.fromRaw( + GithubNode._githubAddress(...pullParts) + ): any); + return ({type: "MERGED_AS", pull}: MergedAsAddress); + } + case "has_parent": { + const parts = multiLengthDecode(rest, fail); + if (parts.length !== 1) { + throw fail(); + } + const [childParts] = parts; + const child: GithubNode.ChildAddress = (GithubNode.fromRaw( + GithubNode._githubAddress(...childParts) + ): any); + return ({type: "HAS_PARENT", child}: HasParentAddress); + } + case "references": { + const parts = multiLengthDecode(rest, fail); + if (parts.length !== 2) { + throw fail(); + } + const [referrerParts, referentParts] = parts; + const referrer: GithubNode.TextContentAddress = (GithubNode.fromRaw( + GithubNode._githubAddress(...referrerParts) + ): any); + const referent: GithubNode.ReferentAddress = (GithubNode.fromRaw( + GithubNode._githubAddress(...referentParts) + ): any); + return ({type: "REFERENCES", referrer, referent}: ReferencesAddress); + } + default: + throw fail(); + } +} + +export function toRaw(x: StructuredAddress): RawAddress { + switch (x.type) { + case "AUTHORS": + return githubEdgeAddress( + "authors", + ...lengthEncode(GithubNode.toRaw(x.author)), + ...lengthEncode(GithubNode.toRaw(x.content)) + ); + case "MERGED_AS": + return githubEdgeAddress( + "merged_as", + ...lengthEncode(GithubNode.toRaw(x.pull)) + ); + case "HAS_PARENT": + return githubEdgeAddress( + "has_parent", + ...lengthEncode(GithubNode.toRaw(x.child)) + ); + case "REFERENCES": + return githubEdgeAddress( + "references", + ...lengthEncode(GithubNode.toRaw(x.referrer)), + ...lengthEncode(GithubNode.toRaw(x.referent)) + ); + default: + // eslint-disable-next-line no-unused-expressions + (x.type: empty); + throw new Error(x.type); + } +} diff --git a/src/v3/plugins/github/edges.test.js b/src/v3/plugins/github/edges.test.js new file mode 100644 index 0000000..0aca42d --- /dev/null +++ b/src/v3/plugins/github/edges.test.js @@ -0,0 +1,120 @@ +// @flow + +import {NodeAddress, EdgeAddress, edgeToString} from "../../core/graph"; +import {createEdge, fromRaw, toRaw} from "./edges"; + +describe("plugins/github/edges", () => { + const nodeExamples = { + repo: () => ({ + type: "REPO", + owner: "sourcecred", + name: "example-github", + }), + issue: () => ({type: "ISSUE", repo: nodeExamples.repo(), number: "2"}), + pull: () => ({type: "PULL", repo: nodeExamples.repo(), number: "5"}), + review: () => ({ + type: "REVIEW", + pull: nodeExamples.pull(), + id: "100313899", + }), + issueComment: () => ({ + type: "COMMENT", + parent: nodeExamples.issue(), + id: "373768703", + }), + pullComment: () => ({ + type: "COMMENT", + parent: nodeExamples.pull(), + id: "396430464", + }), + reviewComment: () => ({ + type: "COMMENT", + parent: nodeExamples.review(), + id: "171460198", + }), + user: () => ({type: "USERLIKE", login: "decentralion"}), + }; + + const edgeExamples = { + authors: () => ({ + type: "AUTHORS", + author: nodeExamples.user(), + content: nodeExamples.pull(), + }), + mergedAs: () => ({ + type: "MERGED_AS", + pull: nodeExamples.pull(), + }), + hasParent: () => ({ + type: "HAS_PARENT", + child: nodeExamples.reviewComment(), + }), + references: () => ({ + type: "REFERENCES", + referrer: nodeExamples.issue(), + referent: {type: "ISSUE", repo: nodeExamples.repo(), number: "1"}, + }), + }; + + describe("createEdge", () => { + it("works for authors edges", () => { + expect( + edgeToString( + createEdge.authors(nodeExamples.user(), nodeExamples.issue()) + ) + ).toMatchSnapshot(); + }); + it("works for merged-as edges", () => { + const commitAddress = NodeAddress.fromParts(["git", "commit", "123"]); + expect( + edgeToString(createEdge.mergedAs(nodeExamples.pull(), commitAddress)) + ).toMatchSnapshot(); + }); + it("works for has-parent edges", () => { + expect( + edgeToString( + createEdge.hasParent( + nodeExamples.reviewComment(), + nodeExamples.review() + ) + ) + ).toMatchSnapshot(); + }); + it("works for reference edges", () => { + expect( + edgeToString( + createEdge.references(nodeExamples.issue(), nodeExamples.pull()) + ) + ).toMatchSnapshot(); + }); + }); + + describe("`fromRaw` after `toRaw` is identity", () => { + Object.keys(edgeExamples).forEach((example) => { + it(example, () => { + const instance = edgeExamples[example](); + expect(fromRaw(toRaw(instance))).toEqual(instance); + }); + }); + }); + + describe("`toRaw` after `fromRaw` is identity", () => { + Object.keys(edgeExamples).forEach((example) => { + it(example, () => { + const instance = edgeExamples[example](); + const raw = toRaw(instance); + expect(toRaw(fromRaw(raw))).toEqual(raw); + }); + }); + }); + + describe("snapshots as expected:", () => { + Object.keys(edgeExamples).forEach((example) => { + it(example, () => { + const instance = edgeExamples[example](); + const raw = EdgeAddress.toParts(toRaw(instance)); + expect({address: raw, structured: instance}).toMatchSnapshot(); + }); + }); + }); +});