@atomist/sdm
Version:
Atomist Software Delivery Machine SDK
378 lines (335 loc) • 13.1 kB
text/typescript
/*
* Copyright © 2020 Atomist, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Configuration, deepMergeConfigs } from "@atomist/automation-client/lib/configuration";
import { NoParameters } from "@atomist/automation-client/lib/SmartParameters";
import * as changeCase from "change-case";
import * as fg from "fast-glob";
import * as fs from "fs-extra";
import * as yaml from "js-yaml";
import * as stringify from "json-stringify-safe";
import * as _ from "lodash";
import * as path from "path";
import * as trace from "stack-trace";
import { ExtensionPack } from "../../../api/machine/ExtensionPack";
import { SoftwareDeliveryMachine } from "../../../api/machine/SoftwareDeliveryMachine";
import { SoftwareDeliveryMachineConfiguration } from "../../../api/machine/SoftwareDeliveryMachineOptions";
import { PushTest } from "../../../api/mapping/PushTest";
import { CommandHandlerRegistration } from "../../../api/registration/CommandHandlerRegistration";
import { EventHandlerRegistration } from "../../../api/registration/EventHandlerRegistration";
import { githubGoalStatusSupport } from "../../../pack/github-goal-status/github";
import { goalStateSupport } from "../../../pack/goal-state/goalState";
import { toArray } from "../../util/misc/array";
import {
configure,
ConfigureMachineOptions,
CreateGoals,
DeliveryGoals,
GoalConfigurer,
GoalCreator,
GoalData,
} from "../configure";
import { decorateSoftwareDeliveryMachine } from "./mapCommand";
import { GoalMaker, mapGoals } from "./mapGoals";
import { PushTestMaker } from "./mapPushTests";
import { mapRules } from "./mapRules";
import { camelCase, watchPaths } from "./util";
export interface YamlSoftwareDeliveryMachineConfiguration {
extensionPacks?: ExtensionPack[];
}
export type YamlCommandHandlerRegistration = Omit<CommandHandlerRegistration, "name" | "paramsMaker" | "parameters">;
export type CommandMaker<PARAMS = NoParameters> = (
sdm: SoftwareDeliveryMachine,
) => Promise<YamlCommandHandlerRegistration> | YamlCommandHandlerRegistration;
export type YamlEventHandler<PARAMS = NoParameters> = Omit<EventHandlerRegistration<PARAMS>, "name">;
export type EventMaker<PARAMS = NoParameters> = (
sdm: SoftwareDeliveryMachine,
) => Promise<YamlEventHandler<PARAMS>> | YamlEventHandler<PARAMS>;
export type ConfigurationMaker = (
cfg: Configuration,
) =>
| Promise<SoftwareDeliveryMachineConfiguration<YamlSoftwareDeliveryMachineConfiguration>>
| SoftwareDeliveryMachineConfiguration<YamlSoftwareDeliveryMachineConfiguration>;
export enum Target {
SDM = "sdm",
Skill = "skill",
}
/**
* Configuration options for the yaml support
*/
export interface ConfigureYamlOptions<G extends DeliveryGoals> {
options?: ConfigureMachineOptions;
tests?: Record<string, PushTest>;
goals?: GoalCreator<G>;
configurers?: GoalConfigurer<G> | Array<GoalConfigurer<G>>;
cwd?: string;
makers?: {
commands: Record<string, CommandMaker>;
events: Record<string, EventMaker>;
goals: Record<string, GoalMaker>;
tests: Record<string, PushTestMaker>;
configurations: Record<string, ConfigurationMaker>;
};
patterns?: {
commands?: string[];
events?: string[];
ingesters?: string[];
goals?: string[];
tests?: string[];
configurations?: string[];
};
target?: Target;
}
async function createExtensions(
cwd: string,
options: ConfigureYamlOptions<any>,
cfg: YamlSoftwareDeliveryMachineConfiguration,
sdm: SoftwareDeliveryMachine,
): Promise<void> {
const commandCallback = async (c, k) => {
let registration: CommandHandlerRegistration;
try {
const makerResult = await c(sdm);
registration = { name: k, ...makerResult };
} catch (e) {
e.message = `Failed to make command using CommandMaker ${k}: ${e.message}`;
throw e;
}
try {
sdm.addCommand(registration);
} catch (e) {
e.message = `Failed to add command ${k} '${stringify(registration)}': ${e.message}`;
throw e;
}
};
if (!options?.makers?.commands) {
await awaitIterable(await requireCommands(cwd, options?.patterns?.commands), commandCallback);
} else {
await awaitIterable(options.makers.commands, commandCallback);
}
const eventCallback = async (e, k) => {
let registration: EventHandlerRegistration;
try {
const makerResult = await e(sdm);
registration = { name: k, ...makerResult };
} catch (e) {
e.message = `Failed to make event using EventMaker ${k}: ${e.message}`;
throw e;
}
try {
sdm.addEvent(registration);
} catch (e) {
e.message = `Failed to add event ${k} '${stringify(registration)}': ${e.message}`;
throw e;
}
};
if (!options.makers?.events) {
await awaitIterable(await requireEvents(cwd, options?.patterns?.events), eventCallback);
} else {
await awaitIterable(options.makers.events, eventCallback);
}
await requireIngesters(cwd, options?.patterns?.ingesters);
sdm.addExtensionPacks(
...(sdm.configuration.sdm?.extensionPacks || [
goalStateSupport({
cancellation: {
enabled: true,
},
}),
githubGoalStatusSupport(),
]),
);
}
/**
* Load one or more yaml files to create goal sets
*
* When providing more than one yaml file, files are being loaded
* in provided order with later files overwriting earlier ones.
*/
export async function configureYaml<G extends DeliveryGoals>(
patterns: string | string[],
options: ConfigureYamlOptions<G> = {},
): Promise<Configuration> {
// Get the caller of this function to determine the cwd for resolving glob patterns
const callerCallSite = trace
.get()
.filter(t => t.getFileName() !== __filename)
.filter(t => !!t.getFileName())[0];
const cwd = options.cwd || path.dirname(callerCallSite.getFileName());
const cfg = await createConfiguration(cwd, options);
return configure<G>(async sdm => {
let sdmToUse = sdm;
if (options.target === Target.Skill) {
sdmToUse = decorateSoftwareDeliveryMachine(sdm);
}
await createExtensions(cwd, options, cfg, sdmToUse);
return createGoalData(patterns, cwd, options, cfg, sdmToUse);
}, options.options || {});
}
async function createConfiguration(
cwd: string,
options: ConfigureYamlOptions<any>,
): Promise<YamlSoftwareDeliveryMachineConfiguration> {
const cfg: any = {};
if (!options?.makers?.configurations) {
await awaitIterable(await requireConfiguration(cwd, options?.patterns?.configurations), async v => {
const c = await v(cfg);
deepMergeConfigs(cfg, c);
});
} else {
await awaitIterable(options.makers.configurations, async v => {
const c = await v(cfg);
deepMergeConfigs(cfg, c);
});
}
_.update(options, "options.preProcessors", old => (!!old ? old : []));
options.options.preProcessors = [
async c => deepMergeConfigs(c, cfg) as any,
...toArray(options.options.preProcessors),
];
return cfg;
}
// tslint:disable-next-line:cyclomatic-complexity
async function createGoalData<G extends DeliveryGoals>(
patterns: string | string[],
cwd: string,
options: ConfigureYamlOptions<G>,
cfg: YamlSoftwareDeliveryMachineConfiguration,
sdm: SoftwareDeliveryMachine & { createGoals: CreateGoals<G> },
): Promise<GoalData> {
const additionalGoals = options.goals ? await sdm.createGoals(options.goals, options.configurers) : {};
const goalMakers = !!options.makers?.goals
? options.makers.goals
: await requireGoals(cwd, _.get(cfg, "extensions.goals"));
const testMakers = !!options.makers?.tests
? options.makers.tests
: await requireTests(cwd, _.get(cfg, "extensions.tests"));
const files = await resolvePaths(cwd, patterns, true);
const goalData: GoalData = {};
for (const file of files) {
const configs = yaml.safeLoadAll(await fs.readFile(path.join(cwd, file), { encoding: "UTF-8" }));
for (const config of configs) {
if (!!config.configuration) {
_.merge(sdm.configuration, camelCase(config.configuration));
}
if (!!config.skill) {
_.merge(sdm.configuration, camelCase(config.skill));
sdm.configuration.name = config.skill.name;
(sdm as any).name = config.skill.title || config.skill.name;
}
for (const k in config) {
if (config.hasOwnProperty(k)) {
const value = config[k];
// Ignore two special keys used to set up the SDM
if (k === "name" || k === "configuration" || k === "skill") {
continue;
}
// Just create goals and register with SDM
if (k === "goals") {
await mapGoals(
sdm,
camelCase(value),
additionalGoals,
goalMakers,
options.tests || {},
testMakers,
);
}
if (k === "rules") {
await mapRules(value, goalData, sdm, options, additionalGoals, goalMakers, testMakers);
}
}
}
}
}
return goalData;
}
async function requireExtensions<EXT>(
cwd: string,
pattern: string[],
cb: (v: EXT, k: string, e: Record<string, EXT>) => void = () => {},
): Promise<Record<string, EXT>> {
if (pattern.length === 0) {
return {};
}
const extensions: Record<string, EXT> = {};
const files = await resolvePaths(cwd, pattern);
for (const file of files) {
const testJs = require(`${cwd}/${file}`);
_.forEach(testJs, (v: EXT, k: string) => {
if (!!cb) {
cb(v, k, extensions);
}
extensions[k] = v;
});
}
return extensions;
}
async function requireTests(
cwd: string,
pattern: string[] = ["tests/**.js", "lib/tests/**.js"],
): Promise<Record<string, PushTestMaker>> {
return requireExtensions<PushTestMaker>(cwd, pattern, (v, k, e) => (e[changeCase.snake(k)] = v));
}
async function requireGoals(
cwd: string,
pattern: string[] = ["goals/**.js", "lib/goals/**.js"],
): Promise<Record<string, GoalMaker>> {
return requireExtensions<GoalMaker>(cwd, pattern, (v, k, e) => (e[changeCase.snake(k)] = v));
}
async function requireCommands(
cwd: string,
pattern: string[] = ["commands/**.js", "lib/commands/**.js"],
): Promise<Record<string, CommandMaker>> {
return requireExtensions<CommandMaker>(cwd, pattern);
}
async function requireEvents(
cwd: string,
pattern: string[] = ["events/**.js", "lib/events/**.js"],
): Promise<Record<string, EventMaker>> {
return requireExtensions<EventMaker>(cwd, pattern);
}
async function requireConfiguration(
cwd: string,
pattern: string[] = ["config.js", "lib/config.js"],
): Promise<Record<string, ConfigurationMaker>> {
return requireExtensions<ConfigurationMaker>(cwd, pattern);
}
async function requireIngesters(
cwd: string,
pattern: string[] = ["ingesters/**.graphql", "lib/graphql/ingester/**.graphql"],
): Promise<string[]> {
const ingesters: string[] = [];
const files = await resolvePaths(cwd, pattern);
for (const file of files) {
ingesters.push((await fs.readFile(file)).toString());
}
return ingesters;
}
async function awaitIterable<G>(elems: Record<string, G>, cb: (v: G, k: string) => Promise<any>): Promise<void> {
for (const k in elems) {
if (elems.hasOwnProperty(k)) {
const v = elems[k];
await cb(v, k);
}
}
}
async function resolvePaths(cwd: string, patterns: string | string[], watch: boolean = false): Promise<string[]> {
const paths = await fg(toArray(patterns), { ignore: [`**/{.git,node_modules}/**`], cwd });
if (watch) {
watchPaths(paths);
}
return paths;
}