1
0
Fork 0

feat(config): add project comment, state divider to config

This commit is contained in:
ssube 2020-08-18 08:54:57 -05:00 committed by BZ Libby
parent 3a3facb282
commit 2feb5a3c60
16 changed files with 255 additions and 205 deletions

View File

@ -1,11 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [cautious-journey](./cautious-journey.md) &gt; [SyncOptions](./cautious-journey.syncoptions.md) &gt; [flags](./cautious-journey.syncoptions.flags.md)
[Home](./index.md) &gt; [cautious-journey](./cautious-journey.md) &gt; [StateLabel](./cautious-journey.statelabel.md) &gt; [divider](./cautious-journey.statelabel.divider.md)
## SyncOptions.flags property
## StateLabel.divider property
<b>Signature:</b>
```typescript
flags: Array<FlagLabel>;
divider: string;
```

View File

@ -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&lt;[StateValue](./cautious-journey.statevalue.md)<!-- -->&gt; | Values for this state. |

View File

@ -1,11 +0,0 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [cautious-journey](./cautious-journey.md) &gt; [SyncOptions](./cautious-journey.syncoptions.md) &gt; [colors](./cautious-journey.syncoptions.colors.md)
## SyncOptions.colors property
<b>Signature:</b>
```typescript
colors: Array<string>;
```

View File

@ -14,11 +14,8 @@ export interface SyncOptions
| Property | Type | Description |
| --- | --- | --- |
| [colors](./cautious-journey.syncoptions.colors.md) | Array&lt;string&gt; | |
| [flags](./cautious-journey.syncoptions.flags.md) | Array&lt;[FlagLabel](./cautious-journey.flaglabel.md)<!-- -->&gt; | |
| [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&lt;[StateLabel](./cautious-journey.statelabel.md)<!-- -->&gt; | States from project config. |

View File

@ -7,5 +7,5 @@
<b>Signature:</b>
```typescript
project: string;
project: ProjectConfig;
```

View File

@ -1,13 +0,0 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [cautious-journey](./cautious-journey.md) &gt; [SyncOptions](./cautious-journey.syncoptions.md) &gt; [states](./cautious-journey.syncoptions.states.md)
## SyncOptions.states property
States from project config.
<b>Signature:</b>
```typescript
states: Array<StateLabel>;
```

View File

@ -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<string>;
/**
* Leave a comment along with any update, explaining the changes that were made.
*
* @default `true`
*/
comment: boolean;
/**
* Individual flag labels.
*/
flags: Array<FlagLabel>;
/**
* Project name or path.
*/
name: string;
/**
* Remote APIs.
*/
remote: RemoteOptions;
/**
* Grouped state labels.
*/
states: Array<StateLabel>;
}
/**
* 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<string>;
/**
* Individual flag labels.
*/
flags: Array<FlagLabel>;
/**
* Project name or path.
*/
name: string;
/**
* Remote APIs.
*/
remote: RemoteOptions;
/**
* Grouped state labels.
*/
states: Array<StateLabel>;
}>;
logger: LoggerConfig;
projects: Array<ProjectConfig>;
}
/**
@ -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;
}
}

View File

@ -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

View File

@ -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);
});

View File

@ -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<FlagLabel>, states: Array<StateLabel>
return new Set(labels);
}
export function splitValueName(name: string): Array<string> {
return name.split('/');
export function splitValueName(state: StateLabel, name: string): Array<string> {
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}`;
}
/**

View File

@ -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<ConfigData> {
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<string>): Promise<number> {
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<string>): Promise<number> {
// mode switch
const options: SyncOptions = {
colors,
flags,
logger,
project: name,
project,
random,
remote,
states,
};
switch (mode) {
case Commands.GRAPH:

View File

@ -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) {

View File

@ -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<string>;
/**
*/
flags: Array<FlagLabel>;
logger: Logger;
project: string;
project: ProjectConfig;
random: prng;
remote: Remote;
/**
* States from project config.
*/
states: Array<StateLabel>;
}
/**
@ -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<unknown> {
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<unknown> {
}
export async function syncProjectLabels(options: SyncOptions): Promise<unknown> {
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<unknown>
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<unknown>
}
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<void> {
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;

View File

@ -35,3 +35,11 @@ export function compareItems<T>(a: Array<T>, b: Array<T>): boolean {
return true;
}
interface Collection<T> {
has(value: T): boolean;
}
export function contains<T>(a: Collection<T>, b: Array<T>): boolean {
return b.every((it) => a.has(it));
}

View File

@ -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<StateLabel> = [{
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: [],

View File

@ -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: '',