feat(Linear Node): Add Linear Node (#2971)

*  Linear node

*  Improvements
This commit is contained in:
Ricardo Espinoza 2022-03-20 05:11:06 -04:00 committed by GitHub
parent 1a7f0a4246
commit 8d04474e30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 864 additions and 4 deletions

View file

@ -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<any> { // tslint:disable-line:no-any
const credentials = await this.getCredentials('linearApi') as IDataObject;
@ -32,14 +40,60 @@ 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<any> { // 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<any> { // 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);
}
//@ts-ignore
export const sort = (a, b) => {
if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) { return -1; }
if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) { return 1; }
return 0;
};

View file

@ -0,0 +1,338 @@
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',
},
{
name: 'Update',
value: 'update',
description: 'Update an issue',
},
],
default: 'create',
},
];
export const issueFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* issue:create */
/* -------------------------------------------------------------------------- */
{
displayName: 'Team Name/ID',
name: 'teamId',
type: 'options',
required: true,
displayOptions: {
show: {
resource: [
'issue',
],
operation: [
'create',
],
},
},
typeOptions: {
loadOptionsMethod: 'getTeams',
},
default: '',
},
{
displayName: 'Title',
name: 'title',
type: 'string',
required: true,
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: '',
},
],
},
/* -------------------------------------------------------------------------- */
/* issue:delete */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue ID',
name: 'issueId',
type: 'string',
required: true,
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,
maxValue: 300,
},
displayOptions: {
show: {
resource: [
'issue',
],
operation: [
'getAll',
],
returnAll: [
false,
],
},
},
description: 'Max number of results to return',
},
/* -------------------------------------------------------------------------- */
/* issue:update */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue ID',
name: 'issueId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'issue',
],
operation: [
'update',
],
},
},
default: '',
},
{
displayName: 'Update Fields',
name: 'updateFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'issue',
],
operation: [
'update',
],
},
},
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: '',
},
{
displayName: 'Team Name/ID',
name: 'teamId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getTeams',
},
default: '',
},
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
},
],
},
];

View file

@ -0,0 +1,258 @@
import {
IExecuteFunctions,
} from 'n8n-core';
import {
ICredentialDataDecryptedObject,
ICredentialsDecrypted,
ICredentialTestFunctions,
IDataObject,
ILoadOptionsFunctions,
INodeCredentialTestResult,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
JsonObject,
} from 'n8n-workflow';
import {
linearApiRequest,
linearApiRequestAllItems,
sort,
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<INodeCredentialTestResult> {
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<INodePropertyOptions[]> {
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<INodePropertyOptions[]> {
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<INodePropertyOptions[]> {
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.sort(sort);
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
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 (operation === 'update') {
const issueId = this.getNodeParameter('issueId', i) as string;
const updateFields = this.getNodeParameter('updateFields', i) as IDataObject;
const body: IGraphqlBody = {
query: query.updateIssue(),
variables: {
issueId,
...updateFields,
},
};
responseData = await linearApiRequest.call(this, body);
responseData = responseData?.data?.issueUpdate?.issue;
}
}
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)];
}
}

View file

@ -35,6 +35,7 @@ export class LinearTrigger implements INodeType {
{
name: 'linearApi',
required: true,
testedBy: 'linearApiTest',
},
],
webhooks: [

View file

@ -0,0 +1,208 @@
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
}
}
}
}`;
},
updateIssue() {
return `mutation IssueUpdate (
$issueId: String!,
$title: String,
$teamId: String,
$description: String,
$assigneeId: String,
$priorityId: Int,
$stateId: String){
issueUpdate(
id: $issueId,
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
}
}
}
}`;
},
};

View file

@ -499,6 +499,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",