diff --git a/packages/nodes-base/credentials/LinearApi.credentials.ts b/packages/nodes-base/credentials/LinearApi.credentials.ts new file mode 100644 index 0000000000..649012ad5b --- /dev/null +++ b/packages/nodes-base/credentials/LinearApi.credentials.ts @@ -0,0 +1,18 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class LinearApi implements ICredentialType { + name = 'linearApi'; + displayName = 'Linear API'; + documentationUrl = 'linear'; + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Linear/GenericFunctions.ts b/packages/nodes-base/nodes/Linear/GenericFunctions.ts new file mode 100644 index 0000000000..3de349d3fb --- /dev/null +++ b/packages/nodes-base/nodes/Linear/GenericFunctions.ts @@ -0,0 +1,45 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + IHookFunctions, + IWebhookFunctions, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +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; + + const endpoint = 'https://api.linear.app/graphql'; + + let options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + Authorization: credentials.apiKey, + }, + method: 'POST', + body, + uri: endpoint, + json: true, + }; + options = Object.assign({}, options, option); + try { + + return await this.helpers.request!(options); + + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export function capitalizeFirstLetter(data: string) { + return data.charAt(0).toUpperCase() + data.slice(1); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts b/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts new file mode 100644 index 0000000000..0997c7072d --- /dev/null +++ b/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts @@ -0,0 +1,242 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + ILoadOptionsFunctions, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + capitalizeFirstLetter, + linearApiRequest, +} from './GenericFunctions'; + +export class LinearTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Linear Trigger', + name: 'linearTrigger', + icon: 'file:linear.svg', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["triggerOn"]}}', + description: 'Starts the workflow when Linear events occur', + defaults: { + name: 'Linear Trigger', + color: '#D9DCF8', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'linearApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Team Name or ID', + name: 'teamId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTeams', + }, + default: '', + }, + { + displayName: 'Listen to Resources', + name: 'resources', + type: 'multiOptions', + options: [ + { + name: 'Comment Reaction', + value: 'reaction', + }, + { + name: 'Cycle', + value: 'cycle', + }, + /* It's still on Alpha stage + { + name: 'Issue Attachment', + value: 'attachment', + },*/ + { + name: 'Issue', + value: 'issue', + }, + { + name: 'Issue Comment', + value: 'comment', + }, + { + name: 'Issue Label', + value: 'issueLabel', + }, + { + name: 'Project', + value: 'project', + }, + + ], + default: [], + required: true, + }, + ], + }; + + methods = { + loadOptions: { + async getTeams(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const body = { + query: + `query Teams { + teams { + nodes { + id + name + } + } + }`, + }; + const { data: { teams: { nodes } } } = await linearApiRequest.call(this, body); + + for (const node of nodes) { + returnData.push({ + name: node.name, + value: node.id, + }); + } + return returnData; + }, + }, + }; + + //@ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + const teamId = this.getNodeParameter('teamId') as string; + const body = { + query: + `query { + webhooks { + nodes { + id + url + enabled + team { + id + name + } + } + } + }`, + }; + // Check all the webhooks which exist already if it is identical to the + // one that is supposed to get created. + const { data: { webhooks: { nodes } } } = await linearApiRequest.call(this, body); + + for (const node of nodes) { + if (node.url === webhookUrl && + node.team.id === teamId && + node.enabled === true) { + webhookData.webhookId = node.id as string; + return true; + } + } + return false; + }, + async create(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const teamId = this.getNodeParameter('teamId') as string; + const resources = this.getNodeParameter('resources') as string[]; + const body = { + query: ` + mutation webhookCreate($url: String!, $teamId: String!, $resources: [String!]!) { + webhookCreate( + input: { + url: $url + teamId: $teamId + resourceTypes: $resources + } + ) { + success + webhook { + id + enabled + } + } + }`, + variables: { + url: webhookUrl, + teamId, + resources: resources.map(capitalizeFirstLetter), + }, + }; + + const { data: { webhookCreate: { success, webhook: { id } } } } = await linearApiRequest.call(this, body); + + if (!success) { + return false; + } + webhookData.webhookId = id as string; + + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + const body = { + query: ` + mutation webhookDelete($id: String!){ + webhookDelete( + id: $id + ) { + success + } + }`, + variables: { + id: webhookData.webhookId, + }, + }; + + try { + await linearApiRequest.call(this, body); + } catch (error) { + return false; + } + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; + } + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const bodyData = this.getBodyData(); + return { + workflowData: [ + this.helpers.returnJsonArray(bodyData), + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Linear/linear.svg b/packages/nodes-base/nodes/Linear/linear.svg new file mode 100644 index 0000000000..48f134ba9e --- /dev/null +++ b/packages/nodes-base/nodes/Linear/linear.svg @@ -0,0 +1 @@ + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index b298bff330..76b38eb490 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -158,6 +158,7 @@ "dist/credentials/KeapOAuth2Api.credentials.js", "dist/credentials/KitemakerApi.credentials.js", "dist/credentials/LemlistApi.credentials.js", + "dist/credentials/LinearApi.credentials.js", "dist/credentials/LineNotifyOAuth2Api.credentials.js", "dist/credentials/LingvaNexApi.credentials.js", "dist/credentials/LinkedInOAuth2Api.credentials.js", @@ -487,6 +488,7 @@ "dist/nodes/Lemlist/Lemlist.node.js", "dist/nodes/Lemlist/LemlistTrigger.node.js", "dist/nodes/Line/Line.node.js", + "dist/nodes/Linear/LinearTrigger.node.js", "dist/nodes/LingvaNex/LingvaNex.node.js", "dist/nodes/LinkedIn/LinkedIn.node.js", "dist/nodes/LocalFileTrigger/LocalFileTrigger.node.js",