diff --git a/src/plugins/initiatives/createGraph.js b/src/plugins/initiatives/createGraph.js new file mode 100644 index 0000000..5919219 --- /dev/null +++ b/src/plugins/initiatives/createGraph.js @@ -0,0 +1,98 @@ +// @flow + +import { + Graph, + EdgeAddress, + NodeAddress, + type Edge, + type Node, + type EdgeAddressT, + type NodeAddressT, +} from "../../core/graph"; +import type {ReferenceDetector, URL} from "../../core/references"; +import type {Initiative, InitiativeRepository} from "./initiative"; +import { + initiativeNodeType, + dependsOnEdgeType, + referencesEdgeType, + contributesToEdgeType, + championsEdgeType, +} from "./declaration"; + +function initiativeAddress(initiative: Initiative): NodeAddressT { + return NodeAddress.append( + initiativeNodeType.prefix, + ...NodeAddress.toParts(initiative.tracker) + ); +} + +function initiativeNode(initiative: Initiative): Node { + return { + address: initiativeAddress(initiative), + timestampMs: initiative.timestampMs, + description: initiative.title, + }; +} + +type EdgeFactoryT = (initiative: Initiative, other: NodeAddressT) => Edge; + +function edgeFactory( + prefix: EdgeAddressT, + fromInitiative: boolean +): EdgeFactoryT { + return (initiative: Initiative, other: NodeAddressT): Edge => { + const iAddr = initiativeAddress(initiative); + const src = fromInitiative ? iAddr : other; + const dst = fromInitiative ? other : iAddr; + return { + address: EdgeAddress.append( + prefix, + ...NodeAddress.toParts(initiativeAddress(initiative)), + ...NodeAddress.toParts(other) + ), + timestampMs: initiative.timestampMs, + src, + dst, + }; + }; +} + +const depedencyEdge = edgeFactory(dependsOnEdgeType.prefix, true); +const referenceEdge = edgeFactory(referencesEdgeType.prefix, true); +const contributionEdge = edgeFactory(contributesToEdgeType.prefix, false); +const championEdge = edgeFactory(championsEdgeType.prefix, false); + +export function createGraph( + repo: InitiativeRepository, + refs: ReferenceDetector +): Graph { + const graph = new Graph(); + + for (const initiative of repo.initiatives()) { + // Adds the Initiative node. + graph.addNode(initiativeNode(initiative)); + + // Consider the tracker a contribution. + graph.addEdge(contributionEdge(initiative, initiative.tracker)); + + // Generic approach to adding edges when the reference detector has a hit. + const edgeHandler = ( + urls: $ReadOnlyArray, + createEdge: EdgeFactoryT + ) => { + for (const url of urls) { + const addr = refs.addressFromUrl(url); + if (!addr) continue; + graph.addEdge(createEdge(initiative, addr)); + } + }; + + // Maps the edge types to it's fields. + edgeHandler(initiative.dependencies, depedencyEdge); + edgeHandler(initiative.references, referenceEdge); + edgeHandler(initiative.contributions, contributionEdge); + edgeHandler(initiative.champions, championEdge); + } + + return graph; +} diff --git a/src/plugins/initiatives/createGraph.test.js b/src/plugins/initiatives/createGraph.test.js new file mode 100644 index 0000000..23a8ea0 --- /dev/null +++ b/src/plugins/initiatives/createGraph.test.js @@ -0,0 +1,357 @@ +// @flow + +import { + EdgeAddress, + NodeAddress, + type EdgeAddressT, + type NodeAddressT, +} from "../../core/graph"; +import type {ReferenceDetector, URL} from "../../core/references"; +import type {Initiative, InitiativeRepository} from "./initiative"; +import {topicAddress} from "../discourse/address"; +import {createGraph} from "./createGraph"; +import { + initiativeNodeType, + dependsOnEdgeType, + referencesEdgeType, + contributesToEdgeType, + championsEdgeType, +} from "./declaration"; + +class MockInitiativeRepository implements InitiativeRepository { + _counter: number; + _initiatives: Initiative[]; + + constructor() { + this._counter = 1; + this._initiatives = []; + } + + addInitiative(shape?: $Shape): Initiative { + const num = this._counter; + this._counter++; + + const initiative: Initiative = { + title: `Example Initiative ${num}`, + timestampMs: 400 + num, + completed: false, + tracker: topicAddress("https://example.com", num), + dependencies: [], + references: [], + contributions: [], + champions: [], + ...shape, + }; + + this._initiatives.push(initiative); + return initiative; + } + + initiatives() { + return [...this._initiatives]; + } +} + +class MockReferenceDetector implements ReferenceDetector { + _references: Map; + + constructor() { + this._references = new Map(); + jest.spyOn(this, "addressFromUrl"); + } + + addReference(url: URL, address: NodeAddressT) { + this._references.set(url, address); + } + + addressFromUrl(url: URL): ?NodeAddressT { + return this._references.get(url); + } +} + +function example() { + return { + repo: new MockInitiativeRepository(), + refs: new MockReferenceDetector(), + }; +} + +function exampleNodeAddress(id: number): NodeAddressT { + return NodeAddress.fromParts(["example", String(id)]); +} + +function discourseInitiativeAddress(id: number): NodeAddressT { + return NodeAddress.append( + initiativeNodeType.prefix, + ...NodeAddress.toParts(topicAddress("https://example.com", id)) + ); +} + +function edgeAddress(prefix: EdgeAddressT) { + return ( + initiativeAddress: NodeAddressT, + other: NodeAddressT + ): EdgeAddressT => { + return EdgeAddress.append( + prefix, + ...NodeAddress.toParts(initiativeAddress), + ...NodeAddress.toParts(other) + ); + }; +} + +const dependencyEdgeAddress = edgeAddress(dependsOnEdgeType.prefix); +const referenceEdgeAddress = edgeAddress(referencesEdgeType.prefix); +const contributionEdgeAddress = edgeAddress(contributesToEdgeType.prefix); +const championEdgeAddress = edgeAddress(championsEdgeType.prefix); + +describe("plugins/initiatives/createGraph", () => { + describe("createGraph", () => { + it("should add initiative nodes to the graph", () => { + // Given + const {repo, refs} = example(); + repo.addInitiative(); + repo.addInitiative(); + + // When + const graph = createGraph(repo, refs); + + // Then + const nodes = Array.from( + graph.nodes({prefix: initiativeNodeType.prefix}) + ); + expect(nodes).toEqual([ + { + description: "Example Initiative 1", + timestampMs: 401, + address: discourseInitiativeAddress(1), + }, + { + description: "Example Initiative 2", + timestampMs: 402, + address: discourseInitiativeAddress(2), + }, + ]); + }); + + it("should add the tracker as a contribution edge", () => { + // Given + const {repo, refs} = example(); + const i1 = repo.addInitiative(); + + // When + const graph = createGraph(repo, refs); + + // Then + const contributions = Array.from( + graph.edges({ + addressPrefix: contributesToEdgeType.prefix, + showDangling: true, + }) + ); + expect(contributions).toEqual([ + { + address: contributionEdgeAddress( + discourseInitiativeAddress(1), + i1.tracker + ), + dst: discourseInitiativeAddress(1), + src: i1.tracker, + timestampMs: i1.timestampMs, + }, + ]); + }); + + describe("reference detection attempts", () => { + it("should attempt to resolve dependency URLs", () => { + // Given + const {repo, refs} = example(); + repo.addInitiative({ + dependencies: ["https://example.com/1"], + }); + + // When + createGraph(repo, refs); + + // Then + expect(refs.addressFromUrl).toHaveBeenCalledWith( + "https://example.com/1" + ); + }); + + it("should attempt to resolve reference URLs", () => { + // Given + const {repo, refs} = example(); + repo.addInitiative({ + references: ["https://example.com/2"], + }); + + // When + createGraph(repo, refs); + + // Then + expect(refs.addressFromUrl).toHaveBeenCalledWith( + "https://example.com/2" + ); + }); + + it("should attempt to resolve contribution URLs", () => { + // Given + const {repo, refs} = example(); + repo.addInitiative({ + contributions: ["https://example.com/3"], + }); + + // When + createGraph(repo, refs); + + // Then + expect(refs.addressFromUrl).toHaveBeenCalledWith( + "https://example.com/3" + ); + }); + + it("should attempt to resolve champion URLs", () => { + // Given + const {repo, refs} = example(); + repo.addInitiative({ + champions: ["https://example.com/4"], + }); + + // When + createGraph(repo, refs); + + // Then + expect(refs.addressFromUrl).toHaveBeenCalledWith( + "https://example.com/4" + ); + }); + }); + + describe("adding detected edges", () => { + it("should add edges for dependency URLs it can resolve", () => { + // Given + const {repo, refs} = example(); + refs.addReference("https://example.com/1", exampleNodeAddress(1)); + repo.addInitiative({ + dependencies: ["https://example.com/1", "https://example.com/99"], + }); + + // When + const graph = createGraph(repo, refs); + + // Then + const dependencies = Array.from( + graph.edges({ + addressPrefix: dependsOnEdgeType.prefix, + showDangling: true, + }) + ); + expect(refs.addressFromUrl).toHaveBeenCalledTimes(2); + expect(dependencies).toHaveLength(1); + expect(dependencies).toContainEqual({ + address: dependencyEdgeAddress( + discourseInitiativeAddress(1), + exampleNodeAddress(1) + ), + src: discourseInitiativeAddress(1), + dst: exampleNodeAddress(1), + timestampMs: 401, + }); + }); + + it("should add edges for reference URLs it can resolve", () => { + // Given + const {repo, refs} = example(); + refs.addReference("https://example.com/2", exampleNodeAddress(2)); + repo.addInitiative({ + references: ["https://example.com/2", "https://example.com/99"], + }); + + // When + const graph = createGraph(repo, refs); + + // Then + const references = Array.from( + graph.edges({ + addressPrefix: referencesEdgeType.prefix, + showDangling: true, + }) + ); + expect(refs.addressFromUrl).toHaveBeenCalledTimes(2); + expect(references).toHaveLength(1); + expect(references).toContainEqual({ + address: referenceEdgeAddress( + discourseInitiativeAddress(1), + exampleNodeAddress(2) + ), + src: discourseInitiativeAddress(1), + dst: exampleNodeAddress(2), + timestampMs: 401, + }); + }); + + it("should add edges for contribution URLs it can resolve", () => { + // Given + const {repo, refs} = example(); + refs.addReference("https://example.com/3", exampleNodeAddress(3)); + repo.addInitiative({ + contributions: ["https://example.com/3", "https://example.com/99"], + }); + + // When + const graph = createGraph(repo, refs); + + // Then + const contributions = Array.from( + graph.edges({ + addressPrefix: contributesToEdgeType.prefix, + showDangling: true, + }) + ); + expect(refs.addressFromUrl).toHaveBeenCalledTimes(2); + expect(contributions).toHaveLength(2); + expect(contributions).toContainEqual({ + address: contributionEdgeAddress( + discourseInitiativeAddress(1), + exampleNodeAddress(3) + ), + src: exampleNodeAddress(3), + dst: discourseInitiativeAddress(1), + timestampMs: 401, + }); + }); + + it("should add edges for champion URLs it can resolve", () => { + // Given + const {repo, refs} = example(); + refs.addReference("https://example.com/4", exampleNodeAddress(4)); + repo.addInitiative({ + champions: ["https://example.com/4", "https://example.com/99"], + }); + + // When + const graph = createGraph(repo, refs); + + // Then + const champions = Array.from( + graph.edges({ + addressPrefix: championsEdgeType.prefix, + showDangling: true, + }) + ); + expect(refs.addressFromUrl).toHaveBeenCalledTimes(2); + expect(champions).toHaveLength(1); + expect(champions).toContainEqual({ + address: championEdgeAddress( + discourseInitiativeAddress(1), + exampleNodeAddress(4) + ), + src: exampleNodeAddress(4), + dst: discourseInitiativeAddress(1), + timestampMs: 401, + }); + }); + }); + }); +}); diff --git a/src/plugins/initiatives/initiative.js b/src/plugins/initiatives/initiative.js index 93bfae1..c5294bc 100644 --- a/src/plugins/initiatives/initiative.js +++ b/src/plugins/initiatives/initiative.js @@ -28,3 +28,13 @@ export type Initiative = {| +contributions: $ReadOnlyArray, +champions: $ReadOnlyArray, |}; + +/** + * Represents a source of Initiatives. + */ +export interface InitiativeRepository { + /** + * Gets an array of all Initiatives in this repository. + */ + initiatives(): $ReadOnlyArray; +}