diff --git a/src/v3/plugins/github/__snapshots__/address.test.js.snap b/src/v3/plugins/github/__snapshots__/address.test.js.snap new file mode 100644 index 0000000..dac3738 --- /dev/null +++ b/src/v3/plugins/github/__snapshots__/address.test.js.snap @@ -0,0 +1,193 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`plugins/github/address snapshots as expected: issue 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "issue", + "sourcecred", + "example-github", + "2", + ], + "structured": Object { + "number": "2", + "repo": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, + "type": "ISSUE", + }, +} +`; + +exports[`plugins/github/address snapshots as expected: issueComment 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "comment", + "issue", + "sourcecred", + "example-github", + "2", + "373768703", + ], + "structured": Object { + "fragment": "373768703", + "parent": Object { + "number": "2", + "repo": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, + "type": "ISSUE", + }, + "type": "COMMENT", + }, +} +`; + +exports[`plugins/github/address snapshots as expected: pull 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "pull", + "sourcecred", + "example-github", + "5", + ], + "structured": Object { + "number": "5", + "repo": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, + "type": "PULL", + }, +} +`; + +exports[`plugins/github/address snapshots as expected: pullComment 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "comment", + "pull", + "sourcecred", + "example-github", + "5", + "396430464", + ], + "structured": Object { + "fragment": "396430464", + "parent": Object { + "number": "5", + "repo": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, + "type": "PULL", + }, + "type": "COMMENT", + }, +} +`; + +exports[`plugins/github/address snapshots as expected: repo 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "repo", + "sourcecred", + "example-github", + ], + "structured": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, +} +`; + +exports[`plugins/github/address snapshots as expected: review 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "review", + "sourcecred", + "example-github", + "5", + "100313899", + ], + "structured": Object { + "fragment": "100313899", + "pull": Object { + "number": "5", + "repo": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, + "type": "PULL", + }, + "type": "REVIEW", + }, +} +`; + +exports[`plugins/github/address snapshots as expected: reviewComment 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "comment", + "review", + "sourcecred", + "example-github", + "5", + "100313899", + "171460198", + ], + "structured": Object { + "fragment": "171460198", + "parent": Object { + "fragment": "100313899", + "pull": Object { + "number": "5", + "repo": Object { + "name": "example-github", + "owner": "sourcecred", + "type": "REPO", + }, + "type": "PULL", + }, + "type": "REVIEW", + }, + "type": "COMMENT", + }, +} +`; + +exports[`plugins/github/address snapshots as expected: user 1`] = ` +Object { + "address": Array [ + "sourcecred", + "github", + "userlike", + "decentralion", + ], + "structured": Object { + "login": "decentralion", + "type": "USERLIKE", + }, +} +`; diff --git a/src/v3/plugins/github/address.js b/src/v3/plugins/github/address.js new file mode 100644 index 0000000..10a8ada --- /dev/null +++ b/src/v3/plugins/github/address.js @@ -0,0 +1,199 @@ +// @flow + +import {NodeAddress, type NodeAddressT} from "../../core/graph"; + +export opaque type GithubAddressT: NodeAddressT = NodeAddressT; + +const GITHUB_PREFIX = NodeAddress.fromParts(["sourcecred", "github"]); +function githubAddress(...parts: string[]): GithubAddressT { + return NodeAddress.append(GITHUB_PREFIX, ...parts); +} + +export type RepoAddress = {| + +type: "REPO", + +owner: string, + +name: string, +|}; +export type IssueAddress = {| + +type: "ISSUE", + +repo: RepoAddress, + +number: string, +|}; +export type PullAddress = {| + +type: "PULL", + +repo: RepoAddress, + +number: string, +|}; +export type ReviewAddress = {| + +type: "REVIEW", + +pull: PullAddress, + +fragment: string, +|}; +export type CommentAddress = {| + +type: "COMMENT", + +parent: IssueAddress | PullAddress | ReviewAddress, + +fragment: string, +|}; +export type UserlikeAddress = {| + +type: "USERLIKE", + +login: string, +|}; + +export type StructuredAddress = + | RepoAddress + | IssueAddress + | PullAddress + | ReviewAddress + | CommentAddress + | UserlikeAddress; + +export function structure(x: GithubAddressT): StructuredAddress { + function fail() { + return new Error(`Bad address: ${NodeAddress.toString(x)}`); + } + if (!NodeAddress.hasPrefix(x, GITHUB_PREFIX)) { + throw fail(); + } + const [_unused_sc, _unused_gh, kind, ...rest] = NodeAddress.toParts(x); + switch (kind) { + case "repo": { + if (rest.length !== 2) { + throw fail(); + } + const [owner, name] = rest; + return {type: "REPO", owner, name}; + } + case "issue": { + if (rest.length !== 3) { + throw fail(); + } + const [owner, name, number] = rest; + const repo = {type: "REPO", owner, name}; + return {type: "ISSUE", repo, number}; + } + case "pull": { + if (rest.length !== 3) { + throw fail(); + } + const [owner, name, number] = rest; + const repo = {type: "REPO", owner, name}; + return {type: "PULL", repo, number}; + } + case "review": { + if (rest.length !== 4) { + throw fail(); + } + const [owner, name, pullNumber, fragment] = rest; + const repo = {type: "REPO", owner, name}; + const pull = {type: "PULL", repo, number: pullNumber}; + return {type: "REVIEW", pull, fragment}; + } + case "comment": { + if (rest.length < 1) { + throw fail(); + } + const [subkind, ...subrest] = rest; + switch (subkind) { + case "issue": { + if (subrest.length !== 4) { + throw fail(); + } + const [owner, name, issueNumber, fragment] = subrest; + const repo = {type: "REPO", owner, name}; + const issue = {type: "ISSUE", repo, number: issueNumber}; + return {type: "COMMENT", parent: issue, fragment}; + } + case "pull": { + if (subrest.length !== 4) { + throw fail(); + } + const [owner, name, pullNumber, fragment] = subrest; + const repo = {type: "REPO", owner, name}; + const pull = {type: "PULL", repo, number: pullNumber}; + return {type: "COMMENT", parent: pull, fragment}; + } + case "review": { + if (subrest.length !== 5) { + throw fail(); + } + const [owner, name, pullNumber, reviewFragment, fragment] = subrest; + const repo = {type: "REPO", owner, name}; + const pull = {type: "PULL", repo, number: pullNumber}; + const review = {type: "REVIEW", pull, fragment: reviewFragment}; + return {type: "COMMENT", parent: review, fragment}; + } + default: + throw fail(); + } + } + case "userlike": { + if (rest.length !== 1) { + throw fail(); + } + const [login] = rest; + return {type: "USERLIKE", login}; + } + default: + throw fail(); + } +} + +export function destructure(x: StructuredAddress): GithubAddressT { + switch (x.type) { + case "REPO": + return githubAddress("repo", x.owner, x.name); + case "ISSUE": + return githubAddress("issue", x.repo.owner, x.repo.name, x.number); + case "PULL": + return githubAddress("pull", x.repo.owner, x.repo.name, x.number); + case "REVIEW": + return githubAddress( + "review", + x.pull.repo.owner, + x.pull.repo.name, + x.pull.number, + x.fragment + ); + case "COMMENT": + switch (x.parent.type) { + case "ISSUE": + return githubAddress( + "comment", + "issue", + x.parent.repo.owner, + x.parent.repo.name, + x.parent.number, + x.fragment + ); + case "PULL": + return githubAddress( + "comment", + "pull", + x.parent.repo.owner, + x.parent.repo.name, + x.parent.number, + x.fragment + ); + case "REVIEW": + return githubAddress( + "comment", + "review", + x.parent.pull.repo.owner, + x.parent.pull.repo.name, + x.parent.pull.number, + x.parent.fragment, + x.fragment + ); + default: + // eslint-disable-next-line no-unused-expressions + (x.parent.type: empty); + throw new Error(`Bad comment parent type: ${x.parent.type}`); + } + case "USERLIKE": + return githubAddress("userlike", x.login); + default: + // eslint-disable-next-line no-unused-expressions + (x.type: empty); + throw new Error(`Unexpected type ${x.type}`); + } +} diff --git a/src/v3/plugins/github/address.test.js b/src/v3/plugins/github/address.test.js new file mode 100644 index 0000000..b0f4eac --- /dev/null +++ b/src/v3/plugins/github/address.test.js @@ -0,0 +1,207 @@ +// @flow + +import {structure, destructure} from "./address"; +import {NodeAddress} from "../../core/graph"; + +describe("plugins/github/address", () => { + const repo = () => ({ + type: "REPO", + owner: "sourcecred", + name: "example-github", + }); + const issue = () => ({type: "ISSUE", repo: repo(), number: "2"}); + const pull = () => ({type: "PULL", repo: repo(), number: "5"}); + const review = () => ({type: "REVIEW", pull: pull(), fragment: "100313899"}); + const issueComment = () => ({ + type: "COMMENT", + parent: issue(), + fragment: "373768703", + }); + const pullComment = () => ({ + type: "COMMENT", + parent: pull(), + fragment: "396430464", + }); + const reviewComment = () => ({ + type: "COMMENT", + parent: review(), + fragment: "171460198", + }); + const user = () => ({type: "USERLIKE", login: "decentralion"}); + const examples = { + repo, + issue, + pull, + review, + issueComment, + pullComment, + reviewComment, + user, + }; + + describe("Structured -> Raw -> Structured is identity", () => { + Object.keys(examples).forEach((example) => { + it(example, () => { + const instance = examples[example](); + expect(structure(destructure(instance))).toEqual(instance); + }); + }); + }); + + describe("Raw -> Structured -> Raw is identity", () => { + Object.keys(examples).forEach((example) => { + it(example, () => { + const instance = examples[example](); + const raw = destructure(instance); + expect(destructure(structure(raw))).toEqual(raw); + }); + }); + }); + + describe("snapshots as expected:", () => { + Object.keys(examples).forEach((example) => { + it(example, () => { + const instance = examples[example](); + const raw = NodeAddress.toParts(destructure(instance)); + expect({address: raw, structured: instance}).toMatchSnapshot(); + }); + }); + }); + + describe("errors on", () => { + describe("structure(...) with", () => { + function expectBadAddress(name: string, parts: $ReadOnlyArray) { + it(name, () => { + const address = NodeAddress.fromParts([ + "sourcecred", + "github", + ...parts, + ]); + // $ExpectFlowError + expect(() => structure(address)).toThrow("Bad address"); + }); + } + function checkBadCases( + partses: $ReadOnlyArray<{| + +name: string, + +parts: $ReadOnlyArray, + |}> + ) { + let partsAccumulator = []; + for (const {name, parts} of partses) { + const theseParts = [...partsAccumulator, ...parts]; + expectBadAddress(name, theseParts); + partsAccumulator = theseParts; + } + } + it("undefined", () => { + // $ExpectFlowError + expect(() => structure(undefined)).toThrow("undefined"); + }); + it("null", () => { + // $ExpectFlowError + expect(() => structure(null)).toThrow("null"); + }); + it("with bad prefix", () => { + // $ExpectFlowError + expect(() => structure(NodeAddress.fromParts(["foo"]))).toThrow( + "Bad address" + ); + }); + expectBadAddress("no kind", []); + describe("repository with", () => { + checkBadCases([ + {name: "no owner", parts: ["repo"]}, + {name: "no name", parts: ["owner"]}, + {name: "extra parts", parts: ["name", "foo"]}, + ]); + }); + describe("issue with", () => { + checkBadCases([ + {name: "no owner", parts: ["issue"]}, + {name: "no name", parts: ["owner"]}, + {name: "no number", parts: ["name"]}, + {name: "extra parts", parts: ["123", "foo"]}, + ]); + }); + describe("pull request with", () => { + checkBadCases([ + {name: "no owner", parts: ["pull"]}, + {name: "no name", parts: ["owner"]}, + {name: "no number", parts: ["name"]}, + {name: "extra parts", parts: ["123", "foo"]}, + ]); + }); + describe("pull request review with", () => { + checkBadCases([ + {name: "no owner", parts: ["review"]}, + {name: "no name", parts: ["owner"]}, + {name: "no number", parts: ["name"]}, + {name: "no fragment", parts: ["123"]}, + {name: "extra parts", parts: ["987", "foo"]}, + ]); + }); + describe("comment", () => { + expectBadAddress("with no subkind", ["comment"]); + expectBadAddress("with bad subkind", ["comment", "icecream"]); + describe("on issue with", () => { + checkBadCases([ + {name: "no owner", parts: ["comment", "issue"]}, + {name: "no name", parts: ["owner"]}, + {name: "no number", parts: ["name"]}, + {name: "no fragment", parts: ["123"]}, + {name: "extra parts", parts: ["987", "foo"]}, + ]); + }); + describe("on pull request with", () => { + checkBadCases([ + {name: "no owner", parts: ["comment", "pull"]}, + {name: "no name", parts: ["owner"]}, + {name: "no number", parts: ["name"]}, + {name: "no fragment", parts: ["123"]}, + {name: "extra parts", parts: ["987", "foo"]}, + ]); + }); + describe("on pull request review with", () => { + checkBadCases([ + {name: "no owner", parts: ["comment", "review"]}, + {name: "no name", parts: ["owner"]}, + {name: "no number", parts: ["name"]}, + {name: "no review fragment", parts: ["123"]}, + {name: "no comment fragment", parts: ["987"]}, + {name: "extra parts", parts: ["654", "foo"]}, + ]); + }); + }); + describe("userlike", () => { + checkBadCases([ + {name: "no login", parts: ["userlike"]}, + {name: "extra parts", parts: ["decentra", "lion"]}, + ]); + }); + }); + + describe("destructure(...) with", () => { + it("null", () => { + // $ExpectFlowError + expect(() => destructure(null)).toThrow("null"); + }); + it("undefined", () => { + // $ExpectFlowError + expect(() => destructure(undefined)).toThrow("undefined"); + }); + it("bad type", () => { + // $ExpectFlowError + expect(() => destructure({type: "ICE_CREAM"})).toThrow( + "Unexpected type" + ); + }); + it("bad comment type", () => { + expect(() => { + // $ExpectFlowError + destructure({type: "COMMENT", parent: {type: "ICE_CREAM"}}); + }).toThrow("Bad comment parent type"); + }); + }); + }); +});