diff --git a/package.json b/package.json index 56d4f02..32296d8 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "react": "^16.2.0", "react-dev-utils": "^5.0.0", "react-dom": "^16.2.0", + "react-test-renderer": "^16.2.0", "style-loader": "0.19.0", "sw-precache-webpack-plugin": "0.11.4", "url-loader": "0.6.2", diff --git a/src/plugins/artifact/editor/App.js b/src/plugins/artifact/editor/App.js index 43b008e..7d8ebb6 100644 --- a/src/plugins/artifact/editor/App.js +++ b/src/plugins/artifact/editor/App.js @@ -3,14 +3,23 @@ import React from "react"; import {StyleSheet, css} from "aphrodite/no-important"; -import type {Node} from "../../../core/graph"; -import {ArtifactList} from "./ArtifactList"; -import {GitHubGraphFetcher} from "./GitHubGraphFetcher"; +import "./pluginAdapter"; + +import type {Graph, Node} from "../../../core/graph"; import type {ArtifactNodePayload} from "../artifactPlugin"; +import type { + NodePayload as GitHubNodePayload, + EdgePayload as GitHubEdgePayload, +} from "../../github/githubPlugin"; +import {ArtifactList} from "./ArtifactList"; +import {ContributionList} from "./ContributionList"; +import {GitHubGraphFetcher} from "./GitHubGraphFetcher"; +import standardAdapterSet from "./standardAdapterSet"; type Props = {}; type State = { artifacts: Node[], + githubGraph: ?Graph, }; function createSampleArtifact(name) { @@ -30,6 +39,7 @@ export default class App extends React.Component { super(); this.state = { artifacts: [], + githubGraph: null, }; } @@ -39,6 +49,11 @@ export default class App extends React.Component {

Artifact editor

+ { + this.setState({githubGraph}); + }} + /> { @@ -47,7 +62,10 @@ export default class App extends React.Component { })); }} /> - console.log(x)} /> + ); } diff --git a/src/plugins/artifact/editor/ContributionList.js b/src/plugins/artifact/editor/ContributionList.js new file mode 100644 index 0000000..f03732a --- /dev/null +++ b/src/plugins/artifact/editor/ContributionList.js @@ -0,0 +1,65 @@ +// @flow + +import React from "react"; + +import type {Address} from "../../../core/address"; +import {AdapterSet} from "./adapterSet"; +import {Graph} from "../../../core/graph"; + +type Props = { + graph: ?Graph, + adapters: AdapterSet, +}; +type State = {}; + +export class ContributionList extends React.Component { + render() { + return ( +
+

Contributions

