diff --git a/docs/api/cautious-journey.syncoptions.flags.md b/docs/api/cautious-journey.statelabel.divider.md similarity index 51% rename from docs/api/cautious-journey.syncoptions.flags.md rename to docs/api/cautious-journey.statelabel.divider.md index 0a34686..3e097fb 100644 --- a/docs/api/cautious-journey.syncoptions.flags.md +++ b/docs/api/cautious-journey.statelabel.divider.md @@ -1,11 +1,11 @@ -[Home](./index.md) > [cautious-journey](./cautious-journey.md) > [SyncOptions](./cautious-journey.syncoptions.md) > [flags](./cautious-journey.syncoptions.flags.md) +[Home](./index.md) > [cautious-journey](./cautious-journey.md) > [StateLabel](./cautious-journey.statelabel.md) > [divider](./cautious-journey.statelabel.divider.md) -## SyncOptions.flags property +## StateLabel.divider property Signature: ```typescript -flags: Array; +divider: string; ``` diff --git a/docs/api/cautious-journey.statelabel.md b/docs/api/cautious-journey.statelabel.md index aa40614..f68a182 100644 --- a/docs/api/cautious-journey.statelabel.md +++ b/docs/api/cautious-journey.statelabel.md @@ -17,5 +17,6 @@ export interface StateLabel extends BaseLabel | Property | Type | Description | | --- | --- | --- | +| [divider](./cautious-journey.statelabel.divider.md) | string | | | [values](./cautious-journey.statelabel.values.md) | Array<[StateValue](./cautious-journey.statevalue.md)> | Values for this state. | diff --git a/docs/api/cautious-journey.syncoptions.colors.md b/docs/api/cautious-journey.syncoptions.colors.md deleted file mode 100644 index b812f3c..0000000 --- a/docs/api/cautious-journey.syncoptions.colors.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [cautious-journey](./cautious-journey.md) > [SyncOptions](./cautious-journey.syncoptions.md) > [colors](./cautious-journey.syncoptions.colors.md) - -## SyncOptions.colors property - -Signature: - -```typescript -colors: Array; -``` diff --git a/docs/api/cautious-journey.syncoptions.md b/docs/api/cautious-journey.syncoptions.md index 5683f2e..c23ab46 100644 --- a/docs/api/cautious-journey.syncoptions.md +++ b/docs/api/cautious-journey.syncoptions.md @@ -14,11 +14,8 @@ export interface SyncOptions | Property | Type | Description | | --- | --- | --- | -| [colors](./cautious-journey.syncoptions.colors.md) | Array<string> | | -| [flags](./cautious-journey.syncoptions.flags.md) | Array<[FlagLabel](./cautious-journey.flaglabel.md)> | | | [logger](./cautious-journey.syncoptions.logger.md) | Logger | | -| [project](./cautious-journey.syncoptions.project.md) | string | | +| [project](./cautious-journey.syncoptions.project.md) | ProjectConfig | | | [random](./cautious-journey.syncoptions.random.md) | prng | | | [remote](./cautious-journey.syncoptions.remote.md) | [Remote](./cautious-journey.remote.md) | | -| [states](./cautious-journey.syncoptions.states.md) | Array<[StateLabel](./cautious-journey.statelabel.md)> | States from project config. | diff --git a/docs/api/cautious-journey.syncoptions.project.md b/docs/api/cautious-journey.syncoptions.project.md index 5ceea83..ed93344 100644 --- a/docs/api/cautious-journey.syncoptions.project.md +++ b/docs/api/cautious-journey.syncoptions.project.md @@ -7,5 +7,5 @@ Signature: ```typescript -project: string; +project: ProjectConfig; ``` diff --git a/docs/api/cautious-journey.syncoptions.states.md b/docs/api/cautious-journey.syncoptions.states.md deleted file mode 100644 index eb05e71..0000000 --- a/docs/api/cautious-journey.syncoptions.states.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [cautious-journey](./cautious-journey.md) > [SyncOptions](./cautious-journey.syncoptions.md) > [states](./cautious-journey.syncoptions.states.md) - -## SyncOptions.states property - -States from project config. - -Signature: - -```typescript -states: Array; -``` diff --git a/src/config/index.ts b/src/config/index.ts index 064f2f7..32adb88 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -5,40 +5,51 @@ import { FlagLabel, StateLabel } from '../labels'; import { RemoteOptions } from '../remote'; import * as SCHEMA_DATA from './schema.yml'; +export interface LoggerConfig { + level: LogLevel; + name: string; +} + +export interface ProjectConfig { + /** + * Color palette for labels without their own. + */ + colors: Array; + + /** + * Leave a comment along with any update, explaining the changes that were made. + * + * @default `true` + */ + comment: boolean; + + /** + * Individual flag labels. + */ + flags: Array; + + /** + * Project name or path. + */ + name: string; + + /** + * Remote APIs. + */ + remote: RemoteOptions; + + /** + * Grouped state labels. + */ + states: Array; +} + /** * Config data for the app, loaded from CLI or DOM. */ export interface ConfigData { - logger: { - level: LogLevel; - name: string; - }; - projects: Array<{ - /** - * Color palette for labels without their own. - */ - colors: Array; - - /** - * Individual flag labels. - */ - flags: Array; - - /** - * Project name or path. - */ - name: string; - - /** - * Remote APIs. - */ - remote: RemoteOptions; - - /** - * Grouped state labels. - */ - states: Array; - }>; + logger: LoggerConfig; + projects: Array; } /** @@ -54,6 +65,7 @@ export function initConfig(): ConfigData { }, projects: [{ colors: [], + comment: true, flags: [], name: '', remote: { @@ -82,5 +94,11 @@ export function validateConfig(it: unknown): it is ConfigData { const ajv = new Ajv(SCHEMA_OPTIONS); ajv.addSchema(SCHEMA_DATA, 'cautious-journey'); - return ajv.validate('cautious-journey#/definitions/config', it) === true; + if (ajv.validate('cautious-journey#/definitions/config', it) === true) { + return true; + } else { + /* eslint-disable-next-line */ + console.error('invalid config', ajv.errors, it); + return false; + } } diff --git a/src/config/schema.yml b/src/config/schema.yml index cd48ee2..6dd7059 100644 --- a/src/config/schema.yml +++ b/src/config/schema.yml @@ -3,12 +3,17 @@ $id: cautious-journey definitions: label-ref: type: object + required: + - name properties: name: type: string change-set: type: object + required: + - adds + - removes properties: adds: type: array @@ -23,6 +28,9 @@ definitions: base-label: type: object + required: + - name + - requires properties: color: type: string @@ -59,7 +67,13 @@ definitions: - $ref: "#/definitions/change-set" - $ref: "#/definitions/base-label" - type: object + required: + - divider + - values properties: + divider: + type: string + default: "/" values: type: array items: @@ -71,6 +85,8 @@ definitions: - $ref: "#/definitions/change-set" - $ref: "#/definitions/base-label" - type: object + required: + - becomes properties: becomes: type: array @@ -78,38 +94,60 @@ definitions: $ref: "#/definitions/state-change" default: [] + project: + type: object + required: + - colors + - comment + - flags + - name + - remote + - states + properties: + colors: + type: array + items: + type: string + comment: + type: boolean + default: true + flags: + type: array + items: + $ref: "#/definitions/flag-label" + default: [] + name: + type: string + remote: + type: object + states: + type: array + items: + $ref: "#/definitions/state-label" + default: [] + + logger: + type: object + required: + - level + - name + properties: + level: + type: string + name: + type: string + config: type: object + required: + - logger + - projects properties: logger: - type: object - properties: - level: - type: string - name: - type: string + $ref: "#/definitions/logger" projects: type: array items: - type: object - properties: - colors: - type: array - items: - type: string - flags: - type: array - items: - $ref: "#/definitions/flag-label" - default: [] - name: - type: string - remote: - type: object - states: - type: array - items: - $ref: "#/definitions/state-label" - default: [] - + $ref: "#/definitions/project" + type: object \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 60d06d0..7d6c919 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ const STATUS_ERROR = 1; */ main(process.argv).then((status) => process.exit(status)).catch((err: Error) => { // eslint-disable-next-line no-console - console.error('uncaught error during main:', err); + console.error('uncaught error during main:', err.message); process.exit(STATUS_ERROR); }); diff --git a/src/labels.ts b/src/labels.ts index 94ed2ec..35de284 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -71,6 +71,8 @@ export interface StateValue extends BaseLabel { * Grouped labels: the equivalent of a radio group. */ export interface StateLabel extends BaseLabel { + divider: string; + /** * Values for this state. */ @@ -97,12 +99,12 @@ export function getLabelNames(flags: Array, states: Array return new Set(labels); } -export function splitValueName(name: string): Array { - return name.split('/'); +export function splitValueName(state: StateLabel, name: string): Array { + return name.split(state.divider); } export function getValueName(state: StateLabel, value: StateValue): string { - return `${state.name}/${value.name}`; + return `${state.name}${state.divider}${value.name}`; } /** diff --git a/src/main.ts b/src/main.ts index dd69b8b..26df876 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { doesExist, InvalidArgumentError, isNil } from '@apextoaster/js-utils'; +import { doesExist, InvalidArgumentError } from '@apextoaster/js-utils'; import { createSchema } from '@apextoaster/js-yaml-schema'; import { existsSync, readFileSync, realpathSync } from 'fs'; import { DEFAULT_SAFE_SCHEMA, safeLoad } from 'js-yaml'; @@ -7,11 +7,11 @@ import { alea } from 'seedrandom'; import { ConfigData, validateConfig } from './config'; import { Commands, createParser } from './config/args'; +import { dotGraph, graphLabels } from './graph'; import { BunyanLogger } from './logger/bunyan'; import { GithubRemote } from './remote/github'; import { syncIssueLabels, SyncOptions, syncProjectLabels } from './sync'; import { VERSION_INFO } from './version'; -import { graphLabels, dotGraph } from './graph'; export { FlagLabel, StateLabel } from './labels'; export { Remote, RemoteOptions } from './remote'; @@ -38,7 +38,7 @@ async function loadConfig(path: string): Promise { const config = safeLoad(rawConfig, { schema }); if (!validateConfig(config)) { - throw new Error(); + throw new InvalidArgumentError(); } return config as ConfigData; @@ -52,14 +52,16 @@ export async function main(argv: Array): Promise { const logger = BunyanLogger.create(config.logger); logger.info({ - args, - config, mode, version: VERSION_INFO, - }, 'startup environment'); + }, 'running main'); + logger.debug({ + args, + config, + }, 'runtime data'); for (const project of config.projects) { - const { colors, flags, name, states } = project; + const { name } = project; if (doesExist(args.project) && !args.project.includes(name)) { logger.info({ project: name }, 'skipping project'); @@ -77,13 +79,10 @@ export async function main(argv: Array): Promise { // mode switch const options: SyncOptions = { - colors, - flags, logger, - project: name, + project, random, remote, - states, }; switch (mode) { case Commands.GRAPH: diff --git a/src/resolve.ts b/src/resolve.ts index 38481e1..691800d 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -133,40 +133,45 @@ export function resolveLabels(options: ResolveInput): ResolveResult { label: name, }); } - } else { - const combinedValue: BaseLabel = { - adds: [...state.adds, ...value.adds], - name, - priority: defaultUntil(value.priority, state.priority, 0), - removes: [...state.removes, ...value.removes], - requires: [...state.requires, ...value.requires], - }; - if (checkLabelRules(combinedValue)) { - continue; - } + continue; + } - let removed = false; - for (const become of value.becomes) { - if (become.matches.every((l) => activeLabels.has(l.name))) { - checkLabelRules({ - ...combinedValue, - adds: become.adds, - removes: [...become.matches, ...become.removes], - requires: [], + const combinedValue: BaseLabel = { + adds: [...state.adds, ...value.adds], + name, + priority: defaultUntil(value.priority, state.priority, 0), + removes: [...state.removes, ...value.removes], + requires: [...state.requires, ...value.requires], + }; + + if (checkLabelRules(combinedValue)) { + continue; + } + + // TODO: flatten this bit and remove the mutable boolean + let removed = false; + for (const become of value.becomes) { + const matches = become.matches.every((l) => activeLabels.has(l.name)); + + if (matches) { + checkLabelRules({ + ...combinedValue, + adds: become.adds, + removes: [...become.matches, ...become.removes], + requires: [], + }); + + if (activeLabels.delete(name)) { + changes.push({ + cause: name, + effect: ChangeVerb.REMOVED, + label: name, }); - - if (activeLabels.delete(name)) { - changes.push({ - cause: name, - effect: ChangeVerb.REMOVED, - label: name, - }); - removed = true; - } - - break; + removed = true; } + + break; } if (removed) { diff --git a/src/sync.ts b/src/sync.ts index b84ab80..159ac99 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -6,25 +6,13 @@ import { FlagLabel, getLabelColor, getLabelNames, getValueName, StateLabel } fro import { LabelUpdate, Remote } from './remote'; import { resolveLabels } from './resolve'; import { defaultTo, defaultUntil, compareItems } from './utils'; +import { ProjectConfig } from './config'; export interface SyncOptions { - /** - */ - colors: Array; - - /** - */ - flags: Array; - logger: Logger; - project: string; + project: ProjectConfig; random: prng; remote: Remote; - - /** - * States from project config. - */ - states: Array; } /** @@ -32,34 +20,38 @@ export interface SyncOptions { * if there are changes and no errors, then updates the issue. */ export async function syncIssueLabels(options: SyncOptions): Promise { - const issues = await options.remote.listIssues({ - project: options.project, + const { logger, project, remote } = options; + const issues = await remote.listIssues({ + project: project.name, }); for (const issue of issues) { - options.logger.info({ issue }, 'project issue'); + logger.info({ issue }, 'project issue'); const { changes, errors, labels } = resolveLabels({ - flags: options.flags, + flags: project.flags, labels: issue.labels, - states: options.states, + states: project.states, }); - options.logger.debug({ changes, errors, issue, labels }, 'resolved labels'); + logger.debug({ changes, errors, issue, labels }, 'resolved labels'); // TODO: prompt user to update this particular issue const sameLabels = compareItems(issue.labels, labels) || changes.length === 0; if (sameLabels === false && errors.length === 0) { - options.logger.info({ changes, errors, issue, labels }, 'updating issue'); - await options.remote.updateIssue({ + logger.info({ changes, errors, issue, labels }, 'updating issue'); + await remote.updateIssue({ ...issue, labels, }); - await options.remote.createComment({ - ...issue, - changes, - errors, - }); + + if (project.comment) { + await remote.createComment({ + ...issue, + changes, + errors, + }); + } } } @@ -67,19 +59,21 @@ export async function syncIssueLabels(options: SyncOptions): Promise { } export async function syncProjectLabels(options: SyncOptions): Promise { - const labels = await options.remote.listLabels({ - project: options.project, + const { logger, project, remote } = options; + + const labels = await remote.listLabels({ + project: project.name, }); const present = new Set(labels.map((l) => l.name)); - const desired = getLabelNames(options.flags, options.states); + const desired = getLabelNames(project.flags, project.states); const combined = new Set([...desired, ...present]); for (const label of combined) { const exists = present.has(label); const expected = desired.has(label); - options.logger.info({ + logger.info({ exists, expected, label, @@ -90,15 +84,15 @@ export async function syncProjectLabels(options: SyncOptions): Promise const data = mustExist(labels.find((l) => l.name === label)); await syncSingleLabel(options, data); } else { - options.logger.warn({ label }, 'remove label'); - await options.remote.deleteLabel({ + logger.warn({ label }, 'remove label'); + await remote.deleteLabel({ name: label, - project: options.project, + project: project.name, }); } } else { if (expected) { - options.logger.info({ label }, 'create label'); + logger.info({ label }, 'create label'); await createLabel(options, label); } else { // skip @@ -110,27 +104,29 @@ export async function syncProjectLabels(options: SyncOptions): Promise } export async function createLabel(options: SyncOptions, name: string) { - const flag = options.flags.find((it) => name === it.name); + const { project, remote } = options; + + const flag = project.flags.find((it) => name === it.name); if (doesExist(flag)) { - await options.remote.createLabel({ - color: getLabelColor(options.colors, options.random, flag), + await remote.createLabel({ + color: getLabelColor(project.colors, options.random, flag), desc: mustExist(flag.desc), name, - project: options.project, + project: project.name, }); return; } - const state = options.states.find((it) => name.startsWith(it.name)); + const state = project.states.find((it) => name.startsWith(it.name)); if (doesExist(state)) { const value = state.values.find((it) => getValueName(state, it) === name); if (doesExist(value)) { - await options.remote.createLabel({ - color: getLabelColor(options.colors, options.random, state, value), + await remote.createLabel({ + color: getLabelColor(project.colors, options.random, state, value), desc: defaultUntil(value.desc, state.desc, ''), name: getValueName(state, value), - project: options.project, + project: project.name, }); return; @@ -139,6 +135,8 @@ export async function createLabel(options: SyncOptions, name: string) { } export async function syncLabelDiff(options: SyncOptions, oldLabel: LabelUpdate, newLabel: LabelUpdate) { + const { logger, project } = options; + const dirty = oldLabel.color !== mustExist(newLabel.color) || oldLabel.desc !== mustExist(newLabel.desc); @@ -148,41 +146,40 @@ export async function syncLabelDiff(options: SyncOptions, oldLabel: LabelUpdate, color: defaultTo(newLabel.color, oldLabel.color), desc: defaultTo(newLabel.desc, oldLabel.desc), name: oldLabel.name, - project: options.project, + project: project.name, }; - options.logger.debug({ body, newLabel, oldLabel }, 'update label'); - + logger.debug({ body, newLabel, oldLabel }, 'updating label'); const resp = await options.remote.updateLabel(body); - - options.logger.debug({ resp }, 'update resp'); + logger.debug({ resp }, 'update response'); } } export async function syncSingleLabel(options: SyncOptions, label: LabelUpdate): Promise { - const flag = options.flags.find((it) => label.name === it.name); + const { project } = options; + const flag = project.flags.find((it) => label.name === it.name); if (doesExist(flag)) { - const color = getLabelColor(options.colors, options.random, flag); + const color = getLabelColor(project.colors, options.random, flag); await syncLabelDiff(options, label, { color, desc: defaultTo(flag.desc, label.desc), name: flag.name, - project: options.project, + project: project.name, }); return; } - const state = options.states.find((it) => label.name.startsWith(it.name)); + const state = project.states.find((it) => label.name.startsWith(it.name)); if (doesExist(state)) { const value = state.values.find((it) => getValueName(state, it) === label.name); if (doesExist(value)) { - const color = mustExist(getLabelColor(options.colors, options.random, state, value)); + const color = mustExist(getLabelColor(project.colors, options.random, state, value)); await syncLabelDiff(options, label, { color, desc: defaultTo(value.desc, label.desc), name: getValueName(state, value), - project: options.project, + project: project.name, }); return; diff --git a/src/utils.ts b/src/utils.ts index efca542..192b119 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -35,3 +35,11 @@ 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/TestLabels.ts b/test/TestLabels.ts index 27cfb72..176b80c 100644 --- a/test/TestLabels.ts +++ b/test/TestLabels.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { alea } from 'seedrandom'; -import { getLabelColor, getLabelNames, prioritySort } from '../src/labels'; +import { getLabelColor, getLabelNames, prioritySort, StateLabel } from '../src/labels'; describe('label helpers', () => { describe('label name helper', () => { @@ -38,8 +38,9 @@ describe('label helpers', () => { removes: [], requires: [], }]; - const states = [{ + const states: Array = [{ adds: [], + divider: '/', name: 'foo', priority: 1, removes: [], @@ -47,6 +48,7 @@ describe('label helpers', () => { values, }, { adds: [], + divider: '/', name: 'bar', priority: 1, removes: [], @@ -109,6 +111,7 @@ describe('label helpers', () => { expect(getLabelColor(['test'], alea(), { adds: [], color: 'beans', + divider: '/', name: '', priority: 1, removes: [], @@ -129,6 +132,7 @@ describe('label helpers', () => { expect(getLabelColor(['test'], alea(), { adds: [], color: 'beans', + divider: '/', name: '', priority: 1, removes: [], diff --git a/test/sync/TestSyncLabels.ts b/test/sync/TestSyncLabels.ts index 6a8b9b2..1f0b07c 100644 --- a/test/sync/TestSyncLabels.ts +++ b/test/sync/TestSyncLabels.ts @@ -12,30 +12,35 @@ describe('label sync', () => { const logger = BunyanLogger.create({ name: 'test', }); - const remote = new GithubRemote({ + const remoteConfig = { data: {}, dryrun: true, logger, type: '', - }); + }; + const remote = new GithubRemote(remoteConfig); const updateSpy = spy(remote, 'updateLabel'); await syncSingleLabel({ - colors: [ - 'ff0000', - ], - flags: [{ - adds: [], - name: 'foo', - priority: 1, - removes: [], - requires: [], - }], logger, - project: '', + project: { + colors: [ + 'ff0000', + ], + comment: true, + flags: [{ + adds: [], + name: 'foo', + priority: 1, + removes: [], + requires: [], + }], + name: '', + remote: remoteConfig, + states: [], + }, random: alea(), remote, - states: [], }, { color: '', desc: '',