Add the identity plugin

This commit adds the new SourceCred identity plugin. As described in the
README.md file:

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.

The plugin is broken up into a few submoudles:
- `declaration.js` provides the PluginDeclaration. It has a single node
type (the identity node).
- `identity.js` declares the `Identity` type (a username and list of
aliases), allows constructing identity nodes, and does some validation
on the identity username.
- `alias.js` implements the logic for parsing aliases like
"github/decentralion" or "discourse/s_ben" into a node address.
- `nodeContractions.js` provides logic for turning a list of Identities
into a list of NodeContractions, suitable for use in
`Graph.contractNodes`.

The plugin is not yet integrated; that will come in a followon commit.

Test plan: Unit tests added; `yarn test` passes.
This commit is contained in:
Dandelion Mané 2019-09-18 14:51:26 +02:00
parent 8f46d7d812
commit 1770edbbdb
8 changed files with 337 additions and 0 deletions

View File

@ -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.

View File

@ -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}`);
}
}

View File

@ -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);
});
});
});
});

View File

@ -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],
});

View File

@ -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<Alias>,
|};
/**
* 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};
}

View File

@ -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");
});
});
});

View File

@ -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<Identity>,
discourseUrl: string | null
): NodeContraction[] {
function errorOnDuplicate(xs: $ReadOnlyArray<string>, 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};
}

View File

@ -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))
);
});
});
});