From 505636f3dc9ece69dc4e617506b727b199b4f67b Mon Sep 17 00:00:00 2001 From: ssube Date: Thu, 20 Aug 2020 17:08:57 -0500 Subject: [PATCH] fix(config): match command names and docs, cover parts of sync --- docs/arch.md | 37 +++++++- docs/getting-started.md | 18 ++-- src/config/args.ts | 4 +- src/sync.ts | 6 +- src/utils.ts | 8 -- test/TestGraph.ts | 33 +++++++ test/TestUtils.ts | 41 ++++++++- test/sync/TestSyncIssues.ts | 57 ++++++++++++- test/sync/TestSyncLabels.ts | 59 ------------- test/sync/TestSyncProjects.ts | 156 ++++++++++++++++++++++++++++++++++ 10 files changed, 337 insertions(+), 82 deletions(-) create mode 100644 test/TestGraph.ts delete mode 100644 test/sync/TestSyncLabels.ts create mode 100644 test/sync/TestSyncProjects.ts diff --git a/docs/arch.md b/docs/arch.md index 75e8356..7dce45a 100644 --- a/docs/arch.md +++ b/docs/arch.md @@ -10,7 +10,8 @@ This guide describes the program architecture. - [Label](#label) - [Flag Label](#flag-label) - [State Label](#state-label) - - [State Label Value](#state-label-value) + - [State Value](#state-value) + - [State Changes](#state-changes) - [Remote](#remote) - [Github Remote](#github-remote) - [Gitlab Remote](#gitlab-remote) @@ -36,10 +37,42 @@ they are not connected directly. State labels are set from a group. Only one value from each state may be set at a time, and the highest priority value automatically replaces any others. -#### State Label Value +#### State Value Individual values within the state. +#### State Changes + +State labels may replace one value with another using normal change rules, with the addition +of a list of matching labels that must be present for the state change to be resolved. + +For example, a state with three values like so: + +```yaml +projects: + - name: foo + flags: + - name: next + states: + - name: status + values: + - name: new + becomes: + - adds: [status/in-progress] + matches: [next] + - name: in-progress + becomes: + - adds: [status/done] + matches: [next] + - name: done + becomes: + - adds: [status/new] + matches: [next] +``` + +Each time `cautious-journey` evaluates the labels on this project, any issues with the `status/new` **and** `next` +labels will be promoted to `status/in-progress`, and both the `status/new` and `next` labels will be removed. + ## Remote `cautious-journey` manipulates issues and labels that exist on some remote service. diff --git a/docs/getting-started.md b/docs/getting-started.md index 7dfa03b..b6ca212 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,8 +20,9 @@ This guide explains how to start using `cautious-journey` to manage project and - [Available Remotes](#available-remotes) - [Copying Project Remotes](#copying-project-remotes) - [Commands](#commands) - - [Sync Issue Labels](#sync-issue-labels) - - [Sync Project Labels](#sync-project-labels) + - [Graph Labels](#graph-labels) + - [Sync Issues](#sync-issues) + - [Sync Projects](#sync-projects) - [Changes](#changes) - [Flag Changes](#flag-changes) - [State Changes](#state-changes) @@ -145,15 +146,20 @@ TODO: how would you share a remote config block between two projects? The program can run in a few different modes: -- sync issue labels -- sync project labels +- [`dot-graph`](#graph-labels) +- [`sync-issues`](#sync-issue-labels) +- [`sync-labels`](#sync-project-labels) -### Sync Issue Labels +### Graph Labels + +This mode will print a `dot` graph, to be formatted with the GraphViz tools. + +### Sync Issues This mode will look through each issue on the project, open or closed, and check for label updates. Labels will be resolved using the project's rules, and a comment left recording any changes. -### Sync Project Labels +### Sync Projects This mode will look through the project labels, ensuring they are up to date with the project labels in the config. Missing labels will be created, extra labels will be deleted, and existing labels will be updated to diff --git a/src/config/args.ts b/src/config/args.ts index 9afc8b1..481b1a3 100644 --- a/src/config/args.ts +++ b/src/config/args.ts @@ -4,9 +4,9 @@ import { VERSION_INFO } from '../version'; export enum Commands { UNKNOWN = 'unknown', - GRAPH = 'dot-graph', + GRAPH = 'graph-labels', ISSUES = 'sync-issues', - LABELS = 'sync-labels', + LABELS = 'sync-projects', } interface Parser { diff --git a/src/sync.ts b/src/sync.ts index 159ac99..446b4a8 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -2,11 +2,11 @@ import { doesExist, mustExist } from '@apextoaster/js-utils'; import { Logger } from 'noicejs'; import { prng } from 'seedrandom'; -import { FlagLabel, getLabelColor, getLabelNames, getValueName, StateLabel } from './labels'; +import { ProjectConfig } from './config'; +import { getLabelColor, getLabelNames, getValueName } from './labels'; import { LabelUpdate, Remote } from './remote'; import { resolveLabels } from './resolve'; -import { defaultTo, defaultUntil, compareItems } from './utils'; -import { ProjectConfig } from './config'; +import { compareItems, defaultTo, defaultUntil } from './utils'; export interface SyncOptions { logger: Logger; diff --git a/src/utils.ts b/src/utils.ts index 192b119..efca542 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -35,11 +35,3 @@ export function compareItems(a: Array, b: Array): boolean { return true; } - -interface Collection { - has(value: T): boolean; -} - -export function contains(a: Collection, b: Array): boolean { - return b.every((it) => a.has(it)); -} diff --git a/test/TestGraph.ts b/test/TestGraph.ts new file mode 100644 index 0000000..5230789 --- /dev/null +++ b/test/TestGraph.ts @@ -0,0 +1,33 @@ +describe('graph tools', () => { + describe('label edges', () => { + it('should include rule labels'); + }); + + describe('graph labels', () => { + it('should create nodes for each label'); + it('should create edges for each change rule'); + it('should create edges for each state change'); + }); + + describe('clean name', () => { + it('should remove special characters'); + it('should remove duplicate escape characters'); + }); + + describe('edge style', () => { + it('should color edges'); + }); + + describe('node style', () => { + it('should label nodes'); + it('should color nodes'); + }); + + describe('dot formatter', () => { + it('should print nodes'); + it('should print edges'); + it('should cluster flags'); + it('should cluster states'); + it('should label the root digraph'); + }); +}); diff --git a/test/TestUtils.ts b/test/TestUtils.ts index 01fdc2e..9c21f68 100644 --- a/test/TestUtils.ts +++ b/test/TestUtils.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { defaultTo, defaultUntil } from '../src/utils'; +import { defaultTo, defaultUntil, compareItems } from '../src/utils'; const TEST_TRUE = 'foo'; const TEST_FALSE = 'bar'; @@ -25,4 +25,43 @@ describe('utils', () => { expect(defaultUntil(undefined, undefined, TEST_TRUE, undefined)).to.equal(TEST_TRUE); }); }); + + describe('compare items helper', () => { + /* eslint-disable no-magic-numbers */ + it('should compare items by reference', () => { + const dat = {}; + expect(compareItems([ + 1, dat, 3, + ], [ + 1, dat, 3, + ])).to.equal(true); + + expect(compareItems([ + 1, dat, 3, + ], [ + 1, {}, 3, + ])).to.equal(false); + + expect(compareItems([ + 1, 2, 3, + ], [ + 1, 2, 4, + ])).to.equal(false); + }); + + it('should sort arrays before comparison', () => { + expect(compareItems([ + 1, 2, 3, + ], [ + 3, 2, 1, + ])).to.equal(true); + }); + + it('should always reject arrays of different lengths', () => { + expect(compareItems( + new Array(5).fill(1), + new Array(3).fill(1) + )).to.equal(false); + }); + }); }); diff --git a/test/sync/TestSyncIssues.ts b/test/sync/TestSyncIssues.ts index eef4447..1cd5ec0 100644 --- a/test/sync/TestSyncIssues.ts +++ b/test/sync/TestSyncIssues.ts @@ -1,6 +1,61 @@ import { expect } from 'chai'; +import { NullLogger } from 'noicejs'; +import { alea } from 'seedrandom'; +import { stub } from 'sinon'; + +import { GithubRemote } from '../../src/remote/github'; +import { syncIssueLabels } from '../../src/sync'; describe('issue sync', () => { - it('should resolve each issue'); + it('should resolve each issue', async () => { + const logger = NullLogger.global; + const remoteData = { + data: {}, + dryrun: true, + logger, + type: '', + }; + const remote = new GithubRemote(remoteData); + const listStub = stub(remote, 'listIssues').returns(Promise.resolve([{ + issue: '', + labels: ['nope'], + name: '', + project: 'foo', + }])); + const updateStub = stub(remote, 'updateIssue').returns(Promise.resolve({ + issue: '', + labels: [], + name: '', + project: '', + })); + + await syncIssueLabels({ + logger, + project: { + colors: [], + comment: false, + flags: [{ + adds: [], + name: 'nope', + priority: 0, + removes: [], + requires: [{ + name: 'yep', + }], + }], + name: 'foo', + remote: remoteData, + states: [], + }, + random: alea(), + remote, + }); + expect(listStub).to.have.callCount(1); + expect(updateStub).to.have.callCount(1); + expect(updateStub).to.have.been.calledWithMatch({ + project: 'foo', + }); + }); + it('should update issues with label changes'); }); diff --git a/test/sync/TestSyncLabels.ts b/test/sync/TestSyncLabels.ts deleted file mode 100644 index 1f0b07c..0000000 --- a/test/sync/TestSyncLabels.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { expect } from 'chai'; -import { alea } from 'seedrandom'; -import { match, spy } from 'sinon'; - -import { BunyanLogger } from '../../src/logger/bunyan'; -import { GithubRemote } from '../../src/remote/github'; -import { syncSingleLabel } from '../../src/sync'; - -describe('label sync', () => { - it('should sync each label'); - it('should pick a stable random color for each label', async () => { - const logger = BunyanLogger.create({ - name: 'test', - }); - const remoteConfig = { - data: {}, - dryrun: true, - logger, - type: '', - }; - const remote = new GithubRemote(remoteConfig); - const updateSpy = spy(remote, 'updateLabel'); - - await syncSingleLabel({ - logger, - project: { - colors: [ - 'ff0000', - ], - comment: true, - flags: [{ - adds: [], - name: 'foo', - priority: 1, - removes: [], - requires: [], - }], - name: '', - remote: remoteConfig, - states: [], - }, - random: alea(), - remote, - }, { - color: '', - desc: '', - name: 'foo', - project: '', - }); - - expect(updateSpy).to.have.callCount(1); - - const COLOR_LENGTH = 6; - expect(updateSpy).to.have.been.calledWithMatch({ - color: match.string.and(match((it) => it.length === COLOR_LENGTH)), - name: 'foo', - }); - }); -}); diff --git a/test/sync/TestSyncProjects.ts b/test/sync/TestSyncProjects.ts new file mode 100644 index 0000000..583e39b --- /dev/null +++ b/test/sync/TestSyncProjects.ts @@ -0,0 +1,156 @@ +import { expect } from 'chai'; +import { alea } from 'seedrandom'; +import { match, spy, stub } from 'sinon'; + +import { BunyanLogger } from '../../src/logger/bunyan'; +import { GithubRemote } from '../../src/remote/github'; +import { syncProjectLabels, syncSingleLabel } from '../../src/sync'; + +describe('project sync', () => { + describe('all labels', () => { + it('should sync each label'); + it('should pick a stable random color for each label', async () => { + const logger = BunyanLogger.create({ + name: 'test', + }); + const remoteConfig = { + data: {}, + dryrun: true, + logger, + type: '', + }; + const remote = new GithubRemote(remoteConfig); + const updateSpy = spy(remote, 'updateLabel'); + + await syncSingleLabel({ + logger, + project: { + colors: [ + 'ff0000', + ], + comment: true, + flags: [{ + adds: [], + name: 'foo', + priority: 1, + removes: [], + requires: [], + }], + name: '', + remote: remoteConfig, + states: [], + }, + random: alea(), + remote, + }, { + color: '', + desc: '', + name: 'foo', + project: '', + }); + + expect(updateSpy).to.have.callCount(1); + + const COLOR_LENGTH = 6; + expect(updateSpy).to.have.been.calledWithMatch({ + color: match.string.and(match((it) => it.length === COLOR_LENGTH)), + name: 'foo', + }); + }); + + it('should create missing labels', async () => { + const logger = BunyanLogger.create({ + name: 'test', + }); + const remoteConfig = { + data: {}, + dryrun: true, + logger, + type: '', + }; + const remote = new GithubRemote(remoteConfig); + const createStub = stub(remote, 'createLabel'); + const deleteStub = stub(remote, 'deleteLabel'); + const listStub = stub(remote, 'listLabels').returns(Promise.resolve([])); + + await syncProjectLabels({ + logger, + project: { + colors: [], + comment: true, + flags: [{ + adds: [], + color: '', + desc: '', + name: '', + priority: 1, + removes: [], + requires: [], + }], + name: '', + remote: remoteConfig, + states: [], + }, + random: alea(), + remote, + }); + + expect(listStub).to.have.callCount(1); + expect(createStub).to.have.callCount(1); + expect(deleteStub).to.have.callCount(0); + }); + + it('should delete extra labels', async () => { + const logger = BunyanLogger.create({ + name: 'test', + }); + const remoteConfig = { + data: {}, + dryrun: true, + logger, + type: '', + }; + const remote = new GithubRemote(remoteConfig); + const createStub = stub(remote, 'createLabel'); + const deleteStub = stub(remote, 'deleteLabel'); + const listStub = stub(remote, 'listLabels').returns(Promise.resolve([{ + color: '', + desc: '', + name: '', + project: '', + }])); + + await syncProjectLabels({ + logger, + project: { + colors: [], + comment: true, + flags: [], + name: '', + remote: remoteConfig, + states: [], + }, + random: alea(), + remote, + }); + + expect(listStub).to.have.callCount(1); + expect(createStub).to.have.callCount(0); + expect(deleteStub).to.have.callCount(1); + }); + }); + + describe('flag labels', () => { + it('should prefer flag color'); + }); + + describe('state labels', () => { + it('should prefer value color'); + it('should fall back to state color'); + it('should use state divider'); + }); + + describe('create label', () => { + it('should create label'); + }); +});