From 26de2600faecd75643ecf8bef476493f046651ad Mon Sep 17 00:00:00 2001 From: mshanemc Date: Fri, 24 May 2024 11:22:08 -0500 Subject: [PATCH] feat: v2 change case API --- .eslintrc.cjs | 2 +- .vscode/settings.json | 3 ++ messages/changecase.md | 40 ++++-------------- messages/check.md | 3 ++ messages/close.md | 7 ++++ messages/create.md | 11 +++++ src/changeCaseApi.ts | 13 +----- src/commands/check.ts | 6 ++- src/commands/close.ts | 80 ++++++++++++------------------------ src/commands/create.ts | 38 ++++++++--------- src/flags.ts | 10 ++--- src/types.ts | 93 ++++++++++++++++++++++++++++-------------- 12 files changed, 147 insertions(+), 159 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 messages/check.md create mode 100644 messages/close.md create mode 100644 messages/create.md diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a572c58..e3c946d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,7 +5,7 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ module.exports = { - extends: ['eslint-config-salesforce-typescript', 'eslint-config-salesforce-license', 'plugin:sf-plugin/migration'], + extends: ['eslint-config-salesforce-typescript', 'eslint-config-salesforce-license', 'plugin:sf-plugin/recommended'], rules: { camelcase: 'off', 'sf-plugin/get-connection-with-version': 'off', diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ad92582 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} diff --git a/messages/changecase.md b/messages/changecase.md index 9082a3c..23a8346 100644 --- a/messages/changecase.md +++ b/messages/changecase.md @@ -1,43 +1,19 @@ -# check.description +# flags.changecaseid.summary -check the status of a change case record +change case id -# create.description +# flags.dry-run.summary -create a change case record based on a template ID with one implementation step +run the command without making any API calls - all calls will be 'successful' -# create.flags.templateid.description +# NoOrgError -change case template id +The command needs either a target org specified via flag or an environment variable: %s -# create.flags.release.description +# flags.release.summary schedule build of the new release -# create.flags.location.description +# flags.location.summary url of the source control location - -# create.flags.configurationitem.description - -Full path from the configuration item, ex: Salesforce.SF_Off_Core.DeveloperTools.NPM - -# close.description - -stops the implementation steps, and closes the change case record - -# command.flags.changecaseid.description - -change case id - -# command.flags.dryrun.description - -run the command without making any API calls - all calls will be 'successful' - -# close.flags.status.summary - -What the status of the implementation steps should be set to - -# NoOrgError - -The command needs either a target org specified via flag or an environment variable: %s diff --git a/messages/check.md b/messages/check.md new file mode 100644 index 0000000..10b0207 --- /dev/null +++ b/messages/check.md @@ -0,0 +1,3 @@ +# summary + +Checks if the change case (for the release and location) is currently blocked by a moratorium (that is, it's pre-approved and scheduled) diff --git a/messages/close.md b/messages/close.md new file mode 100644 index 0000000..4f8da4d --- /dev/null +++ b/messages/close.md @@ -0,0 +1,7 @@ +# summary + +Close a change case and stop its implementation steps. + +# flags.status.summary + +What the status of the implementation steps should be set to. diff --git a/messages/create.md b/messages/create.md new file mode 100644 index 0000000..1c95fa2 --- /dev/null +++ b/messages/create.md @@ -0,0 +1,11 @@ +# summary + +create a change case record based on a template ID with one implementation step + +# flags.template-id.summary + +change case template id + +# flags.configuration-item.summary + +Full path from the configuration item, ex: Salesforce.SF_Off_Core.DeveloperTools.NPM diff --git a/src/changeCaseApi.ts b/src/changeCaseApi.ts index f9d8091..78a8856 100644 --- a/src/changeCaseApi.ts +++ b/src/changeCaseApi.ts @@ -6,7 +6,7 @@ */ import { Connection, SfError } from '@salesforce/core'; import { Ux } from '@salesforce/sf-plugins-core'; -import { Case, ChangeCaseApiResponse, CreateCaseResponse, Implementation } from './types.js'; +import { Case, Implementation } from './types.js'; const retrieveOrCreateBuildId = async (conn: Connection, ux: Ux, release: string): Promise => { const buildResults = await conn.query<{ Id: string }>(`SELECT Id FROM ADM_Build__c WHERE Name = '${release}'`); @@ -109,14 +109,3 @@ export const retrieveCaseFromIdOrRelease = async ({ return cases[0]; } }; - -export const parseErrors = (body: ChangeCaseApiResponse | CreateCaseResponse): string => { - if (body.errors) { - return body.errors.map((error) => error.message).join(','); - } - if (body.results?.length && typeof body.results[0].message === 'string') { - return body.results[0].message; - } else { - throw new SfError('Unexpected error response from change case API'); - } -}; diff --git a/src/commands/check.ts b/src/commands/check.ts index c520803..74292c3 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -13,7 +13,7 @@ import { changeCaseIdFlag, environmentAwareOrgFlag, locationFlag, releaseFlag } import { getEnvVarFullName } from '../functions.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/change-case-management', 'changecase'); +const messages = Messages.loadMessages('@salesforce/change-case-management', 'check'); // ID for Standard Pre Approved const CHANGE_TYPE_ID = env.getString(getEnvVarFullName('CHANGE_TYPE_ID'), 'a8hB00000004DIzIAM'); @@ -24,7 +24,9 @@ export type CheckResult = { type: string; }; export default class Check extends SfCommand { - public static readonly summary = messages.getMessage('check.description'); + public static readonly deprecated = true; + public static readonly hidden = true; + public static readonly summary = messages.getMessage('summary'); public static readonly examples = []; public static readonly flags = { 'target-org': environmentAwareOrgFlag({ required: true }), diff --git a/src/commands/close.ts b/src/commands/close.ts index d382d17..1d215f6 100644 --- a/src/commands/close.ts +++ b/src/commands/close.ts @@ -8,13 +8,13 @@ import { Flags, SfCommand, Ux } from '@salesforce/sf-plugins-core'; import { Connection, Messages, SfError } from '@salesforce/core'; import { AnyJson } from '@salesforce/ts-types'; -import { parseErrors, retrieveCaseFromIdOrRelease, retrieveImplementationFromCase } from '../changeCaseApi.js'; -import { Implementation, ChangeCaseApiResponse, StartApiResponse } from '../types.js'; +import { retrieveCaseFromIdOrRelease } from '../changeCaseApi.js'; +import { ChangeCaseCloseApiResponse } from '../types.js'; import { getEnvVarFullName } from '../functions.js'; import { changeCaseIdFlag, dryrunFlag, environmentAwareOrgFlag, locationFlag, releaseFlag } from '../flags.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/change-case-management', 'changecase'); +const messages = Messages.loadMessages('@salesforce/change-case-management', 'close'); export type CloseResult = { case: { @@ -23,8 +23,7 @@ export type CloseResult = { }; }; export default class Close extends SfCommand { - public static readonly summary = messages.getMessage('close.description'); - public static readonly description = messages.getMessage('close.description'); + public static readonly summary = messages.getMessage('summary'); public static readonly examples = []; @@ -34,7 +33,8 @@ export default class Close extends SfCommand { release: releaseFlag, location: locationFlag, status: Flags.string({ - summary: messages.getMessage('close.flags.status.summary'), + // eslint-disable-next-line sf-plugin/no-hardcoded-messages-flags + summary: messages.getMessage('flags.status.summary'), char: 's', default: 'Implemented - per plan', options: ['Implemented - per plan', 'Not Implemented', 'Rolled back - with no impact'], @@ -58,62 +58,32 @@ export default class Close extends SfCommand { }) ).Id; - const implementationSteps = await retrieveImplementationFromCase(conn, changeCaseId); - if (!flags['dry-run']) { - await this.stopImplementation(flags.status, implementationSteps, conn); - await this.closeCase(flags.status, changeCaseId, conn); + (await closeCase(flags.status, changeCaseId, conn)).map((msg) => this.log(msg)); } else { this.log('Case will not be closed because of the dryrun flag.'); } - // delete the config file, until the next release - return { case: { Id: changeCaseId, Status: flags.status } }; } +} - private async closeCase(status: string, changecaseid: string, conn: Connection): Promise { - // close the case - const closeBody = { - cases: [{ Id: changecaseid }], - }; - - const closeResult = await conn.request( - { - method: 'PATCH', - url: '/services/apexrest/change-management/v1/change-cases/close', - body: JSON.stringify(closeBody), - }, - { responseType: 'application/json' } - ); - - if (closeResult.results && closeResult.results[0].success === false) { - throw new SfError(`Stoping the implementation steps failed with ${parseErrors(closeResult)}`); - } - - this.log(`Release ${closeResult.results[0].id} set to ${status}.`); +const closeCase = async (status: string, changecaseid: string, conn: Connection): Promise => { + // close the case + const closeBody = { cases: [{ Id: changecaseid }] }; + + const closeResult = await conn.request( + { + method: 'PATCH', + url: '/services/apexrest/change-management/v2/change-cases/close', + body: JSON.stringify(closeBody), + }, + { responseType: 'application/json' } + ); + + if (closeResult.hasErrors) { + throw new SfError(`Stoping the implementation steps failed with ${JSON.stringify(closeResult)}`); } - private async stopImplementation(status: string, steps: Implementation[], conn: Connection): Promise { - // stop the implementation steps - // add the status to the implementation steps - const implementationsToStop = { - implementationSteps: steps.map((step) => ({ Id: step.Id, Status__c: status })), - }; - - const stopResult = await conn.request( - { - method: 'PATCH', - url: '/services/apexrest/change-management/v1/implementation-steps/stop', - body: JSON.stringify(implementationsToStop), - }, - { responseType: 'application/json' } - ); - - if (stopResult.results && stopResult.results[0].success === false) { - throw new SfError(`Stoping the implementation steps failed with ${parseErrors(stopResult)}`); - } - - this.log(`Successfully stopped implementation steps ${steps[0].Id}`); - } -} + return closeResult.results.map((r) => `Release ${r.id} set to ${status}.`); +}; diff --git a/src/commands/create.ts b/src/commands/create.ts index 573f938..87cd47c 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -13,10 +13,10 @@ import { Interfaces } from '@oclif/core'; import { Step, StartApiResponse, CreateCaseResponse, Implementation, CaseWithImpl, Case } from '../types.js'; import { getEnvVarFullName } from '../functions.js'; import { dryrunFlag, environmentAwareOrgFlag, locationFlag, releaseFlag } from '../flags.js'; -import { parseErrors, retrieveOrCreateReleaseId } from '../changeCaseApi.js'; +import { retrieveOrCreateReleaseId } from '../changeCaseApi.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/change-case-management', 'changecase'); +const messages = Messages.loadMessages('@salesforce/change-case-management', 'create'); const CHANGE_RECORD_TYPE_ID = env.getString(getEnvVarFullName('CHANGE_RECORD_TYPE_ID'), '012B000000009fBIAQ'); const CHANGE_TEMPLATE_RECORD_TYPE_ID = env.getString( @@ -58,8 +58,7 @@ export type CreateResponse = { record: CaseWithImpl; }; export default class Create extends SfCommand { - public static readonly summary = messages.getMessage('create.description'); - public static readonly description = messages.getMessage('create.description'); + public static readonly summary = messages.getMessage('summary'); public static readonly examples = []; @@ -67,7 +66,7 @@ export default class Create extends SfCommand { 'target-org': environmentAwareOrgFlag({ required: true }), 'template-id': Flags.salesforceId({ length: 'both', - summary: messages.getMessage('create.flags.templateid.description'), + summary: messages.getMessage('flags.template-id.summary'), char: 'i', required: true, startsWith: '500', @@ -78,7 +77,7 @@ export default class Create extends SfCommand { release: releaseFlag, location: locationFlag, 'configuration-item': Flags.string({ - summary: messages.getMessage('create.flags.configurationitem.description'), + summary: messages.getMessage('flags.configuration-item.summary'), required: true, char: 'c', env: getEnvVarFullName('CONFIGURATION_ITEM'), @@ -109,13 +108,16 @@ export default class Create extends SfCommand { } private async startImplementations(createRes: CreateCaseResponse, conn: Connection): Promise { + if (createRes.success === false) { + throw new SfError(`Creating release failed with ${JSON.stringify(createRes)}`); + } const implementationsToStart = generateImplementations(createRes.implementationSteps); // start the implementation steps const startResult = await conn.request( { method: 'PATCH', - url: '/services/apexrest/change-management/v1/implementation-steps/start', + url: '/services/apexrest/change-management/v2/implementation-steps/start', body: JSON.stringify(implementationsToStart), }, { responseType: 'application/json' } @@ -129,11 +131,7 @@ export default class Create extends SfCommand { } this.styledJSON(startResult); - throw new SfError( - `Starting release failed with ${startResult.results - .map((result) => result.errors?.map((error) => error.message?.message).join(',')) - .join(',')}` - ); + throw new SfError(`Starting release failed with ${JSON.stringify(startResult.results)}}`); } private async createCase(record: CaseWithImpl, conn: Connection): Promise { @@ -142,7 +140,7 @@ export default class Create extends SfCommand { const createResult = await conn.request( { method: 'POST', - url: conn.instanceUrl + '/services/apexrest/change-management/v1/change-cases', + url: conn.instanceUrl + '/services/apexrest/change-management/v2/change-cases', body: JSON.stringify(record), }, { responseType: 'application/json' } @@ -155,11 +153,11 @@ export default class Create extends SfCommand { return createResult; } - throw new SfError(`Creating release failed with ${parseErrors(createResult)}`); + throw new SfError(`Creating release failed with ${JSON.stringify(createResult.errors)}`); } catch (e) { const err = e as Error; const error = JSON.parse(err.message) as CreateCaseResponse; - throw new SfError(`Creating release failed with ${parseErrors(error)}`); + throw new SfError(`Creating release failed with ${JSON.stringify(error)}`); } } @@ -195,10 +193,6 @@ export default class Create extends SfCommand { record.Status = 'Approved, Scheduled'; record.SM_Risk_Level__c = 'Low'; - const startTime = new Date(); - // set the estimated end time 10 minutes in the future - const endTime = new Date(startTime.getTime() + 10 * 60000); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment const identity = await conn.identity(); return { change: record as Case, @@ -207,12 +201,12 @@ export default class Create extends SfCommand { Description__c: 'releasing the salesforce CLI', // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment OwnerId: identity.user_id, - SM_Estimated_Start_Time__c: startTime.toISOString(), - SM_Estimated_End_Time__c: endTime.toISOString(), + Planned_Start_Time__c: new Date().toISOString(), + Planned_Duration_In_Hours__c: 0.25, SM_Implementation_Steps__c: 'N/A', Configuration_Item_Path_List__c: this.flags['configuration-item'], SM_Infrastructure_Type__c: 'Off Core', - } as Implementation, + } satisfies Implementation, ], } as CaseWithImpl; } diff --git a/src/flags.ts b/src/flags.ts index 0998fe5..577e07c 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -40,7 +40,7 @@ const getOrgOrThrow = async (input?: string): Promise => { }; /** like the normal target-org, but could also derive the org from an SFDX_AUTH_URL in the env */ -export const environmentAwareOrgFlag = Flags.custom({ +export const environmentAwareOrgFlag = Flags.custom({ char: 'o', summary: `For testing, you can supply a username/alias. It will also parse the org from the environment: ${getEnvVarFullName( 'SFDX_AUTH_URL' @@ -53,28 +53,28 @@ export const environmentAwareOrgFlag = Flags.custom({ }); export const dryrunFlag = Flags.boolean({ - description: messages.getMessage('command.flags.dryrun.description'), + description: messages.getMessage('flags.dry-run.summary'), env: getEnvVarFullName('DRYRUN'), default: false, aliases: ['dryrun'], }); export const releaseFlag = Flags.string({ - description: messages.getMessage('create.flags.release.description'), + description: messages.getMessage('flags.release.summary'), char: 'r', env: getEnvVarFullName('SCHEDULE_BUILD'), dependsOn: ['location'], }); export const locationFlag = Flags.url({ - description: messages.getMessage('create.flags.location.description'), + description: messages.getMessage('flags.location.summary'), char: 'l', env: getEnvVarFullName('REPO'), dependsOn: ['release'], }); export const changeCaseIdFlag = Flags.salesforceId({ - description: messages.getMessage('command.flags.changecaseid.description'), + description: messages.getMessage('flags.changecaseid.summary'), char: 'i', startsWith: '500', env: getEnvVarFullName('ID'), diff --git a/src/types.ts b/src/types.ts index cb03273..92c48de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,12 +9,11 @@ export type Implementation = { Id?: string; Description__c: string; OwnerId: string; - SM_Estimated_Start_Time__c: string; - SM_Estimated_End_Time__c: string; Configuration_Item_Path_List__c: string; SM_Implementation_Steps__c: string; - SM_Change_Implementation_ID__c: string; SM_Infrastructure_Type__c: string; + Planned_Start_Time__c: string; + Planned_Duration_In_Hours__c: number; }; export type CaseWithImpl = { @@ -61,47 +60,81 @@ export type Step = { Id: string; }; -export type ChangeCaseApiResponse = { - results: [ +type SuccessResult = { + id: string; + success: true; +}; + +type CloseFailureResult = { + success: false; + errors: [ { - id: string; - success: boolean; - message?: string; + message: string; + errorCode: string; } ]; - errors?: [{ message: string }]; }; +// https://confluence.internal.salesforce.com/display/PETOOLS/Change+API+V2 +export type ChangeCaseCloseApiResponse = + | { hasErrors: true; results: [CloseFailureResult | SuccessResult] } + | { hasErrors: false; results: [SuccessResult] }; + export type CreateCaseResponse = { id: string; - implementationSteps: string[]; - success: boolean; - errors?: [{ message: string }]; - results?: [{ message: string }]; -}; +} & ( + | { success: true; implementationSteps: string[] } + | { success: false; errors: [{ message: string; errorCode: string }] } +); -export type StartApiResponse = { - hasErrors: boolean; - results: [ +export type StartFailureResult = { + success: false; + id: string; + errors?: [ { - success: boolean; - id: string; - errors?: [ - { - message?: { - blockedLock: { + message?: { + message?: string; + blockedLock: { + configurationItem: { + id: string; + name: string; + path: string; + }; + title: string; + }; + blockingLocks: [ + { + blockingLock: { configurationItem: { id: string; - name: string; path: string; }; - title: string; + id: string; + lockOwner: { + email: string; + id: string; + name: string; + }; + lockType: { + id: string; + name: string; + }; }; - message?: string; - }; - errorCode: string; - } - ]; + } + ]; + errorCode: string; + fields?: string[]; + }; } ]; }; +export type StartApiResponse = + | { hasErrors: false; results: SuccessResult[] } + | { + hasErrors: true; + results: [StartFailureResult | SuccessResult]; + }; + +export const isFailure = ( + result: StartFailureResult | CloseFailureResult | SuccessResult +): result is StartFailureResult | CloseFailureResult => result.success === false;