Skip to content

Commit

Permalink
Auto merge pull request #661 from atomist/sdm
Browse files Browse the repository at this point in the history
Support parameter prompting from command listeners
  • Loading branch information
atomist-bot authored Jan 23, 2019
2 parents 657fab7 + b055391 commit 251c3df
Show file tree
Hide file tree
Showing 15 changed files with 613 additions and 285 deletions.
5 changes: 4 additions & 1 deletion lib/api-helper/goal/chooseAndSetGoals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
AddressChannels,
addressChannelsFor,
} from "../../api/context/addressChannels";
import { ParameterPromptFactory } from "../../api/context/parameterPrompt";
import {
NoPreferenceStore,
PreferenceStore,
Expand Down Expand Up @@ -83,7 +84,9 @@ export interface ChooseAndSetGoalsRules {

enrichGoal?: (goal: SdmGoalMessage) => Promise<SdmGoalMessage>;

preferencesFactory?: PreferenceStoreFactory;
preferencesFactory?: PreferenceStoreFactory;

parameterPromptFactory?: ParameterPromptFactory<any>;
}

/**
Expand Down
1 change: 1 addition & 0 deletions lib/api-helper/goal/executeGoal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as _ from "lodash";
import * as path from "path";
import { sprintf } from "sprintf-js";
import { AddressChannels } from "../../api/context/addressChannels";
import { NoParameterPrompt } from "../../api/context/parameterPrompt";
import {
ExecuteGoalResult,
isFailure,
Expand Down
26 changes: 19 additions & 7 deletions lib/api-helper/machine/handlerRegistrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
} from "@atomist/slack-messages";
import { GitHubRepoTargets } from "../../api/command/target/GitHubRepoTargets";
import { isTransformModeSuggestion } from "../../api/command/target/TransformModeSuggestion";
import { NoParameterPrompt } from "../../api/context/parameterPrompt";
import { NoPreferenceStore } from "../../api/context/preferenceStore";
import { SdmContext } from "../../api/context/SdmContext";
import { CommandListenerInvocation } from "../../api/listener/CommandListener";
Expand Down Expand Up @@ -281,6 +282,12 @@ export function eventHandlerRegistrationToEvent(sdm: MachineOrMachineOptions, e:
);
}

export class CommandListenerExecutionInterruptError extends Error {
constructor(public readonly message) {
super(message);
}
}

function toOnCommand<PARAMS>(c: CommandHandlerRegistration<any>): (sdm: MachineOrMachineOptions) => OnCommand<PARAMS> {
addParametersDefinedInBuilder(c);
return sdm => async (context, parameters) => {
Expand All @@ -289,12 +296,16 @@ function toOnCommand<PARAMS>(c: CommandHandlerRegistration<any>): (sdm: MachineO
await c.listener(cli);
return Success;
} catch (err) {
logger.error("Error executing command '%s': %s", cli.commandName, err.message);
logger.error(err.stack);
return {
code: 1,
message: err.message,
};
if (err instanceof CommandListenerExecutionInterruptError) {
return Success;
} else {
logger.error("Error executing command '%s': %s", cli.commandName, err.message);
logger.error(err.stack);
return {
code: 1,
message: err.message,
};
}
}
};
}
Expand Down Expand Up @@ -322,14 +333,15 @@ function toCommandListenerInvocation<P>(c: CommandRegistration<P>,
}
}

// TODO do a look up for associated channels
const addressChannels = (msg, opts) => context.messageClient.respond(msg, opts);
const promptFor = sdm.parameterPromptFactory ? sdm.parameterPromptFactory(context) : NoParameterPrompt;
const preferences = sdm.preferenceStoreFactory ? sdm.preferenceStoreFactory(context) : NoPreferenceStore;
return {
commandName: c.name,
context,
parameters,
addressChannels,
promptFor,
preferences,
credentials,
ids,
Expand Down
2 changes: 2 additions & 0 deletions lib/api-helper/testsupport/fakeCommandListenerInvocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { AddressNoChannels } from "../../api/context/addressChannels";
import { NoParameterPrompt } from "../../api/context/parameterPrompt";
import { NoPreferenceStore } from "../../api/context/preferenceStore";
import { CommandListenerInvocation } from "../../api/listener/CommandListener";
import { fakeContext } from "./fakeContext";
Expand All @@ -25,6 +26,7 @@ export function fakeCommandListenerInvocation<P>(opts: Partial<CommandListenerIn
parameters: opts.parameters,
context: fakeContext(),
addressChannels: AddressNoChannels,
promptFor: NoParameterPrompt,
preferences: NoPreferenceStore,
credentials: opts.credentials,
...opts,
Expand Down
1 change: 1 addition & 0 deletions lib/api/context/SdmContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface SdmContext {
* Store and retrieve preferences for this SDM or team
*/
preferences: PreferenceStore;

}

