Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support parameter prompting from command listeners #661

Merged
merged 1 commit into from Jan 23, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/api-helper/goal/chooseAndSetGoals.ts
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
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
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
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
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
@@ -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
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
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
@@ -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
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
@@ -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
@@ -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
}
}
}