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", "http://foo.bar/A/champ",
], ],
"completed": true, "completed": true,
"contributions": Array [ "contributions": Object {
"urls": Array [
"http://foo.bar/A/contrib", "http://foo.bar/A/contrib",
], ],
"dependencies": Array [ },
"dependencies": Object {
"urls": Array [
"http://foo.bar/A/dep", "http://foo.bar/A/dep",
], ],
"references": Array [ },
"references": Object {
"urls": Array [
"http://foo.bar/A/ref", "http://foo.bar/A/ref",
], ],
},
"timestampIso": "2020-01-08T22:01:57.711Z", "timestampIso": "2020-01-08T22:01:57.711Z",
"title": "Initiative A", "title": "Initiative A",
"weight": Object { "weight": Object {
@ -28,15 +34,21 @@ Map {
"http://foo.bar/B/champ", "http://foo.bar/B/champ",
], ],
"completed": false, "completed": false,
"contributions": Array [ "contributions": Object {
"urls": Array [
"http://foo.bar/B/contrib", "http://foo.bar/B/contrib",
], ],
"dependencies": Array [ },
"dependencies": Object {
"urls": Array [
"http://foo.bar/B/dep", "http://foo.bar/B/dep",
], ],
"references": Array [ },
"references": Object {
"urls": Array [
"http://foo.bar/B/ref", "http://foo.bar/B/ref",
], ],
},
"timestampIso": "2020-01-08T22:01:57.722Z", "timestampIso": "2020-01-08T22:01:57.722Z",
"title": "Initiative B", "title": "Initiative B",
"weight": Object { "weight": Object {
@ -44,6 +56,50 @@ Map {
"incomplete": 42, "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\\", \\"http://example.com/initiatives/initiative-B.json\\",
\\"N\\\\u0000sourcecred\\\\u0000initiatives\\\\u0000initiative\\\\u0000INITIATIVE_FILE\\\\u0000http://example.com/initiatives\\\\u0000initiative-B.json\\\\u0000\\" \\"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, \\"complete\\": 69,
\\"incomplete\\": 42 \\"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", "type": "sourcecred/initiativeFile",
"version": "0.1.0" "version": "0.2.0"
}, },
{ {
"title": "Initiative B", "title": "Initiative B",
@ -12,8 +12,8 @@
}, },
"completed": false, "completed": false,
"champions": ["http://foo.bar/B/champ"], "champions": ["http://foo.bar/B/champ"],
"contributions": ["http://foo.bar/B/contrib"], "contributions": {"urls": ["http://foo.bar/B/contrib"]},
"dependencies": ["http://foo.bar/B/dep"], "dependencies": {"urls": ["http://foo.bar/B/dep"]},
"references": ["http://foo.bar/B/ref"] "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 TimestampISO} from "../../util/timestamp";
import {type NodeAddressT, NodeAddress} from "../../core/graph"; import {type NodeAddressT, NodeAddress} from "../../core/graph";
import {type Compatible, fromCompat, toCompat} from "../../util/compat"; import {type Compatible, fromCompat, toCompat} from "../../util/compat";
import {initiativeNodeType} from "./declaration";
import {type InitiativeWeight, type InitiativeId, createId} from "./initiative"; import {type InitiativeWeight, type InitiativeId, createId} from "./initiative";
import {type InitiativesDirectory} from "./initiativesDirectory"; import {type InitiativesDirectory} from "./initiativesDirectory";
import {type EdgeSpecJson} from "./edgeSpec";
import {initiativeNodeType} from "./declaration";
export const INITIATIVE_FILE_SUBTYPE = "INITIATIVE_FILE"; 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 * 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. * 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, +title: string,
+timestampIso: TimestampISO, +timestampIso: TimestampISO,
+weight: InitiativeWeight, +weight: InitiativeWeight,
@ -27,10 +48,14 @@ export type InitiativeFile = {|
+champions: $ReadOnlyArray<URL>, +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 { 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> { export function toJSON(m: InitiativeFile): Compatible<InitiativeFile> {

View File

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

View File

@ -11,6 +11,7 @@ import {
type ReferenceDetector, type ReferenceDetector,
MappedReferenceDetector, MappedReferenceDetector,
} from "../../core/references"; } from "../../core/references";
import {type EdgeSpecJson} from "./edgeSpec";
import { import {
type Initiative, type Initiative,
type InitiativeRepository, type InitiativeRepository,
@ -148,18 +149,41 @@ export function _convertToInitiatives(
): $ReadOnlyArray<Initiative> { ): $ReadOnlyArray<Initiative> {
const initiatives = []; const initiatives = [];
for (const [fileName, initiativeFile] of map.entries()) { 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 timestampMs = Timestamp.fromISO(timestampIso);
const initiative: Initiative = { const initiative: Initiative = {
...partialInitiativeFile, ...partialInitiativeFile,
id: initiativeFileId(directory, fileName), id: initiativeFileId(directory, fileName),
timestampMs, timestampMs,
champions: champions || [],
contributions: _lossyURLFromEdgeSpecJson(contributions),
dependencies: _lossyURLFromEdgeSpecJson(dependencies),
references: _lossyURLFromEdgeSpecJson(references),
}; };
initiatives.push(initiative); initiatives.push(initiative);
} }
return initiatives; 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`. // Creates a reference map using `initiativeFileURL`.
export function _createReferenceMap( export function _createReferenceMap(
initiatives: $ReadOnlyArray<Initiative> initiatives: $ReadOnlyArray<Initiative>

View File

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