/**
Expand Down
104 changes: 104 additions & 0 deletions lib/api/context/parameterPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright © 2019 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 {
AutomationContextAware,
CommandIncoming,
configurationValue,
HandlerContext,
} from "@atomist/automation-client";
import { Arg } from "@atomist/automation-client/lib/internal/transport/RequestProcessor";
import { WebSocketLifecycle } from "@atomist/automation-client/lib/internal/transport/websocket/WebSocketLifecycle";
import { HandlerResponse } from "@atomist/automation-client/lib/internal/transport/websocket/WebSocketMessageClient";
import { Parameter } from "@atomist/automation-client/lib/metadata/automationMetadata";
import * as _ from "lodash";
import { CommandListenerExecutionInterruptError } from "../../api-helper/machine/handlerRegistrations";
import { ParametersObjectValue } from "../registration/ParametersDefinition";

/**
* Object with properties defining parameters. Useful for combination via spreads.
*/
export type ParametersPromptObject<PARAMS, K extends keyof PARAMS = keyof PARAMS> = Record<K, ParametersObjectValue>;

/**
* Factory to create a ParameterPrompt
*/
export type ParameterPromptFactory<PARAMS> = (ctx: HandlerContext) => ParameterPrompt<PARAMS>;

/**
* ParameterPrompts let the caller prompt for the provided parameters
*/
export type ParameterPrompt<PARAMS> = (parameters: ParametersPromptObject<PARAMS>) => Promise<PARAMS>;

/**
* No-op NoParameterPrompt implementation that never prompts for new parameters
* @constructor
*/
export const NoParameterPrompt: ParameterPrompt<any> = async () => ({});

export const AtomistContinuationMimeType = "application/x-atomist-continuation+json";

/**
* Default ParameterPromptFactory that uses the WebSocket connection to send parameter prompts to the backend.
* @param ctx
*/
export function commandRequestParameterPromptFactory<T>(ctx: HandlerContext): ParameterPrompt<T> {
return async parameters => {
const trigger = (ctx as any as AutomationContextAware).trigger as CommandIncoming;

const existingParameters = trigger.parameters;
const newParameters = _.cloneDeep(parameters);

// Find out if - and if - which parameters are actually missing
let missing = false;
const params: any = {};
for (const parameter in parameters) {
if (!existingParameters.some(p => p.name === parameter)) {
missing = true;
} else {
params[parameter] = existingParameters.find(p => p.name === parameter).value;
delete newParameters[parameter];
}
}

// If no parameters are missing we can return the already collected parameters
if (!missing) {
return params;
}

// Create a continuation message using the existing HandlerResponse and mixing in parameters
// and parameter_specs
const response: HandlerResponse & { parameters: Arg[], parameter_specs: Parameter[] } = {
api_version: "1",
correlation_id: trigger.correlation_id,
team: trigger.team,
command: trigger.command,
source: trigger.source,
parameters: trigger.parameters,
parameter_specs: _.map(newParameters, (v, k) => ({
...v,
name: k,
required: v.required !== undefined ? v.required : true,
pattern: v.pattern ? v.pattern.source : undefined,
})),
content_type: AtomistContinuationMimeType,
};

await configurationValue<WebSocketLifecycle>("ws.lifecycle").send(response);
throw new CommandListenerExecutionInterruptError(
`Prompting for new parameters: ${_.map(newParameters, (v, k) => k).join(", ")}`);
};
}
2 changes: 1 addition & 1 deletion lib/api/context/preferenceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,4 @@ export const NoPreferenceStore: PreferenceStore = {

put: async (key, value) => value,

}
};
2 changes: 1 addition & 1 deletion lib/api/goal/support/GoalScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ export interface GoalScheduler {
* @param gi
*/
schedule(gi: GoalInvocation): Promise<ExecuteGoalResult>;
}
}
18 changes: 17 additions & 1 deletion lib/api/listener/CommandListener.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright © 2018 Atomist, Inc.
* Copyright © 2019 Atomist, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -18,6 +18,7 @@ import {
NoParameters,
RemoteRepoRef,
} from "@atomist/automation-client";
import { ParametersDefinition } from "../registration/ParametersDefinition";
import { SdmListener } from "./Listener";
import { ParametersInvocation } from "./ParametersInvocation";

