Initiatives: add EdgeSpecJson support to InitativeFile v0.2.0 (#1763)

An additional change here is we're allowing more keys to be omitted in
the JSON format. This is both intuitive for data entry, and safer in
terms of Flow types (as JSON.parse returns any).

The test examples now cover a v0.1.0 (initiative-A), v0.2.0 with just
URLs (initiative-B) and one with just entries (initiative-C).

To make this commit smaller and easier to review, we're not yet adding
`EdgeSpec` to the `Initiative` type and will ignore the entries when
converting from `InitiativeFile` to `Initiative`.
This commit is contained in:
Robin van Boven 2020-04-27 18:24:07 +02:00 committed by GitHub
parent 5ef69bef50
commit 0a671025d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 265 additions and 38 deletions

View File

@ -7,15 +7,21 @@ Map {
"http://foo.bar/A/champ",
],
"completed": true,
"contributions": Array [
"contributions": Object {
"urls": Array [
"http://foo.bar/A/contrib",
],
"dependencies": Array [
},
"dependencies": Object {
"urls": Array [
"http://foo.bar/A/dep",
],
"references": Array [
},
"references": Object {
"urls": Array [
"http://foo.bar/A/ref",
],
},
"timestampIso": "2020-01-08T22:01:57.711Z",
"title": "Initiative A",
"weight": Object {
@ -28,15 +34,21 @@ Map {
"http://foo.bar/B/champ",
],
"completed": false,
"contributions": Array [
"contributions": Object {
"urls": Array [
"http://foo.bar/B/contrib",
],
"dependencies": Array [
},
"dependencies": Object {
"urls": Array [
"http://foo.bar/B/dep",
],
"references": Array [
},
"references": Object {
"urls": Array [
"http://foo.bar/B/ref",
],
},
"timestampIso": "2020-01-08T22:01:57.722Z",
"title": "Initiative B",
"weight": Object {
@ -44,6 +56,50 @@ Map {
"incomplete": 42,
},
},
"initiative-C.json" => Object {
"champions": Array [
"http://foo.bar/C/champ",
],
"completed": false,
"contributions": Object {
"entries": Array [
Object {
"contributors": Array [
"https://foo.bar/C/contrib-user",
],
"title": "Add test contrib",
"weight": 10,
},
],
},
"dependencies": Object {
"entries": Array [
Object {
"contributors": Array [
"https://foo.bar/C/dep-user",
],
"timestampIso": "2020-01-02T22:01:57.700Z",
"title": "Add test dependency",
},
],
},
"references": Object {
"entries": Array [
Object {
"contributors": Array [
"https://foo.bar/C/ref-user",
],
"title": "Add test reference",
},
],
},
"timestampIso": "2020-01-08T22:01:57.733Z",
"title": "Initiative C",
"weight": Object {
"complete": 321,
"incomplete": 123,
},
},
}
`;
@ -56,6 +112,10 @@ exports[`plugins/initiatives/initiativesDirectory loadDirectory should handle an
[
\\"http://example.com/initiatives/initiative-B.json\\",
\\"N\\\\u0000sourcecred\\\\u0000initiatives\\\\u0000initiative\\\\u0000INITIATIVE_FILE\\\\u0000http://example.com/initiatives\\\\u0000initiative-B.json\\\\u0000\\"
],
[
\\"http://example.com/initiatives/initiative-C.json\\",
\\"N\\\\u0000sourcecred\\\\u0000initiatives\\\\u0000initiative\\\\u0000INITIATIVE_FILE\\\\u0000http://example.com/initiatives\\\\u0000initiative-C.json\\\\u0000\\"
]
]"
`;
@ -113,6 +173,29 @@ exports[`plugins/initiatives/initiativesDirectory loadDirectory should handle an
\\"complete\\": 69,
\\"incomplete\\": 42
}
},
{
\\"champions\\": [
\\"http://foo.bar/C/champ\\"
],
\\"completed\\": false,
\\"contributions\\": [
],
\\"dependencies\\": [
],
\\"id\\": [
\\"INITIATIVE_FILE\\",
\\"http://example.com/initiatives\\",
\\"initiative-C.json\\"
],
\\"references\\": [
],
\\"timestampMs\\": 1578520917733,
\\"title\\": \\"Initiative C\\",
\\"weight\\": {
\\"complete\\": 321,
\\"incomplete\\": 123
}
}
]"
`;

View File

@ -1,7 +1,7 @@
[
{
"type": "sourcecred/initiativeFile",
"version": "0.1.0"
"version": "0.2.0"
},
{
"title": "Initiative B",
@ -12,8 +12,8 @@
},
"completed": false,
"champions": ["http://foo.bar/B/champ"],
"contributions": ["http://foo.bar/B/contrib"],
"dependencies": ["http://foo.bar/B/dep"],
"references": ["http://foo.bar/B/ref"]
"contributions": {"urls": ["http://foo.bar/B/contrib"]},
"dependencies": {"urls": ["http://foo.bar/B/dep"]},
"references": {"urls": ["http://foo.bar/B/ref"]}
}
]

View File

@ -0,0 +1,42 @@
[
{
"type": "sourcecred/initiativeFile",
"version": "0.2.0"
},
{
"title": "Initiative C",
"timestampIso": "2020-01-08T22:01:57.733Z",
"weight": {
"incomplete": 123,
"complete": 321
},
"completed": false,
"champions": ["http://foo.bar/C/champ"],
"contributions": {
"entries": [
{
"title": "Add test contrib",
"weight": 10,
"contributors": ["https://foo.bar/C/contrib-user"]
}
]
},
"dependencies": {
"entries": [
{
"title": "Add test dependency",
"timestampIso": "2020-01-02T22:01:57.700Z",
"contributors": ["https://foo.bar/C/dep-user"]
}
]
},
"references": {
"entries": [
{
"title": "Add test reference",
"contributors": ["https://foo.bar/C/ref-user"]
}
]
}
}
]

View File

@ -4,9 +4,10 @@ import {type URL} from "../../core/references";
import {type TimestampISO} from "../../util/timestamp";
import {type NodeAddressT, NodeAddress} from "../../core/graph";
import {type Compatible, fromCompat, toCompat} from "../../util/compat";
import {initiativeNodeType} from "./declaration";
import {type InitiativeWeight, type InitiativeId, createId} from "./initiative";
import {type InitiativesDirectory} from "./initiativesDirectory";
import {type EdgeSpecJson} from "./edgeSpec";
import {initiativeNodeType} from "./declaration";
export const INITIATIVE_FILE_SUBTYPE = "INITIATIVE_FILE";
@ -16,7 +17,27 @@ export const INITIATIVE_FILE_SUBTYPE = "INITIATIVE_FILE";
* Note: The file name will be used to derive the InitiativeId. So it doesn't
* make sense to use this outside of the context of an InitiativesDirectory.
*/
export type InitiativeFile = {|
export type InitiativeFile = InitiativeFileV020;
export type InitiativeFileV020 = {|
+title: string,
+timestampIso: TimestampISO,
+weight: InitiativeWeight,
+completed: boolean,
+contributions?: EdgeSpecJson,
+dependencies?: EdgeSpecJson,
+references?: EdgeSpecJson,
+champions?: $ReadOnlyArray<URL>,
|};
const upgradeFrom010 = (file: InitiativeFileV010): InitiativeFileV020 => ({
...file,
contributions: {urls: file.contributions},
dependencies: {urls: file.dependencies},
references: {urls: file.references},
});
export type InitiativeFileV010 = {|
+title: string,
+timestampIso: TimestampISO,
+weight: InitiativeWeight,
@ -27,10 +48,14 @@ export type InitiativeFile = {|
+champions: $ReadOnlyArray<URL>,
|};
const COMPAT_INFO = {type: "sourcecred/initiativeFile", version: "0.1.0"};
const upgrades = {
"0.1.0": upgradeFrom010,
};
const COMPAT_INFO = {type: "sourcecred/initiativeFile", version: "0.2.0"};
export function fromJSON(j: Compatible<any>): InitiativeFile {
return fromCompat(COMPAT_INFO, j);
return fromCompat(COMPAT_INFO, j, upgrades);
}
export function toJSON(m: InitiativeFile): Compatible<InitiativeFile> {

View File

@ -18,9 +18,18 @@ const exampleInitiativeFile = (): InitiativeFile => ({
weight: {incomplete: 360, complete: 420},
completed: false,
champions: ["http://foo.bar/champ"],
contributions: ["http://foo.bar/contrib"],
dependencies: ["http://foo.bar/dep"],
references: ["http://foo.bar/ref"],
contributions: {
urls: ["http://foo.bar/contrib"],
entries: [{title: "Inline contrib"}],
},
dependencies: {
urls: ["http://foo.bar/dep"],
entries: [{title: "Inline dep"}],
},
references: {
urls: ["http://foo.bar/ref"],
entries: [{title: "Inline ref"}],
},
});
describe("plugins/initiatives/initiativeFile", () => {

View File

@ -11,6 +11,7 @@ import {
type ReferenceDetector,
MappedReferenceDetector,
} from "../../core/references";
import {type EdgeSpecJson} from "./edgeSpec";
import {
type Initiative,
type InitiativeRepository,
@ -148,18 +149,41 @@ export function _convertToInitiatives(
): $ReadOnlyArray<Initiative> {
const initiatives = [];
for (const [fileName, initiativeFile] of map.entries()) {
const {timestampIso, ...partialInitiativeFile} = initiativeFile;
const {
timestampIso,
champions,
contributions,
dependencies,
references,
...partialInitiativeFile
} = initiativeFile;
const timestampMs = Timestamp.fromISO(timestampIso);
const initiative: Initiative = {
...partialInitiativeFile,
id: initiativeFileId(directory, fileName),
timestampMs,
champions: champions || [],
contributions: _lossyURLFromEdgeSpecJson(contributions),
dependencies: _lossyURLFromEdgeSpecJson(dependencies),
references: _lossyURLFromEdgeSpecJson(references),
};
initiatives.push(initiative);
}
return initiatives;
}
// TODO: this is a temporary function, which reverts an ?EdgeSpecJson to just
// $ReadOnlyArray<URL>. It only exists to allow the `Initiative` type to add
// support for EdgeSpec in a separate commit.
export function _lossyURLFromEdgeSpecJson(
json?: EdgeSpecJson
): $ReadOnlyArray<URL> {
return (json || {}).urls || [];
}
// Creates a reference map using `initiativeFileURL`.
export function _createReferenceMap(
initiatives: $ReadOnlyArray<Initiative>

View File

@ -16,6 +16,7 @@ import {
_validateUrl,
_convertToInitiatives,
_createReferenceMap,
_lossyURLFromEdgeSpecJson,
} from "./initiativesDirectory";
import {type InitiativeFile} from "./initiativeFile";
@ -25,18 +26,38 @@ const exampleInitiativeFile = (): InitiativeFile => ({
weight: {incomplete: 360, complete: 420},
completed: false,
champions: ["http://foo.bar/champ"],
contributions: ["http://foo.bar/contrib"],
dependencies: ["http://foo.bar/dep"],
references: ["http://foo.bar/ref"],
contributions: {
urls: ["http://foo.bar/contrib"],
entries: [{title: "Inline contrib"}],
},
dependencies: {
urls: ["http://foo.bar/dep"],
entries: [{title: "Inline dep"}],
},
references: {
urls: ["http://foo.bar/ref"],
entries: [{title: "Inline ref"}],
},
});
const exampleInitiative = (remoteUrl: string, fileName: string): Initiative => {
const {timestampIso, ...partialInitiativeFile} = exampleInitiativeFile();
const {
timestampIso,
contributions,
dependencies,
references,
champions,
...partialInitiativeFile
} = exampleInitiativeFile();
const timestampMs = Timestamp.fromISO(timestampIso);
return {
...partialInitiativeFile,
id: createId("INITIATIVE_FILE", remoteUrl, fileName),
timestampMs,
champions: champions || [],
contributions: _lossyURLFromEdgeSpecJson(contributions),
dependencies: _lossyURLFromEdgeSpecJson(dependencies),
references: _lossyURLFromEdgeSpecJson(references),
};
};
@ -92,7 +113,11 @@ describe("plugins/initiatives/initiativesDirectory", () => {
// Then
// Shallow copy to sort, because the array is read-only.
const actualNames = [...fileNames].sort();
expect(actualNames).toEqual(["initiative-A.json", "initiative-B.json"]);
expect(actualNames).toEqual([
"initiative-A.json",
"initiative-B.json",
"initiative-C.json",
]);
});
});
@ -100,7 +125,11 @@ describe("plugins/initiatives/initiativesDirectory", () => {
it("should read provided initiativeFiles, sorted by name", async () => {
// Given
const localPath = path.join(__dirname, "example");
const fileNames = ["initiative-B.json", "initiative-A.json"];
const fileNames = [
"initiative-C.json",
"initiative-B.json",
"initiative-A.json",
];
// When
const map = await _readFiles(localPath, fileNames);
@ -109,6 +138,7 @@ describe("plugins/initiatives/initiativesDirectory", () => {
expect([...map.keys()]).toEqual([
"initiative-A.json",
"initiative-B.json",
"initiative-C.json",
]);
expect(map).toMatchSnapshot();
});
@ -116,7 +146,11 @@ describe("plugins/initiatives/initiativesDirectory", () => {
it("should throw when directory doesn't exist", async () => {
// Given
const localPath = path.join(tmp.dirSync().name, "findFiles_test");
const fileNames = ["initiative-B.json", "initiative-A.json"];
const fileNames = [
"initiative-C.json",
"initiative-B.json",
"initiative-A.json",
];
// When
const p = _readFiles(localPath, fileNames);
@ -128,7 +162,11 @@ describe("plugins/initiatives/initiativesDirectory", () => {
it("should throw when directory is not a directory", async () => {
// Given
const localPath = path.join(tmp.dirSync().name, "findFiles_test");
const fileNames = ["initiative-B.json", "initiative-A.json"];
const fileNames = [
"initiative-C.json",
"initiative-B.json",
"initiative-A.json",
];
await fs.writeFile(localPath, "");
// When
@ -320,6 +358,7 @@ describe("plugins/initiatives/initiativesDirectory", () => {
expect(urls).toEqual([
"http://example.com/initiatives/initiative-A.json",
"http://example.com/initiatives/initiative-B.json",
"http://example.com/initiatives/initiative-C.json",
]);
expect(initiatives.map((i) => i.id)).toEqual([
[
@ -332,6 +371,11 @@ describe("plugins/initiatives/initiativesDirectory", () => {
"http://example.com/initiatives",
"initiative-B.json",
],
[
"INITIATIVE_FILE",
"http://example.com/initiatives",
"initiative-C.json",
],
]);
});
});