diff --git a/src/plugins/identity/README.md b/src/plugins/identity/README.md new file mode 100644 index 0000000..84ad0d2 --- /dev/null +++ b/src/plugins/identity/README.md @@ -0,0 +1,19 @@ +# SourceCred Identity Plugin + +This folder contains the Identity plugin. Unlike most other plugins, the +Identity plugin does not add any new contributions to the graph. Instead, it +allows collapsing different user accounts together into a shared 'identity' +node. + +To see why this is valuable, imagine that a contributor has an account on both +GitHub and Discourse (potentially with a different username on each service). +We would like to combine these two identities together, so that we can +represent that user's combined cred properly. The Identity plugin enables this. + +Specifically, the instance maintainer can provide a (locally unique) username +for the user, along with a list of aliases the user is known by, e.g. +`github/username` and `discourse/other_username`. The aliases are simple string +representations, that are intended to be easy to maintain by hand in a +configuration file. Then, the identity plugin will provide a list of +`NodeContraction`s that can be used by `Graph.contractNodes` to combine the +user identities as described. diff --git a/src/plugins/identity/alias.js b/src/plugins/identity/alias.js new file mode 100644 index 0000000..c516153 --- /dev/null +++ b/src/plugins/identity/alias.js @@ -0,0 +1,44 @@ +// @flow + +import {type NodeAddressT} from "../../core/graph"; +import {loginAddress as githubAddress} from "../github/nodes"; +import {userAddress as discourseAddress} from "../discourse/createGraph"; + +/** An Alias is a string specification of an identity within another plugin. + * + * For now, the supported alias forms are `github/${githubUsername}` and + * `discourse/${discourseUsername}`. As a courtesty, if the user has put an @ + * before the username, we strip it for them. + * + * This format has been chosen to be moderately extensible and easy to maintain + * by hand, as in the near future the alias files will be maintained by hand. + * This system will not scale well when user-provided plugins need to add to the + * aliasing scheme, so at that point we will rewrite this system. + */ +export type Alias = string; + +export function resolveAlias( + alias: Alias, + discourseUrl: string | null +): NodeAddressT { + const re = /(\w+)\/@?(\w+)/g; + const match = re.exec(alias); + if (match == null) { + throw new Error(`Unable to parse alias: ${alias}`); + } + const prefix = match[1]; + const name = match[2]; + switch (prefix) { + case "github": { + return githubAddress(name); + } + case "discourse": { + if (discourseUrl == null) { + throw new Error(`Can't parse alias ${alias} without Discourse url`); + } + return discourseAddress(discourseUrl, name); + } + default: + throw new Error(`Unknown type for alias: ${alias}`); + } +} diff --git a/src/plugins/identity/alias.test.js b/src/plugins/identity/alias.test.js new file mode 100644 index 0000000..118d607 --- /dev/null +++ b/src/plugins/identity/alias.test.js @@ -0,0 +1,52 @@ +// @flow + +import {resolveAlias} from "./alias"; +import {loginAddress as githubAddress} from "../github/nodes"; +import {userAddress as discourseAddress} from "../discourse/createGraph"; + +describe("src/plugins/identity/alias", () => { + describe("resolveAlias", () => { + describe("errors on", () => { + it("an empty alias", () => { + expect(() => resolveAlias("", null)).toThrow("Unable to parse"); + }); + it("an alias without a /-delimited prefix", () => { + expect(() => resolveAlias("@credbot", null)).toThrow("Unable to parse"); + }); + it("an alias with an unknown prefix", () => { + expect(() => resolveAlias("foo/bar", null)).toThrow( + "Unknown type for alias" + ); + }); + it("a discourse alias without a url", () => { + expect(() => resolveAlias("discourse/foo", null)).toThrow( + "without Discourse url" + ); + }); + }); + describe("works on", () => { + it("a github login", () => { + const actual = resolveAlias("github/login", null); + const expected = githubAddress("login"); + expect(actual).toEqual(expected); + }); + it("a discourse login", () => { + const url = "https://example.com"; + const actual = resolveAlias("discourse/login", url); + const expected = discourseAddress(url, "login"); + expect(actual).toEqual(expected); + }); + it("a github login with prefixed @", () => { + const a = resolveAlias("github/login", null); + const b = resolveAlias("github/@login", null); + expect(a).toEqual(b); + }); + it("a discourse login with prefixed @", () => { + const url = "https://example.com"; + const a = resolveAlias("discourse/login", url); + const b = resolveAlias("discourse/@login", url); + expect(a).toEqual(b); + }); + }); + }); +}); diff --git a/src/plugins/identity/declaration.js b/src/plugins/identity/declaration.js new file mode 100644 index 0000000..dd9d415 --- /dev/null +++ b/src/plugins/identity/declaration.js @@ -0,0 +1,28 @@ +// @flow +/** + * Declaration for the SourceCred identity plugin. + */ +import deepFreeze from "deep-freeze"; +import type {PluginDeclaration} from "../../analysis/pluginDeclaration"; +import type {NodeType} from "../../analysis/types"; +import {NodeAddress, EdgeAddress} from "../../core/graph"; + +export const nodePrefix = NodeAddress.fromParts(["sourcecred", "identity"]); +export const edgePrefix = EdgeAddress.fromParts(["sourcecred", "identity"]); + +export const identityType: NodeType = deepFreeze({ + name: "Identity", + pluralName: "Identities", + prefix: nodePrefix, + defaultWeight: 1, + description: "A combined user identity as specified to SourceCred", +}); + +export const declaration: PluginDeclaration = deepFreeze({ + name: "Identity", + nodePrefix, + edgePrefix, + nodeTypes: [identityType], + edgeTypes: [], + userTypes: [identityType], +}); diff --git a/src/plugins/identity/identity.js b/src/plugins/identity/identity.js new file mode 100644 index 0000000..452d116 --- /dev/null +++ b/src/plugins/identity/identity.js @@ -0,0 +1,35 @@ +// @flow + +import {NodeAddress, type Node} from "../../core/graph"; +import {nodePrefix} from "./declaration"; +import {type Alias} from "./alias"; + +/** + * A Username is a locally (within-instance) unique identifier for a user of + * SourceCred. Must match the USERNAME_PATTERN regexp. + */ +export type Username = string; +export const USERNAME_PATTERN = "^@?([A-Za-z0-9-_]+)$"; + +/** + * Configuration for combining user accounts into a single SourceCred identity. + */ +export type Identity = {| + +username: Username, + +aliases: $ReadOnlyArray, +|}; + +/** + * Create a new node representing an identity. + */ +export function identityNode(identity: Identity): Node { + const re = new RegExp(USERNAME_PATTERN); + const match = re.exec(identity.username); + if (match == null) { + throw new Error(`Invalid username: ${identity.username}`); + } + const username = match[1]; + const address = NodeAddress.append(nodePrefix, username); + const description = `@${username}`; + return {address, timestampMs: null, description}; +} diff --git a/src/plugins/identity/identity.test.js b/src/plugins/identity/identity.test.js new file mode 100644 index 0000000..a66a31b --- /dev/null +++ b/src/plugins/identity/identity.test.js @@ -0,0 +1,35 @@ +// @flow + +import {NodeAddress} from "../../core/graph"; +import {identityNode} from "./identity"; + +describe("src/plugins/identity/identity", () => { + describe("identityNode", () => { + it("works as expected for valid identity", () => { + const identity = {username: "foo", aliases: ["github/foo"]}; + const n = identityNode(identity); + expect(n.address).toEqual( + NodeAddress.fromParts(["sourcecred", "identity", "foo"]) + ); + expect(n.timestampMs).toEqual(null); + expect(n.description).toEqual("@foo"); + }); + it("errors for an empty username", () => { + const identity = {username: "", aliases: ["github/foo"]}; + expect(() => identityNode(identity)).toThrowError("Invalid username"); + }); + it("errors for a bad username", () => { + const identity = {username: "$foo$bar", aliases: ["github/foo"]}; + expect(() => identityNode(identity)).toThrowError("Invalid username"); + }); + it("strips redundant leading @ from the description and address", () => { + const identity = {username: "@foo", aliases: ["github/foo"]}; + const n = identityNode(identity); + expect(n.address).toEqual( + NodeAddress.fromParts(["sourcecred", "identity", "foo"]) + ); + expect(n.timestampMs).toEqual(null); + expect(n.description).toEqual("@foo"); + }); + }); +}); diff --git a/src/plugins/identity/nodeContractions.js b/src/plugins/identity/nodeContractions.js new file mode 100644 index 0000000..e6d8b8e --- /dev/null +++ b/src/plugins/identity/nodeContractions.js @@ -0,0 +1,56 @@ +// @flow + +import {type NodeContraction} from "../../core/graph"; +import {type Identity, identityNode} from "./identity"; +import {resolveAlias} from "./alias"; + +/** + * Outputs the NodeContractions for identity transformation. + * + * This function takes a list of identities and (semi-optionally) a discourse + * server url. The server url is required if any Discourse identities are + * present. + * + * It returns the needed information for transforming the graph to have + * consolidated identities. Specifically, it returns a list of contractions; + * applying these contractions via `Graph.contractions` will produce a graph with + * consolidated identity nodes. + * + * TODO(#638): Once we develop a robust system for plugin configuration, we'll + * refactor this method so it no longer takes a Discourse server url as a + * special argument. + */ +export function nodeContractions( + identities: $ReadOnlyArray, + discourseUrl: string | null +): NodeContraction[] { + function errorOnDuplicate(xs: $ReadOnlyArray, kind: string) { + const s = new Set(); + for (const x of xs) { + if (s.has(x)) { + throw new Error(`Duplicate ${kind}: ${x}`); + } + s.add(x); + } + } + const usernames = identities.map((x) => x.username); + errorOnDuplicate(usernames, "username"); + const aliases = [].concat(...identities.map((x) => x.aliases)); + errorOnDuplicate(aliases, "alias"); + return identities.map((i) => _contraction(i, discourseUrl)); +} + +/** + * Produce the contraction for an individual identity (along with the + * discourseUrl, if needed). + * + * Exported for testing purposes. + */ +export function _contraction( + identity: Identity, + discourseUrl: string | null +): NodeContraction { + const replacement = identityNode(identity); + const old = identity.aliases.map((a) => resolveAlias(a, discourseUrl)); + return {old, replacement}; +} diff --git a/src/plugins/identity/nodeContractions.test.js b/src/plugins/identity/nodeContractions.test.js new file mode 100644 index 0000000..616c5f1 --- /dev/null +++ b/src/plugins/identity/nodeContractions.test.js @@ -0,0 +1,68 @@ +// @flow + +import {nodeContractions, _contraction} from "./nodeContractions"; +import {resolveAlias} from "./alias"; +import {identityNode} from "./identity"; + +describe("src/plugins/identity/nodeContractions", () => { + describe("_contraction", () => { + it("processes an empty identity", () => { + const identity = {username: "empty", aliases: []}; + const actual = _contraction(identity, null); + const expected = {old: [], replacement: identityNode(identity)}; + expect(actual).toEqual(expected); + }); + it("processes a single-alias identity", () => { + const alias = "github/foo"; + const identity = {username: "foo", aliases: [alias]}; + const actual = _contraction(identity, null); + const expected = { + old: [resolveAlias(alias, null)], + replacement: identityNode(identity), + }; + expect(actual).toEqual(expected); + }); + it("processes a multi-alias identity", () => { + const aliases = ["github/foo", "discourse/bar"]; + const identity = {username: "foo", aliases}; + const url = "https://example.com"; + const actual = _contraction(identity, url); + const expected = { + old: aliases.map((x) => resolveAlias(x, url)), + replacement: identityNode(identity), + }; + expect(actual).toEqual(expected); + }); + }); + + describe("nodeContractions", () => { + it("errors if any username is duplicated", () => { + const identities = [ + {username: "foo", aliases: ["github/foo", "github/bar"]}, + {username: "foo", aliases: []}, + ]; + expect(() => nodeContractions(identities, null)).toThrowError( + "Duplicate username" + ); + }); + it("errors if any alias is duplicated", () => { + const identities = [ + {username: "foo", aliases: ["github/foo", "github/bar"]}, + {username: "bar", aliases: ["github/foo"]}, + ]; + expect(() => nodeContractions(identities, null)).toThrowError( + "Duplicate alias" + ); + }); + it("produces a contraction for each identity", () => { + const identities = [ + {username: "foo", aliases: ["discourse/foo"]}, + {username: "bar", aliases: ["github/bar"]}, + ]; + const url = "https://example.com"; + expect(nodeContractions(identities, url)).toEqual( + identities.map((i) => _contraction(i, url)) + ); + }); + }); +});