Expand All @@ -33,6 +34,21 @@ export interface CommandListenerInvocation<PARAMS = NoParameters> extends Parame
*/
ids?: RemoteRepoRef[];

/**
* Prompt for additional parameters needed during execution of the command listener.
*
* Callers should wait for the returned Promise to resolve. It will resolve with the requested
* parameters if those have already been collected. If not, a parameter prompt request to the backend
* will be sent and the Promise will reject. Once the new parameters are collected, a new
* command invocation will be sent and the command listener will restart.
*
* This requires that any state that gets created before calling promptFor can be re-created when
* re-entering the listener function. Also any action taken before calling promptFor needs to be
* implemented using idempotency patterns.
* @param parameters
*/
promptFor<NEWPARAMS>(parameters: ParametersDefinition<NEWPARAMS>): Promise<NEWPARAMS>;

}

export type CommandListener<PARAMS = NoParameters> =
Expand Down
11 changes: 10 additions & 1 deletion lib/api/machine/SoftwareDeliveryMachineOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import { ProgressLogFactory } from "../../spi/log/ProgressLog";
import { ProjectLoader } from "../../spi/project/ProjectLoader";
import { RepoRefResolver } from "../../spi/repo-ref/RepoRefResolver";
import { AddressChannels } from "../context/addressChannels";
import {
ParameterPrompt,
ParameterPromptFactory,
} from "../context/parameterPrompt";
import { PreferenceStoreFactory } from "../context/preferenceStore";
import { GoalScheduler } from "../goal/support/GoalScheduler";
import { RepoTargets } from "./RepoTargets";
Expand Down Expand Up @@ -81,10 +85,15 @@ export interface SoftwareDeliveryMachineOptions {
targets?: Maker<RepoTargets>;

/**
* Optional Strategy to create a new PreferenceStore implementation
* Optional strategy to create a new PreferenceStore implementation
*/
preferenceStoreFactory?: PreferenceStoreFactory;

/**
* Optional strategy to allow prompting for additional parameters
*/
parameterPromptFactory?: ParameterPromptFactory<any>;

/**
* Optional strategy for launching goals in different infrastructure
*/
Expand Down
12 changes: 7 additions & 5 deletions lib/api/registration/ParametersDefinition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright © 2018 Atomist, Inc.
* Copyright © 2019 Atomist, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -25,13 +25,15 @@ export type ParametersDefinition<PARAMS = any> = ParametersListing | ParametersO
*/
export interface HasDefaultValue { defaultValue?: any; }

export type ParametersObjectValue = (BaseParameter & HasDefaultValue) | MappedParameterOrSecretDeclaration;
export type ParametersObjectValue = (BaseParameter & HasDefaultValue);

export type MappedParameterOrSecretObjectValue = MappedParameterOrSecretDeclaration;

/**
* Object with properties defining parameters. Useful for combination
* via spreads.
* Object with properties defining parameters, secrets and mapped parameters. Useful for combination via spreads.
*/
export type ParametersObject<PARAMS, K extends keyof PARAMS = keyof PARAMS> = Record<K, ParametersObjectValue>;
export type ParametersObject<PARAMS, K extends keyof PARAMS = keyof PARAMS>
= Record<K, ParametersObjectValue | MappedParameterOrSecretObjectValue>;

export enum DeclarationType {
mapped = "mapped",
Expand Down
11 changes: 5 additions & 6 deletions lib/graphql/query/BranchForName.graphql
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
query BranchForName($repo: String!, $owner: String!, $branch: String!) {
Branch(name: $branch) {
id
repo(name: $repo, owner: $owner) @required {
id
}
Branch(name: $branch) {
id
repo(name: $repo, owner: $owner) @required {
id
}
}
}

Loading

0 comments on commit 251c3df

Please sign in to comment.