diff --git a/packages/nodes-base/credentials/CircleCiApi.credentials.ts b/packages/nodes-base/credentials/CircleCiApi.credentials.ts new file mode 100644 index 0000000000..88ecdc4fe1 --- /dev/null +++ b/packages/nodes-base/credentials/CircleCiApi.credentials.ts @@ -0,0 +1,17 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class CircleCiApi implements ICredentialType { + name = 'circleCiApi'; + displayName = 'CircleCI API'; + properties = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts b/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts new file mode 100644 index 0000000000..00eded6366 --- /dev/null +++ b/packages/nodes-base/nodes/CircleCi/CircleCi.node.ts @@ -0,0 +1,137 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, +} from 'n8n-workflow'; + +import { + pipelineFields, + pipelineOperations, +} from './PipelineDescription'; + +import { + circleciApiRequest, + circleciApiRequestAllItems, +} from './GenericFunctions'; + +export class CircleCi implements INodeType { + description: INodeTypeDescription = { + displayName: 'CircleCI', + name: 'circleCi', + icon: 'file:circleCi.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume CircleCI API', + defaults: { + name: 'CircleCI', + color: '#04AA51', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'circleCiApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: ' Pipeline', + value: 'pipeline', + }, + ], + default: 'pipeline', + description: 'Resource to consume.', + }, + ...pipelineOperations, + ...pipelineFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length as unknown as number; + const qs: IDataObject = {}; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < length; i++) { + if (resource === 'pipeline') { + if (operation === 'get') { + let slug = this.getNodeParameter('projectSlug', i) as string; + const pipelineNumber = this.getNodeParameter('pipelineNumber', i) as number; + + slug = slug.replace(new RegExp(/\//g), '%2F'); + + const endpoint = `/project/${slug}/pipeline/${pipelineNumber}`; + + responseData = await circleciApiRequest.call(this, 'GET', endpoint, {}, qs); + } + if (operation === 'getAll') { + const filters = this.getNodeParameter('filters', i) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + let slug = this.getNodeParameter('projectSlug', i) as string; + + slug = slug.replace(new RegExp(/\//g), '%2F'); + + if (filters.branch) { + qs.branch = filters.branch; + } + + const endpoint = `/project/${slug}/pipeline`; + + if (returnAll === true) { + responseData = await circleciApiRequestAllItems.call(this, 'items', 'GET', endpoint, {}, qs); + + } else { + qs.limit = this.getNodeParameter('limit', i) as number; + responseData = await circleciApiRequest.call(this, 'GET', endpoint, {}, qs); + responseData = responseData.items; + responseData = responseData.splice(0, qs.limit); + } + } + + if (operation === 'trigger') { + let slug = this.getNodeParameter('projectSlug', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + slug = slug.replace(new RegExp(/\//g), '%2F'); + + const endpoint = `/project/${slug}/pipeline`; + + const body: IDataObject = {}; + + if (additionalFields.branch) { + body.branch = additionalFields.branch as string; + } + + if (additionalFields.tag) { + body.tag = additionalFields.tag as string; + } + + responseData = await circleciApiRequest.call(this, 'POST', endpoint, body, qs); + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/CircleCi/GenericFunctions.ts b/packages/nodes-base/nodes/CircleCi/GenericFunctions.ts new file mode 100644 index 0000000000..fb30950a1a --- /dev/null +++ b/packages/nodes-base/nodes/CircleCi/GenericFunctions.ts @@ -0,0 +1,67 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function circleciApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('circleCiApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + let options: OptionsWithUri = { + headers: { + 'Circle-Token': credentials.apiKey, + 'Accept': 'application/json', + }, + method, + qs, + body, + uri: uri ||`https://circleci.com/api/v2${resource}`, + json: true + }; + options = Object.assign({}, options, option); + if (Object.keys(options.body).length === 0) { + delete options.body; + } + try { + return await this.helpers.request!(options); + } catch (err) { + if (err.response && err.response.body && err.response.body.message) { + // Try to return the error prettier + throw new Error(`CircleCI error response [${err.statusCode}]: ${err.response.body.message}`); + } + + // If that data does not exist for some reason return the actual error + throw err; } +} + +/** + * Make an API request to paginated CircleCI endpoint + * and return all results + */ +export async function circleciApiRequestAllItems(this: IHookFunctions | IExecuteFunctions| ILoadOptionsFunctions, propertyName: string, method: string, resource: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + do { + responseData = await circleciApiRequest.call(this, method, resource, body, query); + returnData.push.apply(returnData, responseData[propertyName]); + query['page-token'] = responseData.next_page_token; + } while ( + responseData.next_page_token !== undefined && + responseData.next_page_token !== null + ); + return returnData; +} diff --git a/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts new file mode 100644 index 0000000000..66b99f3eb3 --- /dev/null +++ b/packages/nodes-base/nodes/CircleCi/PipelineDescription.ts @@ -0,0 +1,222 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const pipelineOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'pipeline', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a pipeline', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all pipelines', + }, + { + name: 'Trigger', + value: 'trigger', + description: 'Trigger a pipeline', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const pipelineFields = [ + +/* -------------------------------------------------------------------------- */ +/* pipeline:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'string', + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + description: 'Project slug in the form vcs-slug/org-name/repo-name', + }, + { + displayName: 'Pipeline Number', + name: 'pipelineNumber', + type: 'number', + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: 0, + description: 'The number of the pipeline', + }, +/* -------------------------------------------------------------------------- */ +/* pipeline:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'string', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + description: 'Project slug in the form vcs-slug/org-name/repo-name', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'pipeline', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'pipeline', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + description: 'The name of a vcs branch.', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* pipeline:trigger */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'string', + displayOptions: { + show: { + operation: [ + 'trigger', + ], + resource: [ + 'pipeline', + ], + }, + }, + default: '', + description: 'Project slug in the form vcs-slug/org-name/repo-name', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'pipeline', + ], + operation: [ + 'trigger', + ], + }, + }, + options: [ + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + description: `The branch where the pipeline ran.
+ The HEAD commit on this branch was used for the pipeline.
+ Note that branch and tag are mutually exclusive.`, + }, + { + displayName: 'Tag', + name: 'tag', + type: 'string', + default: '', + description: `The tag used by the pipeline.
+ The commit that this tag points to was used for the pipeline.
+ Note that branch and tag are mutually exclusive`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/CircleCi/circleCi.png b/packages/nodes-base/nodes/CircleCi/circleCi.png new file mode 100644 index 0000000000..1708a6a3dd Binary files /dev/null and b/packages/nodes-base/nodes/CircleCi/circleCi.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index c19d8e2349..a04293da42 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -37,6 +37,7 @@ "dist/credentials/BannerbearApi.credentials.js", "dist/credentials/BitbucketApi.credentials.js", "dist/credentials/BitlyApi.credentials.js", + "dist/credentials/CircleCiApi.credentials.js", "dist/credentials/ChargebeeApi.credentials.js", "dist/credentials/ClearbitApi.credentials.js", "dist/credentials/ClickUpApi.credentials.js", @@ -165,6 +166,7 @@ "dist/nodes/Bitbucket/BitbucketTrigger.node.js", "dist/nodes/Bitly/Bitly.node.js", "dist/nodes/Calendly/CalendlyTrigger.node.js", + "dist/nodes/CircleCi/CircleCi.node.js", "dist/nodes/Chargebee/Chargebee.node.js", "dist/nodes/Chargebee/ChargebeeTrigger.node.js", "dist/nodes/Clearbit/Clearbit.node.js",