mirror of
https://github.com/status-im/sourcecred.git
synced 2025-01-11 13:14:28 +00:00
Add the identity plugin (#1384)
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:
parent
b86dcf742e
commit
9a9f211901
19
src/plugins/identity/README.md
Normal file
19
src/plugins/identity/README.md
Normal 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.
|
44
src/plugins/identity/alias.js
Normal file
44
src/plugins/identity/alias.js
Normal 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}`);
|
||||
}
|
||||
}
|
52
src/plugins/identity/alias.test.js
Normal file
52
src/plugins/identity/alias.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
28
src/plugins/identity/declaration.js
Normal file
28
src/plugins/identity/declaration.js
Normal 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],
|
||||
});
|
35
src/plugins/identity/identity.js
Normal file
35
src/plugins/identity/identity.js
Normal 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};
|
||||
}
|
35
src/plugins/identity/identity.test.js
Normal file
35
src/plugins/identity/identity.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
56
src/plugins/identity/nodeContractions.js
Normal file
56
src/plugins/identity/nodeContractions.js
Normal 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};
|
||||
}
|
68
src/plugins/identity/nodeContractions.test.js
Normal file
68
src/plugins/identity/nodeContractions.test.js
Normal 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))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user