+ {this.renderTable()} +
+ ); + } + + renderTable() { + if (this.props.graph == null) { + return
(no graph)
; + } else { + const graph: Graph = this.props.graph; + return ( + + + + + + + + + + {this.props.graph.getAllNodes().map((node) => { + const adapter = this.props.adapters.getAdapter(node); + if (adapter == null) { + return ( + + + + ); + } else { + return ( + + + + + + ); + } + })} + +
TitleArtifactWeight
+ unknown (plugin: {node.address.pluginName}) +
{adapter.extractTitle(graph, node)}[TODO][TODO]
+ ); + } + } +} diff --git a/src/plugins/artifact/editor/adapterSet.js b/src/plugins/artifact/editor/adapterSet.js new file mode 100644 index 0000000..e2e643b --- /dev/null +++ b/src/plugins/artifact/editor/adapterSet.js @@ -0,0 +1,20 @@ +// @flow + +import type {Node} from "../../../core/graph"; +import type {PluginAdapter} from "./pluginAdapter"; + +export class AdapterSet { + adapters: {[pluginName: string]: PluginAdapter}; + + constructor() { + this.adapters = {}; + } + + addAdapter(adapter: PluginAdapter): void { + this.adapters[adapter.pluginName] = adapter; + } + + getAdapter(node: Node): ?PluginAdapter { + return this.adapters[node.address.pluginName]; + } +} diff --git a/src/plugins/artifact/editor/adapters/__snapshots__/githubPluginAdapter.test.js.snap b/src/plugins/artifact/editor/adapters/__snapshots__/githubPluginAdapter.test.js.snap new file mode 100644 index 0000000..8be7004 --- /dev/null +++ b/src/plugins/artifact/editor/adapters/__snapshots__/githubPluginAdapter.test.js.snap @@ -0,0 +1,324 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`githubPluginAdapter operates on the example repo 1`] = ` +Array [ + Object { + "id": Object { + "id": "MDE3OlB1bGxSZXF1ZXN0UmV2aWV3MTAwMzE0MDM4", + "type": "PULL_REQUEST_REVIEW", + }, + "payload": Object { + "body": "I'm sold", + "state": "APPROVED", + }, + "rendered":
+ type: + PULL_REQUEST_REVIEW + (details to be implemented) +
, + "title": "review of #5: This pull request will be more contentious. I can feel it...", + "type": "PULL_REQUEST_REVIEW", + }, + Object { + "id": Object { + "id": "MDE3OlB1bGxSZXF1ZXN0UmV2aWV3MTAwMzEzODk5", + "type": "PULL_REQUEST_REVIEW", + }, + "payload": Object { + "body": "hmmm.jpg", + "state": "CHANGES_REQUESTED", + }, + "rendered":
+ type: + PULL_REQUEST_REVIEW + (details to be implemented) +
, + "title": "review of #5: This pull request will be more contentious. I can feel it...", + "type": "PULL_REQUEST_REVIEW", + }, + Object { + "id": Object { + "id": "MDExOlB1bGxSZXF1ZXN0MTcxODg3NzQx", + "type": "PULL_REQUEST", + }, + "payload": Object { + "body": "Oh look, it's a pull request.", + "number": 3, + "title": "Add README, merge via PR.", + }, + "rendered":
+ type: + PULL_REQUEST + (details to be implemented) +
, + "title": "#3: Add README, merge via PR.", + "type": "PULL_REQUEST", + }, + Object { + "id": Object { + "id": "MDExOlB1bGxSZXF1ZXN0MTcxODg4NTIy", + "type": "PULL_REQUEST", + }, + "payload": Object { + "body": "@wchargin could you please do the following: +- add a commit comment +- add a review comment requesting some trivial change +- i'll change it +- then approve the pr", + "number": 5, + "title": "This pull request will be more contentious. I can feel it...", + }, + "rendered":
+ type: + PULL_REQUEST + (details to be implemented) +
, + "title": "#5: This pull request will be more contentious. I can feel it...", + "type": "PULL_REQUEST", + }, + Object { + "id": Object { + "id": "MDEyOklzc3VlQ29tbWVudDM2OTE2MjIyMg==", + "type": "COMMENT", + }, + "payload": Object { + "body": "It seems apropos to reference something from a pull request comment... eg: #2 ", + "url": "https://github.com/sourcecred/example-repo/pull/3#issuecomment-369162222", + }, + "rendered":
+ type: + COMMENT + (details to be implemented) +
, + "title": "comment on #3: Add README, merge via PR.", + "type": "COMMENT", + }, + Object { + "id": Object { + "id": "MDEyOklzc3VlQ29tbWVudDM3Mzc2ODQ0Mg==", + "type": "COMMENT", + }, + "payload": Object { + "body": "A wild COMMENT appeared!", + "url": "https://github.com/sourcecred/example-repo/issues/6#issuecomment-373768442", + }, + "rendered":
+ type: + COMMENT + (details to be implemented) +
, + "title": "comment on #6: An issue with comments", + "type": "COMMENT", + }, + Object { + "id": Object { + "id": "MDEyOklzc3VlQ29tbWVudDM3Mzc2ODUzOA==", + "type": "COMMENT", + }, + "payload": Object { + "body": "And the maintainer said, \\"Let there be comments!\\"", + "url": "https://github.com/sourcecred/example-repo/issues/6#issuecomment-373768538", + }, + "rendered":
+ type: + COMMENT + (details to be implemented) +
, + "title": "comment on #6: An issue with comments", + "type": "COMMENT", + }, + Object { + "id": Object { + "id": "MDEyOklzc3VlQ29tbWVudDM3Mzc2ODcwMw==", + "type": "COMMENT", + }, + "payload": Object { + "body": "It should also be possible to reference by exact url: https://github.com/sourcecred/example-repo/issues/6", + "url": "https://github.com/sourcecred/example-repo/issues/2#issuecomment-373768703", + }, + "rendered":
+ type: + COMMENT + (details to be implemented) +
, + "title": "comment on #2: A referencing issue.", + "type": "COMMENT", + }, + Object { + "id": Object { + "id": "MDEyOklzc3VlQ29tbWVudDM3Mzc2ODg1MA==", + "type": "COMMENT", + }, + "payload": Object { + "body": "We might also reference individual comments directly. +https://github.com/sourcecred/example-repo/issues/6#issuecomment-373768538", + "url": "https://github.com/sourcecred/example-repo/issues/2#issuecomment-373768850", + }, + "rendered":
+ type: + COMMENT + (details to be implemented) +
, + "title": "comment on #2: A referencing issue.", + "type": "COMMENT", + }, + Object { + "id": Object { + "id": "MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDE3MTQ2MDE5OA==", + "type": "PULL_REQUEST_REVIEW_COMMENT", + }, + "payload": Object { + "body": "seems a bit capricious", + "url": "https://github.com/sourcecred/example-repo/pull/5#discussion_r171460198", + }, + "rendered":
+ type: + PULL_REQUEST_REVIEW_COMMENT + (details to be implemented) +
, + "title": "comment on review of #5: This pull request will be more contentious. I can feel it...", + "type": "PULL_REQUEST_REVIEW_COMMENT", + }, + Object { + "id": Object { + "id": "MDQ6VXNlcjE0MDAwMjM=", + "type": "USER", + }, + "payload": Object { + "login": "dandelionmane", + }, + "rendered":
+ type: + USER + (details to be implemented) +
, + "title": "dandelionmane", + "type": "USER", + }, + Object { + "id": Object { + "id": "MDQ6VXNlcjQzMTc4MDY=", + "type": "USER", + }, + "payload": Object { + "login": "wchargin", + }, + "rendered":
+ type: + USER + (details to be implemented) +
, + "title": "wchargin", + "type": "USER", + }, + Object { + "id": Object { + "id": "MDU6SXNzdWUzMDA5MzQ4MTg=", + "type": "ISSUE", + }, + "payload": Object { + "body": "This is just an example issue.", + "number": 1, + "title": "An example issue.", + }, + "rendered":
+ type: + ISSUE + (details to be implemented) +
, + "title": "#1: An example issue.", + "type": "ISSUE", + }, + Object { + "id": Object { + "id": "MDU6SXNzdWUzMDA5MzQ5ODA=", + "type": "ISSUE", + }, + "payload": Object { + "body": "This issue references another issue, namely #1", + "number": 2, + "title": "A referencing issue.", + }, + "rendered":
+ type: + ISSUE + (details to be implemented) +
, + "title": "#2: A referencing issue.", + "type": "ISSUE", + }, + Object { + "id": Object { + "id": "MDU6SXNzdWUzMDA5MzYzNzQ=", + "type": "ISSUE", + }, + "payload": Object { + "body": "Alas, its life as an open issue had only just begun.", + "number": 4, + "title": "A closed pull request", + }, + "rendered":
+ type: + ISSUE + (details to be implemented) +
, + "title": "#4: A closed pull request", + "type": "ISSUE", + }, + Object { + "id": Object { + "id": "MDU6SXNzdWUzMDU5OTM3NzM=", + "type": "ISSUE", + }, + "payload": Object { + "body": "This issue shall shortly have a few comments.", + "number": 6, + "title": "An issue with comments", + }, + "rendered":
+ type: + ISSUE + (details to be implemented) +
, + "title": "#6: An issue with comments", + "type": "ISSUE", + }, + Object { + "id": Object { + "id": "MDU6SXNzdWUzMDY5ODM1NTI=", + "type": "ISSUE", + }, + "payload": Object { + "body": "Deal with this, naive string display algorithms!!!!!", + "number": 7, + "title": "An issue with an extremely long title, which even has a VerySuperFragicalisticialiManyCharacterUberLongTriplePlusGood word in it, and should really be truncated intelligently or something", + }, + "rendered":
+ type: + ISSUE + (details to be implemented) +
, + "title": "#7: An issue with an extremely long title, which even has a VerySuperFragicalisticialiManyCharacterUberLongTriplePlusGood word in it, and should really be truncated intelligently or something", + "type": "ISSUE", + }, + Object { + "id": Object { + "id": "MDU6SXNzdWUzMDY5ODUzNjc=", + "type": "ISSUE", + }, + "payload": Object { + "body": "Issue with Unicode: ศดแˆฒ๐ฃณๆฅข๐Ÿ‘ :heart: ๐ค”๐ค๐ค€๐ค‘๐ค๐ค‰๐ค”๐คŒ๐ค„๐ค๐ค โค๏ธ +Issue with Unicode: ศดแˆฒ๐ฃณๆฅข๐Ÿ‘ :heart: ๐ค”๐ค๐ค€๐ค‘๐ค๐ค‰๐ค”๐คŒ๐ค„๐ค๐ค โค๏ธ", + "number": 8, + "title": "Issue with Unicode: ศดแˆฒ๐ฃณๆฅข๐Ÿ‘ :heart: ๐ค”๐ค๐ค€๐ค‘๐ค๐ค‰๐ค”๐คŒ๐ค„๐ค๐ค โค๏ธ", + }, + "rendered":
+ type: + ISSUE + (details to be implemented) +
, + "title": "#8: Issue with Unicode: ศดแˆฒ๐ฃณๆฅข๐Ÿ‘ :heart: ๐ค”๐ค๐ค€๐ค‘๐ค๐ค‰๐ค”๐คŒ๐ค„๐ค๐ค โค๏ธ", + "type": "ISSUE", + }, +] +`; diff --git a/src/plugins/artifact/editor/adapters/githubPluginAdapter.js b/src/plugins/artifact/editor/adapters/githubPluginAdapter.js new file mode 100644 index 0000000..e4f903a --- /dev/null +++ b/src/plugins/artifact/editor/adapters/githubPluginAdapter.js @@ -0,0 +1,110 @@ +// @flow + +import React from "react"; + +import {Graph} from "../../../../core/graph"; +import type {Node} from "../../../../core/graph"; +import type { + NodePayload, + EdgeID, + NodeType, + NodeTypes, + IssueNodePayload, + PullRequestNodePayload, + CommentNodePayload, + PullRequestReviewCommentNodePayload, + PullRequestReviewNodePayload, + AuthorNodePayload, +} from "../../../github/githubPlugin"; +import type {PluginAdapter} from "../pluginAdapter"; +import {GITHUB_PLUGIN_NAME, getNodeType} from "../../../github/githubPlugin"; + +const adapter: PluginAdapter = { + pluginName: GITHUB_PLUGIN_NAME, + + renderer: class GitHubNodeRenderer extends React.Component<{ + graph: Graph, + node: Node, + }> { + render() { + const type = adapter.extractType(this.props.graph, this.props.node); + return
type: {type} (details to be implemented)
; + } + }, + + extractType(graph: *, node: Node): NodeType { + return getNodeType(node); + }, + + extractTitle(graph: *, node: Node): string { + // NOTE: If the graph is malformed such that there are containment + // cycles, then this function may blow the stack or fail to + // terminate. (If necessary, we can fix this by tracking all + // previously queried IDs.) + function extractParentTitles(node: Node): string[] { + return graph + .getInEdges(node.address) + .filter((e) => { + const edgeID: EdgeID = JSON.parse(e.address.id); + return edgeID.type === "CONTAINMENT"; + }) + .map((e) => graph.getNode(e.src)) + .map((container) => { + return adapter.extractTitle(graph, container); + }); + } + function extractIssueOrPrTitle( + node: Node + ) { + return `#${node.payload.number}: ${node.payload.title}`; + } + function extractCommentTitle( + kind: string, + node: Node + ) { + const parentTitles = extractParentTitles(node); + if (parentTitles.length === 0) { + // Should never happen. + return "comment (orphaned)"; + } else { + // Should just be one parent. + return `comment on ${parentTitles.join(" and ")}`; + } + } + function extractPRReviewTitle(node: Node) { + const parentTitles = extractParentTitles(node); + if (parentTitles.length === 0) { + // Should never happen. + return "pull request review (orphaned)"; + } else { + // Should just be one parent. + return `review of ${parentTitles.join(" and ")}`; + } + } + function extractAuthorTitle(node: Node) { + return node.payload.login; + } + type TypedNodeToStringExtractor = >( + T + ) => (node: Node<$ElementType>) => string; + const extractors: $Exact<$ObjMap> = { + ISSUE: extractIssueOrPrTitle, + PULL_REQUEST: extractIssueOrPrTitle, + COMMENT: (node) => extractCommentTitle("comment", node), + PULL_REQUEST_REVIEW_COMMENT: (node) => + extractCommentTitle("review comment", node), + PULL_REQUEST_REVIEW: extractPRReviewTitle, + USER: extractAuthorTitle, + ORGANIZATION: extractAuthorTitle, + BOT: extractAuthorTitle, + }; + function fallbackAccessor(node: Node) { + throw new Error(`unknown node type: ${getNodeType(node)}`); + } + return (extractors[getNodeType(node)] || fallbackAccessor)( + (node: Node) + ); + }, +}; + +export default adapter; diff --git a/src/plugins/artifact/editor/adapters/githubPluginAdapter.test.js b/src/plugins/artifact/editor/adapters/githubPluginAdapter.test.js new file mode 100644 index 0000000..53d3b1b --- /dev/null +++ b/src/plugins/artifact/editor/adapters/githubPluginAdapter.test.js @@ -0,0 +1,36 @@ +// @flow + +import React from "react"; +import reactTestRenderer from "react-test-renderer"; +import stringify from "json-stable-stringify"; + +import type {NodeID} from "../../../github/githubPlugin"; +import {GithubParser} from "../../../github/githubPlugin"; +import exampleRepoData from "../../../github/demoData/example-repo.json"; +import adapter from "./githubPluginAdapter"; + +describe("githubPluginAdapter", () => { + it("operates on the example repo", () => { + const parser = new GithubParser("sourcecred/example-repo"); + parser.addData(exampleRepoData.data); + const graph = parser.graph; + + const result = graph + .getAllNodes() + .map((node) => ({ + id: (JSON.parse(node.address.id): NodeID), + payload: node.payload, + type: adapter.extractType(graph, node), + title: adapter.extractTitle(graph, node), + rendered: reactTestRenderer.create( + + ), + })) + .sort((a, b) => { + const ka = stringify(a.id); + const kb = stringify(b.id); + return ka > kb ? 1 : ka < kb ? -1 : 0; + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/artifact/editor/pluginAdapter.js b/src/plugins/artifact/editor/pluginAdapter.js new file mode 100644 index 0000000..85bcce7 --- /dev/null +++ b/src/plugins/artifact/editor/pluginAdapter.js @@ -0,0 +1,13 @@ +// @flow + +import type {Graph, Node} from "../../../core/graph"; +import type {ComponentType} from "react"; + +export interface PluginAdapter<-NodePayload> { + pluginName: string; + renderer: $Subtype< + ComponentType<{graph: Graph, node: Node}> + >; + extractType(graph: Graph, node: Node): string; + extractTitle(graph: Graph, node: Node): string; +} diff --git a/src/plugins/artifact/editor/standardAdapterSet.js b/src/plugins/artifact/editor/standardAdapterSet.js new file mode 100644 index 0000000..f0e6737 --- /dev/null +++ b/src/plugins/artifact/editor/standardAdapterSet.js @@ -0,0 +1,9 @@ +// @flow + +import {AdapterSet} from "./adapterSet"; +import githubPluginAdapter from "./adapters/githubPluginAdapter"; + +const adapterSet = new AdapterSet(); +adapterSet.addAdapter(githubPluginAdapter); + +export default adapterSet; diff --git a/yarn.lock b/yarn.lock index b2f87b6..ef81d14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5452,6 +5452,14 @@ react-error-overlay@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-4.0.0.tgz#d198408a85b4070937a98667f500c832f86bd5d4" +react-test-renderer@^16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.2.0.tgz#bddf259a6b8fcd8555f012afc8eacc238872a211" + dependencies: + fbjs "^0.8.16" + object-assign "^4.1.1" + prop-types "^15.6.0" + react@^16.2.0: version "16.2.0" resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"