diff --git a/packages/nodes-base/nodes/Linear/GenericFunctions.ts b/packages/nodes-base/nodes/Linear/GenericFunctions.ts index 3de349d3fb633..20c7fc60228c3 100644 --- a/packages/nodes-base/nodes/Linear/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Linear/GenericFunctions.ts @@ -8,13 +8,21 @@ import { } from 'n8n-core'; import { + ICredentialDataDecryptedObject, + ICredentialTestFunctions, IDataObject, IHookFunctions, IWebhookFunctions, + JsonObject, NodeApiError, - NodeOperationError, } from 'n8n-workflow'; +import get = require('lodash.get'); + +import { + query, +} from './Queries'; + export async function linearApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, body: any = {}, option: IDataObject = {}): Promise { // tslint:disable-line:no-any const credentials = await this.getCredentials('linearApi') as IDataObject; @@ -32,14 +40,53 @@ export async function linearApiRequest(this: IExecuteFunctions | IWebhookFunctio }; options = Object.assign({}, options, option); try { - return await this.helpers.request!(options); } catch (error) { - throw new NodeApiError(this.getNode(), error); + throw new NodeApiError(this.getNode(), error as JsonObject); } } export function capitalizeFirstLetter(data: string) { return data.charAt(0).toUpperCase() + data.slice(1); +} + +export async function linearApiRequestAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, body: any = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + body.variables.first = 50; + body.variables.after = null; + + do { + responseData = await linearApiRequest.call(this, body); + returnData.push.apply(returnData, get(responseData, `${propertyName}.nodes`)); + body.variables.after = get(responseData, `${propertyName}.pageInfo.endCursor`); + } while ( + get(responseData, `${propertyName}.pageInfo.hasNextPage`) + ); + return returnData; +} + +export async function validateCrendetials(this: ICredentialTestFunctions, decryptedCredentials: ICredentialDataDecryptedObject): Promise { // tslint:disable-line:no-any + const credentials = decryptedCredentials; + + const options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + Authorization: credentials.apiKey, + }, + method: 'POST', + body: { + query: query.getIssues(), + variables: { + first: 1, + }, + }, + uri: 'https://api.linear.app/graphql', + json: true, + }; + + return this.helpers.request!(options); } \ No newline at end of file diff --git a/packages/nodes-base/nodes/Linear/IssueDescription.ts b/packages/nodes-base/nodes/Linear/IssueDescription.ts new file mode 100644 index 0000000000000..b71f7e0d068cf --- /dev/null +++ b/packages/nodes-base/nodes/Linear/IssueDescription.ts @@ -0,0 +1,220 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const issueOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'issue', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create an issue', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an issue', + }, + { + name: 'Get', + value: 'get', + description: 'Get an issue', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all issues', + }, + ], + default: 'create', + }, +]; + +export const issueFields: INodeProperties[] = [ + + /* -------------------------------------------------------------------------- */ + /* issue:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Team Name/ID', + name: 'teamId', + type: 'options', + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'create', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getTeams', + }, + default: '', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Assignee Name/ID', + name: 'assigneeId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + default: '', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + }, + { + displayName: 'Priority Name/ID', + name: 'priorityId', + type: 'options', + options: [ + { + name: 'Urgent', + value: 1, + }, + { + name: 'High', + value: 2, + }, + { + name: 'Medium', + value: 3, + }, + { + name: 'Low', + value: 3, + }, + { + name: 'No Priority', + value: 0, + }, + ], + default: 0, + }, + { + displayName: 'State Name/ID', + name: 'stateId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getStates', + }, + default: '', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* user:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Issue ID', + name: 'issueId', + type: 'string', + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'get', + 'delete', + ], + }, + }, + default: '', + }, + /* -------------------------------------------------------------------------- */ + /* issue:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + description: 'Max number of results to return', + }, +]; diff --git a/packages/nodes-base/nodes/Linear/Linear.node.ts b/packages/nodes-base/nodes/Linear/Linear.node.ts new file mode 100644 index 0000000000000..a346e670390e9 --- /dev/null +++ b/packages/nodes-base/nodes/Linear/Linear.node.ts @@ -0,0 +1,243 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + ICredentialDataDecryptedObject, + ICredentialsDecrypted, + ICredentialTestFunctions, + IDataObject, + ILoadOptionsFunctions, + INodeCredentialTestResult, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + JsonObject, +} from 'n8n-workflow'; + +import { + linearApiRequest, + linearApiRequestAllItems, + validateCrendetials, +} from './GenericFunctions'; + +import { + issueFields, + issueOperations, +} from './IssueDescription'; + +import { + query, +} from './Queries'; +interface IGraphqlBody { + query: string; + variables: IDataObject; +} +export class Linear implements INodeType { + description: INodeTypeDescription = { + displayName: 'Linear', + name: 'linear', + icon: 'file:linear.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Linear API', + defaults: { + name: 'Linear', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'linearApi', + required: true, + testedBy: 'linearApiTest', + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Issue', + value: 'issue', + }, + ], + default: 'issue', + }, + ...issueOperations, + ...issueFields, + ], + }; + + methods = { + credentialTest: { + async linearApiTest(this: ICredentialTestFunctions, credential: ICredentialsDecrypted): Promise { + try { + await validateCrendetials.call(this, credential.data as ICredentialDataDecryptedObject); + } catch (error) { + const { error: err } = error as JsonObject; + const errors = (err as IDataObject).errors as [{ extensions: { code: string } }]; + const authenticationError = Boolean(errors.filter(e => e.extensions.code === 'AUTHENTICATION_ERROR').length); + if (authenticationError) { + return { + status: 'Error', + message: 'The security token included in the request is invalid', + }; + } + } + + return { + status: 'OK', + message: 'Connection successful!', + }; + }, + }, + loadOptions: { + async getTeams(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const body = { + query: query.getTeams(), + variables: { + $first: 10, + }, + }; + const teams = await linearApiRequestAllItems.call(this, 'data.teams', body); + + for (const team of teams) { + returnData.push({ + name: team.name, + value: team.id, + }); + } + return returnData; + }, + async getUsers(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const body = { + query: query.getUsers(), + variables: { + $first: 10, + }, + }; + const users = await linearApiRequestAllItems.call(this, 'data.users', body); + + for (const user of users) { + returnData.push({ + name: user.name, + value: user.id, + }); + } + return returnData; + }, + async getStates(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const body = { + query: query.getStates(), + variables: { + $first: 10, + }, + }; + const states = await linearApiRequestAllItems.call(this, 'data.workflowStates', body); + + for (const state of states) { + returnData.push({ + name: state.name, + value: state.id, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length as unknown as number; + let responseData; + const qs: IDataObject = {}; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + try { + if (resource === 'issue') { + if (operation === 'create') { + const teamId = this.getNodeParameter('teamId', i) as string; + const title = this.getNodeParameter('title', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const body: IGraphqlBody = { + query: query.createIssue(), + variables: { + teamId, + title, + ...additionalFields, + }, + }; + + responseData = await linearApiRequest.call(this, body); + responseData = responseData.data.issueCreate?.issue; + } + if (operation === 'delete') { + const issueId = this.getNodeParameter('issueId', i) as string; + const body: IGraphqlBody = { + query: query.deleteIssue(), + variables: { + issueId, + }, + }; + + responseData = await linearApiRequest.call(this, body); + responseData = responseData.data.issueDelete; + } + if (operation === 'get') { + const issueId = this.getNodeParameter('issueId', i) as string; + const body: IGraphqlBody = { + query: query.getIssue(), + variables: { + issueId, + }, + }; + + responseData = await linearApiRequest.call(this, body); + responseData = responseData.data.issues?.nodes[0]; + } + if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const body: IGraphqlBody = { + query: query.getIssues(), + variables: { + first: 50, + }, + }; + if (returnAll) { + responseData = await linearApiRequestAllItems.call(this, 'data.issues', body); + } else { + const limit = this.getNodeParameter('limit', 0) as number; + body.variables.first = limit; + responseData = await linearApiRequest.call(this, body); + responseData = responseData.data.issues.nodes; + } + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + error: (error as JsonObject).message, + }); + continue; + } + throw error; + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts b/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts index 0997c7072de83..d0dbb4d620b13 100644 --- a/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts +++ b/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts @@ -35,6 +35,7 @@ export class LinearTrigger implements INodeType { { name: 'linearApi', required: true, + testedBy: 'linearApiTest', }, ], webhooks: [ diff --git a/packages/nodes-base/nodes/Linear/Queries.ts b/packages/nodes-base/nodes/Linear/Queries.ts new file mode 100644 index 0000000000000..c20cec0d27ddb --- /dev/null +++ b/packages/nodes-base/nodes/Linear/Queries.ts @@ -0,0 +1,159 @@ +export const query = { + getUsers() { + return `query Users ($first: Int){ + users (first: $first){ + nodes { + id + name + }, + pageInfo { + hasNextPage + endCursor + } + }}`; + }, + getTeams() { + return `query Teams ($first: Int, $after: String){ + teams (first: $first, after: $after){ + nodes { + id + name + } + pageInfo { + hasNextPage + endCursor + } + }}`; + }, + getStates() { + return `query States ($first: Int){ + workflowStates (first: $first){ + nodes { + id + name + }, + pageInfo { + hasNextPage + endCursor + } + }}`; + }, + createIssue() { + return `mutation IssueCreate ( + $title: String!, + $teamId: String!, + $description: String + $assigneeId: String + $priorityId: Int + $stateId: String){ + issueCreate( + input: { + title: $title + description: $description + teamId: $teamId + assigneeId: $assigneeId + priority: $priorityId + stateId: $stateId + } + ) { + success + issue { + id, + title, + priority + archivedAt + assignee { + id + displayName + } + state { + id + name + } + createdAt + creator { + id + displayName + } + description + dueDate + cycle { + id + name + } + } + } + }`; + }, + deleteIssue() { + return `mutation IssueDelete ($issueId: String!) { + issueDelete(id: $issueId) { + success + } + }`; + }, + getIssue() { + return `query Issue ($issueId: ID){ + issues(filter: { + id: { eq: $issueId } + }) { + nodes { + id, + title, + priority + archivedAt + assignee { + id + displayName + } + state { + id + name + } + createdAt + creator { + id + displayName + } + description + dueDate + cycle { + id + name + } + } + } + }`; + }, + getIssues() { + return `query Issue ($first: Int){ + issues (first: $first){ + nodes { + id, + title, + priority + archivedAt + assignee { + id + displayName + } + state { + id + name + } + createdAt + creator { + id + displayName + } + description + dueDate + cycle { + id + name + } + } + } + }`; + }, +}; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e90250aa1a2ad..8f31bd9ab768d 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -495,6 +495,7 @@ "dist/nodes/Lemlist/Lemlist.node.js", "dist/nodes/Lemlist/LemlistTrigger.node.js", "dist/nodes/Line/Line.node.js", + "dist/nodes/Linear/Linear.node.js", "dist/nodes/Linear/LinearTrigger.node.js", "dist/nodes/LingvaNex/LingvaNex.node.js", "dist/nodes/LinkedIn/LinkedIn.node.js",