first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,78 @@
import type { INodeProperties } from 'n8n-workflow';
export const commentOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['comment'],
},
},
options: [
{
name: 'Add Comment',
value: 'addComment',
description: 'Add a comment to an issue',
action: 'Add a comment to an issue',
},
],
default: 'addComment',
},
];
export const commentFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* comment:addComment */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue ID',
name: 'issueId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['comment'],
operation: ['addComment'],
},
},
default: '',
},
{
displayName: 'Comment',
name: 'comment',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['comment'],
operation: ['addComment'],
},
},
default: '',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['comment'],
operation: ['addComment'],
},
},
options: [
{
displayName: 'Parent Comment ID',
name: 'parentId',
type: 'string',
description: 'ID of the parent comment if this is a reply',
default: '',
},
],
},
];
@@ -0,0 +1,134 @@
import get from 'lodash/get';
import type {
ICredentialDataDecryptedObject,
ICredentialTestFunctions,
IDataObject,
IExecuteFunctions,
ILoadOptionsFunctions,
IHookFunctions,
IWebhookFunctions,
IHttpRequestOptions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { query } from './Queries';
export async function linearApiRequest(
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
body: any = {},
option: IDataObject = {},
): Promise<any> {
const endpoint = 'https://api.linear.app/graphql';
const authenticationMethod = this.getNodeParameter('authentication', 0, 'apiToken') as string;
let options: IHttpRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body,
url: endpoint,
json: true,
};
options = Object.assign({}, options, option);
try {
const response = await this.helpers.httpRequestWithAuthentication.call(
this,
authenticationMethod === 'apiToken' ? 'linearApi' : 'linearOAuth2Api',
options,
);
if (response?.errors) {
throw new NodeApiError(this.getNode(), response.errors, {
message: response.errors[0].message ?? 'Unknown API Error',
});
}
return response;
} catch (error) {
throw new NodeApiError(
this.getNode(),
{},
{
message:
error.errorResponse?.[0]?.message ||
error.context.data.errors[0]?.message ||
'Unknown API error',
description:
error.errorResponse?.[0]?.extensions?.userPresentableMessage ||
error.context.data.errors[0]?.extensions?.userPresentableMessage,
},
);
}
}
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 = {},
limit?: number,
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
body.variables.first = limit && limit < 50 ? limit : 50;
body.variables.after = null;
const propertyPath = propertyName.split('.');
const nodesPath = [...propertyPath, 'nodes'];
const endCursorPath = [...propertyPath, 'pageInfo', 'endCursor'];
const hasNextPagePath = [...propertyPath, 'pageInfo', 'hasNextPage'];
do {
responseData = await linearApiRequest.call(this, body);
const nodes = get(responseData, nodesPath) as IDataObject[];
returnData.push(...nodes);
body.variables.after = get(responseData, endCursorPath);
if (limit && returnData.length >= limit) {
return returnData;
}
} while (get(responseData, hasNextPagePath));
return returnData;
}
export async function validateCredentials(
this: ICredentialTestFunctions,
decryptedCredentials: ICredentialDataDecryptedObject,
): Promise<any> {
const credentials = decryptedCredentials;
const options: IHttpRequestOptions = {
headers: {
'Content-Type': 'application/json',
Authorization: credentials.apiKey,
},
method: 'POST',
body: {
query: query.getIssues(),
variables: {
first: 1,
},
},
url: 'https://api.linear.app/graphql',
json: true,
};
return await 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;
};
@@ -0,0 +1,423 @@
import type { INodeProperties } from 'n8n-workflow';
export const issueOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['issue'],
},
},
options: [
{
name: 'Add Link',
value: 'addLink',
description: 'Add a link to an issue',
action: 'Add a link to an issue',
},
{
name: 'Create',
value: 'create',
description: 'Create an issue',
action: 'Create an issue',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete an issue',
action: 'Delete an issue',
},
{
name: 'Get',
value: 'get',
description: 'Get an issue',
action: 'Get an issue',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many issues',
action: 'Get many issues',
},
{
name: 'Update',
value: 'update',
description: 'Update an issue',
action: 'Update an issue',
},
],
default: 'create',
},
];
export const issueFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* issue:create */
/* -------------------------------------------------------------------------- */
{
displayName: 'Team Name or ID',
name: 'teamId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
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 or ID',
name: 'assigneeId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getUsers',
},
default: '',
},
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
},
{
displayName: 'Priority',
name: 'priorityId',
type: 'options',
options: [
{
name: 'Urgent',
value: 1,
},
{
name: 'High',
value: 2,
},
{
name: 'Normal',
value: 3,
},
{
name: 'Low',
value: 4,
},
{
name: 'No Priority',
value: 0,
},
],
default: 0,
},
{
displayName: 'State Name or ID',
name: 'stateId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getStates',
},
default: '',
},
],
},
/* -------------------------------------------------------------------------- */
/* issue:delete */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue ID',
name: 'issueId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issue'],
operation: ['addLink', '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',
},
/* -------------------------------------------------------------------------- */
/* 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'],
'@version': [1],
},
},
options: [
{
displayName: 'Assignee Name or ID',
name: 'assigneeId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getUsers',
},
default: '',
},
{
displayName: 'Description',
name: 'description',
type: 'string',
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 or ID',
name: 'stateId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getStates',
},
default: '',
},
{
displayName: 'Team Name or ID',
name: 'teamId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getTeams',
},
default: '',
},
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
},
],
},
{
displayName: 'Update Fields',
name: 'updateFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['issue'],
operation: ['update'],
},
hide: {
'@version': [1],
},
},
options: [
{
displayName: 'Assignee Name or ID',
name: 'assigneeId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getUsers',
},
default: '',
},
{
displayName: 'Description',
name: 'description',
type: 'string',
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: 4,
},
{
name: 'No Priority',
value: 0,
},
],
default: 0,
},
{
displayName: 'State Name or ID',
name: 'stateId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getStates',
},
default: '',
},
{
displayName: 'Team Name or ID',
name: 'teamId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getTeams',
},
default: '',
},
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
},
],
},
/* -------------------------------------------------------------------------- */
/* issue:addLink */
/* -------------------------------------------------------------------------- */
{
displayName: 'Link',
name: 'link',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issue'],
operation: ['addLink'],
},
},
default: '',
},
];
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.linear",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/linear/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.linear/"
}
]
}
}
@@ -0,0 +1,354 @@
import {
type IExecuteFunctions,
type ICredentialDataDecryptedObject,
type ICredentialsDecrypted,
type ICredentialTestFunctions,
type IDataObject,
type ILoadOptionsFunctions,
type INodeCredentialTestResult,
type INodeExecutionData,
type INodePropertyOptions,
type INodeType,
type INodeTypeDescription,
type JsonObject,
NodeConnectionTypes,
} from 'n8n-workflow';
import { commentFields, commentOperations } from './CommentDescription';
import {
linearApiRequest,
linearApiRequestAllItems,
sort,
validateCredentials,
} 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, 1.1],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Linear API',
defaults: {
name: 'Linear',
},
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'linearApi',
required: true,
testedBy: 'linearApiTest',
displayOptions: {
show: {
authentication: ['apiToken'],
},
},
},
{
name: 'linearOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'API Token',
value: 'apiToken',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
],
default: 'apiToken',
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Comment',
value: 'comment',
},
{
name: 'Issue',
value: 'issue',
},
],
default: 'issue',
},
...commentOperations,
...commentFields,
...issueOperations,
...issueFields,
],
};
methods = {
credentialTest: {
async linearApiTest(
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
try {
await validateCredentials.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[]> {
let teamId = this.getNodeParameter('teamId', null) as string;
// Handle Updates
if (!teamId) {
const updateFields = this.getNodeParameter('updateFields', null) as IDataObject;
// If not updating the team look up the current team
if (!updateFields.teamId) {
const issueId = this.getNodeParameter('issueId');
const body = {
query: query.getIssueTeam(),
variables: {
issueId,
},
};
const responseData = await linearApiRequest.call(this, body);
teamId = responseData?.data?.issue?.team?.id;
} else {
teamId = updateFields.teamId as string;
}
}
const returnData: INodePropertyOptions[] = [];
const body = {
query: query.getStates(),
variables: {
$first: 10,
filter: {
team: {
id: {
eq: teamId,
},
},
},
},
};
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: INodeExecutionData[] = [];
const length = items.length;
let responseData;
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
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);
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.issue;
}
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i);
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);
responseData = await linearApiRequestAllItems.call(this, 'data.issues', body, limit);
}
}
if (operation === 'update') {
const issueId = this.getNodeParameter('issueId', i) as string;
const updateFields = this.getNodeParameter('updateFields', i);
const body: IGraphqlBody = {
query: query.updateIssue(),
variables: {
issueId,
...updateFields,
},
};
responseData = await linearApiRequest.call(this, body);
responseData = responseData?.data?.issueUpdate?.issue;
}
if (operation === 'addLink') {
const issueId = this.getNodeParameter('issueId', i) as string;
const body: IGraphqlBody = {
query: query.addIssueLink(),
variables: {
issueId,
url: this.getNodeParameter('link', i),
},
};
responseData = await linearApiRequest.call(this, body);
responseData = responseData?.data?.attachmentLinkURL;
}
} else if (resource === 'comment') {
if (operation === 'addComment') {
const issueId = this.getNodeParameter('issueId', i) as string;
const body = this.getNodeParameter('comment', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
const requestBody: IGraphqlBody = {
query: query.addComment(),
variables: {
issueId,
body,
},
};
if (additionalFields.parentId && (additionalFields.parentId as string).trim() !== '') {
requestBody.variables.parentId = additionalFields.parentId as string;
}
responseData = await linearApiRequest.call(this, requestBody);
responseData = responseData?.data?.commentCreate;
}
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return [returnData];
}
}
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.linearTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/linear/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.lineartrigger/"
}
]
}
}
@@ -0,0 +1,282 @@
import {
type IHookFunctions,
type IWebhookFunctions,
type ILoadOptionsFunctions,
type INodePropertyOptions,
type INodeType,
type INodeTypeDescription,
type IWebhookResponseData,
NodeConnectionTypes,
} 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',
},
inputs: [],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'linearApi',
required: true,
testedBy: 'linearApiTest',
displayOptions: {
show: {
authentication: ['apiToken'],
},
},
},
{
name: 'linearOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
webhooks: [
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'API Token',
value: 'apiToken',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
],
default: 'apiToken',
},
{
displayName: 'Make sure your credential has the "Admin" scope to create webhooks.',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Team Name or ID',
name: 'teamId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
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<INodePropertyOptions[]> {
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;
},
},
};
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
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<boolean> {
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<boolean> {
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 registered anymore
delete webhookData.webhookId;
}
return true;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const bodyData = this.getBodyData();
return {
workflowData: [this.helpers.returnJsonArray(bodyData)],
};
}
}
+238
View File
@@ -0,0 +1,238 @@
export const query = {
getUsers() {
return `query Users ($first: Int, $after: String){
users (first: $first, after: $after){
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, $after: String, $filter: WorkflowStateFilter){
workflowStates (first: $first, after: $after, filter: $filter){
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,
identifier,
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: String!) {
issue(id: $issueId) {
id,
identifier,
title,
priority,
archivedAt,
assignee {
id,
displayName
}
state {
id
name
}
createdAt
creator {
id
displayName
}
description
dueDate
cycle {
id
name
}
}
}`;
},
getIssueTeam() {
return `query Issue($issueId: String!) {
issue(id: $issueId) {
team {
id
}
}
}`;
},
getIssues() {
return `query Issue ($first: Int, $after: String){
issues (first: $first, after: $after){
nodes {
id,
identifier,
title,
priority
archivedAt
assignee {
id
displayName
}
state {
id
name
}
createdAt
creator {
id
displayName
}
description
dueDate
cycle {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}`;
},
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,
identifier,
title,
priority
archivedAt
assignee {
id
displayName
}
state {
id
name
}
createdAt
creator {
id
displayName
}
description
dueDate
cycle {
id
name
}
}
}
}`;
},
addComment() {
return `mutation CommentCreate ($issueId: String!, $body: String!, $parentId: String) {
commentCreate(input: {issueId: $issueId, body: $body, parentId: $parentId}) {
success
comment {
id
}
}
}`;
},
addIssueLink() {
return `mutation AttachmentLinkURL($url: String!, $issueId: String!) {
attachmentLinkURL(url: $url, issueId: $issueId) {
success
}
}`;
},
};
@@ -0,0 +1,49 @@
{
"type": "object",
"properties": {
"archivedAt": {
"type": "null"
},
"createdAt": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"id": {
"type": "string"
}
}
},
"dueDate": {
"type": "null"
},
"id": {
"type": "string"
},
"identifier": {
"type": "string"
},
"priority": {
"type": "integer"
},
"state": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"title": {
"type": "string"
}
},
"version": 2
}
@@ -0,0 +1,43 @@
{
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"id": {
"type": "string"
}
}
},
"id": {
"type": "string"
},
"identifier": {
"type": "string"
},
"priority": {
"type": "integer"
},
"state": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"title": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,46 @@
{
"type": "object",
"properties": {
"archivedAt": {
"type": "null"
},
"createdAt": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"id": {
"type": "string"
}
}
},
"id": {
"type": "string"
},
"identifier": {
"type": "string"
},
"priority": {
"type": "integer"
},
"state": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"title": {
"type": "string"
}
},
"version": 3
}
@@ -0,0 +1,43 @@
{
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"id": {
"type": "string"
}
}
},
"id": {
"type": "string"
},
"identifier": {
"type": "string"
},
"priority": {
"type": "integer"
},
"state": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"title": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,12 @@
{
"type": "object",
"properties": {
"message": {
"type": "string"
},
"status": {
"type": "integer"
}
},
"version": 1
}
@@ -0,0 +1,17 @@
{
"type": "object",
"properties": {
"comment": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
}
},
"success": {
"type": "boolean"
}
},
"version": 1
}
@@ -0,0 +1,49 @@
{
"type": "object",
"properties": {
"archivedAt": {
"type": "null"
},
"createdAt": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"id": {
"type": "string"
}
}
},
"dueDate": {
"type": "null"
},
"id": {
"type": "string"
},
"identifier": {
"type": "string"
},
"priority": {
"type": "integer"
},
"state": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"title": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,43 @@
{
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"id": {
"type": "string"
}
}
},
"id": {
"type": "string"
},
"identifier": {
"type": "string"
},
"priority": {
"type": "integer"
},
"state": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"title": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,46 @@
{
"type": "object",
"properties": {
"archivedAt": {
"type": "null"
},
"createdAt": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"id": {
"type": "string"
}
}
},
"id": {
"type": "string"
},
"identifier": {
"type": "string"
},
"priority": {
"type": "integer"
},
"state": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"title": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" style="width:24px;height:24px;margin-right:12px"><path fill="#5E6AD2" d="m.403 37.4 26.198 26.197C13.223 61.336 2.664 50.777.403 37.399M0 30.287 33.713 64a32 32 0 0 0 5.852-.858L.858 24.435c-.46 1.89-.75 3.847-.858 5.852M2.536 19.404l42.06 42.06a32 32 0 0 0 4.4-2.31l-44.15-44.15a32 32 0 0 0-2.31 4.4M7.695 11.145C13.568 4.32 22.268 0 31.977 0 49.663 0 64 14.337 64 32.023c0 9.71-4.32 18.41-11.145 24.282z"/></svg>

After

Width:  |  Height:  |  Size: 489 B

@@ -0,0 +1,176 @@
import type {
IExecuteFunctions,
IHookFunctions,
ILoadOptionsFunctions,
IWebhookFunctions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { capitalizeFirstLetter, linearApiRequest, sort } from '../GenericFunctions';
describe('Linear -> GenericFunctions', () => {
const mockHttpRequestWithAuthentication = jest.fn();
describe('linearApiRequest', () => {
let mockExecuteFunctions:
| IExecuteFunctions
| IWebhookFunctions
| IHookFunctions
| ILoadOptionsFunctions;
const setupMockFunctions = (authentication: string) => {
mockExecuteFunctions = {
getNodeParameter: jest.fn().mockReturnValue(authentication),
helpers: {
httpRequestWithAuthentication: mockHttpRequestWithAuthentication,
},
getNode: jest.fn().mockReturnValue({}),
} as unknown as
| IExecuteFunctions
| IWebhookFunctions
| IHookFunctions
| ILoadOptionsFunctions;
jest.clearAllMocks();
};
beforeEach(() => {
setupMockFunctions('apiToken');
});
it('should make a successful API request', async () => {
const response = { data: { success: true } };
mockHttpRequestWithAuthentication.mockResolvedValue(response);
const result = await linearApiRequest.call(mockExecuteFunctions, {
query: '{ viewer { id } }',
});
expect(result).toEqual(response);
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
'linearApi',
expect.objectContaining({
method: 'POST',
url: 'https://api.linear.app/graphql',
json: true,
body: { query: '{ viewer { id } }' },
}),
);
});
it('should handle API request errors', async () => {
const errorResponse = {
errors: [
{
message: 'Access denied',
extensions: {
userPresentableMessage: 'You need to have the "Admin" scope to create webhooks.',
},
},
],
};
mockHttpRequestWithAuthentication.mockResolvedValue(errorResponse);
await expect(
linearApiRequest.call(mockExecuteFunctions, { query: '{ viewer { id } }' }),
).rejects.toThrow(NodeApiError);
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
'linearApi',
expect.objectContaining({
method: 'POST',
url: 'https://api.linear.app/graphql',
json: true,
body: { query: '{ viewer { id } }' },
}),
);
});
});
describe('capitalizeFirstLetter', () => {
it('should capitalize the first letter of a string', () => {
expect(capitalizeFirstLetter('hello')).toBe('Hello');
expect(capitalizeFirstLetter('world')).toBe('World');
expect(capitalizeFirstLetter('capitalize')).toBe('Capitalize');
});
it('should return an empty string if input is empty', () => {
expect(capitalizeFirstLetter('')).toBe('');
});
it('should handle single character strings', () => {
expect(capitalizeFirstLetter('a')).toBe('A');
expect(capitalizeFirstLetter('b')).toBe('B');
});
it('should not change the case of the rest of the string', () => {
expect(capitalizeFirstLetter('hELLO')).toBe('HELLO');
expect(capitalizeFirstLetter('wORLD')).toBe('WORLD');
});
});
describe('sort', () => {
it('should correctly sort objects by name in ascending order', () => {
// Test data
const items = [
{ name: 'Banana', id: 3 },
{ name: 'apple', id: 2 },
{ name: 'Cherry', id: 1 },
{ name: 'date', id: 4 },
];
// Execute sort
const sorted = [...items].sort(sort);
// Verify sorted order (case-insensitive)
expect(sorted.map((item) => item.id)).toEqual([2, 3, 1, 4]);
expect(sorted.map((item) => item.name)).toEqual(['apple', 'Banana', 'Cherry', 'date']);
});
it('should treat uppercase and lowercase names as equal', () => {
const a = { name: 'apple' };
const b = { name: 'APPLE' };
expect(sort(a, b)).toBe(0);
expect(sort(b, a)).toBe(0);
});
it('should return -1 when first name comes before second name alphabetically', () => {
const a = { name: 'apple' };
const b = { name: 'banana' };
expect(sort(a, b)).toBe(-1);
});
it('should return 1 when first name comes after second name alphabetically', () => {
const a = { name: 'cherry' };
const b = { name: 'banana' };
expect(sort(a, b)).toBe(1);
});
it('should return 0 for identical names', () => {
const a = { name: 'apple' };
const b = { name: 'apple' };
expect(sort(a, b)).toBe(0);
});
it('should handle mixed case properly', () => {
// Test data
const items = [
{ name: 'abc', id: 1 },
{ name: 'ABC', id: 2 },
{ name: 'Abc', id: 3 },
{ name: 'aBC', id: 4 },
];
// They should all be considered equal in terms of sorting
const sorted = [...items].sort(sort);
// Original order should be maintained for equal values
expect(sorted.map((item) => item.id)).toEqual([1, 2, 3, 4]);
});
});
});
@@ -0,0 +1,525 @@
{
"name": "Linear Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-80, 260],
"id": "4dbe018c-edec-46d3-b18d-f4517435e1f8",
"name": "When clicking Execute workflow"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [400, -400],
"id": "512f2fec-df9f-46ac-8df3-e84fd352f7ff",
"name": "Create Comment - Output"
},
{
"parameters": {
"resource": "comment",
"issueId": "test-17",
"comment": "test",
"additionalFields": {}
},
"type": "n8n-nodes-base.linear",
"typeVersion": 1,
"position": [200, -400],
"id": "be60984b-28a5-4c5a-8926-87824300e63e",
"name": "Add Comment",
"credentials": {
"linearApi": {
"id": "6bJTI5tzXOk9m6cv",
"name": "86-88"
}
}
},
{
"parameters": {
"resource": "comment",
"issueId": "test-17",
"comment": "Add to parent",
"additionalFields": {
"parentId": "ff12069e-fac8-4b18-8455-cc6b29fa1e77"
}
},
"type": "n8n-nodes-base.linear",
"typeVersion": 1,
"position": [200, -220],
"id": "328939ed-41ac-442e-819c-f470ce0986b4",
"name": "Add Comment - with parent",
"credentials": {
"linearApi": {
"id": "6bJTI5tzXOk9m6cv",
"name": "86-88"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [400, -220],
"id": "d28e9fe2-4a7b-4714-aa15-a6c6732c4e0d",
"name": "Create Comment with Parent"
},
{
"parameters": {
"operation": "addLink",
"issueId": "test-17",
"link": "https://n8n.io"
},
"type": "n8n-nodes-base.linear",
"typeVersion": 1,
"position": [200, -20],
"id": "6ec9fdbc-eed8-4e9a-850b-973f72f6f3a5",
"name": "Add link to issue",
"credentials": {
"linearApi": {
"id": "6bJTI5tzXOk9m6cv",
"name": "86-88"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [400, -20],
"id": "229e5b09-8867-44f5-a71c-da31d97ab1b5",
"name": "Add Link output"
},
{
"parameters": {
"teamId": "0a2994c1-5d99-48aa-ab22-8b5ba4711ebc",
"title": "This is a test issue",
"additionalFields": {
"assigneeId": "1c51f0c4-c552-4614-a534-8de1752ba7d7",
"description": "test description",
"priorityId": 3,
"stateId": "65a87a3a-5729-4d82-96bf-badccbeb49af"
}
},
"type": "n8n-nodes-base.linear",
"typeVersion": 1,
"position": [200, 160],
"id": "3262089e-902a-4922-b5e9-1bd2dae4915f",
"name": "Create Issue",
"credentials": {
"linearApi": {
"id": "6bJTI5tzXOk9m6cv",
"name": "86-88"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [400, 160],
"id": "3515db04-1fb9-4c15-904b-1a34c7bd63fb",
"name": "Create Issue Response"
},
{
"parameters": {
"operation": "getAll",
"limit": 1
},
"type": "n8n-nodes-base.linear",
"typeVersion": 1,
"position": [200, 520],
"id": "912a5680-2d83-4d24-921b-f622cc8be0be",
"name": "Issue Get Many",
"credentials": {
"linearApi": {
"id": "6bJTI5tzXOk9m6cv",
"name": "86-88"
}
}
},
{
"parameters": {
"operation": "get",
"issueId": "test-18"
},
"type": "n8n-nodes-base.linear",
"typeVersion": 1,
"position": [200, 340],
"id": "8604ce38-89aa-4793-bd2d-13c7d4fce215",
"name": "Get Issue",
"credentials": {
"linearApi": {
"id": "6bJTI5tzXOk9m6cv",
"name": "86-88"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [400, 520],
"id": "d01f72ce-4f5f-4a3f-9805-7975e67d041d",
"name": "Get Many Issues"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [400, 340],
"id": "a2ad027f-8507-4488-84f5-f339ad826120",
"name": "Get Issue Output"
},
{
"parameters": {
"operation": "update",
"issueId": "test-18",
"updateFields": {
"assigneeId": "1c51f0c4-c552-4614-a534-8de1752ba7d7",
"description": "New Description",
"priorityId": 3,
"stateId": "622493c0-f4ee-456d-af65-49a7611ede7a",
"teamId": "0a2994c1-5d99-48aa-ab22-8b5ba4711ebc",
"title": "New Title"
}
},
"type": "n8n-nodes-base.linear",
"typeVersion": 1,
"position": [200, 700],
"id": "141d4f10-6812-40b4-a146-fa9cf929f86a",
"name": "Update Issue",
"credentials": {
"linearApi": {
"id": "6bJTI5tzXOk9m6cv",
"name": "86-88"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [400, 700],
"id": "b08bba99-9d96-43be-8875-c72c7b51f734",
"name": "Update Issue Response"
},
{
"parameters": {
"operation": "delete",
"issueId": "test-18"
},
"type": "n8n-nodes-base.linear",
"typeVersion": 1,
"position": [200, 880],
"id": "d30a2ff2-f8cd-4277-b5c1-6f2df7985872",
"name": "Delete Issue",
"credentials": {
"linearApi": {
"id": "6bJTI5tzXOk9m6cv",
"name": "86-88"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [400, 880],
"id": "8769421e-01ea-41ee-9e35-432408879869",
"name": "Delete Issue Response"
}
],
"pinData": {
"Create Comment - Output": [
{
"json": {
"success": true,
"comment": {
"id": "ff12069e-fac8-4b18-8455-cc6b29fa1e77"
}
}
}
],
"Create Comment with Parent": [
{
"json": {
"success": true,
"comment": {
"id": "bd0e4d70-7964-4877-aa30-d81534027f44"
}
}
}
],
"Add Link output": [
{
"json": {
"success": true
}
}
],
"Create Issue Response": [
{
"json": {
"id": "3c7316e3-4224-424d-8cc8-1dd3b96764b8",
"identifier": "TEST-18",
"title": "This is a test issue",
"priority": 3,
"archivedAt": null,
"assignee": {
"id": "1c51f0c4-c552-4614-a534-8de1752ba7d7",
"displayName": "nathan"
},
"state": {
"id": "65a87a3a-5729-4d82-96bf-badccbeb49af",
"name": "Backlog"
},
"createdAt": "2025-06-12T10:38:35.296Z",
"creator": {
"id": "1c51f0c4-c552-4614-a534-8de1752ba7d7",
"displayName": "nathan"
},
"description": "test description",
"dueDate": null,
"cycle": null
}
}
],
"Get Issue Output": [
{
"json": {
"id": "3c7316e3-4224-424d-8cc8-1dd3b96764b8",
"identifier": "TEST-18",
"title": "This is a test issue",
"priority": 3,
"archivedAt": null,
"assignee": {
"id": "1c51f0c4-c552-4614-a534-8de1752ba7d7",
"displayName": "nathan"
},
"state": {
"id": "65a87a3a-5729-4d82-96bf-badccbeb49af",
"name": "Backlog"
},
"createdAt": "2025-06-12T10:38:35.296Z",
"creator": {
"id": "1c51f0c4-c552-4614-a534-8de1752ba7d7",
"displayName": "nathan"
},
"description": "test description",
"dueDate": null,
"cycle": null
}
}
],
"Get Many Issues": [
{
"json": {
"id": "3c7316e3-4224-424d-8cc8-1dd3b96764b8",
"identifier": "TEST-18",
"title": "This is a test issue",
"priority": 3,
"archivedAt": null,
"assignee": {
"id": "1c51f0c4-c552-4614-a534-8de1752ba7d7",
"displayName": "nathan"
},
"state": {
"id": "65a87a3a-5729-4d82-96bf-badccbeb49af",
"name": "Backlog"
},
"createdAt": "2025-06-12T10:38:35.296Z",
"creator": {
"id": "1c51f0c4-c552-4614-a534-8de1752ba7d7",
"displayName": "nathan"
},
"description": "test description",
"dueDate": null,
"cycle": null
}
}
],
"Update Issue Response": [
{
"json": {
"id": "3c7316e3-4224-424d-8cc8-1dd3b96764b8",
"identifier": "TEST-18",
"title": "New Title",
"priority": 3,
"archivedAt": null,
"assignee": {
"id": "1c51f0c4-c552-4614-a534-8de1752ba7d7",
"displayName": "nathan"
},
"state": {
"id": "622493c0-f4ee-456d-af65-49a7611ede7a",
"name": "Canceled"
},
"createdAt": "2025-06-12T10:38:35.296Z",
"creator": {
"id": "1c51f0c4-c552-4614-a534-8de1752ba7d7",
"displayName": "nathan"
},
"description": "New Description",
"dueDate": null,
"cycle": null
}
}
],
"Delete Issue Response": [
{
"json": {
"success": true
}
}
]
},
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "Add Comment",
"type": "main",
"index": 0
},
{
"node": "Add Comment - with parent",
"type": "main",
"index": 0
},
{
"node": "Add link to issue",
"type": "main",
"index": 0
},
{
"node": "Create Issue",
"type": "main",
"index": 0
},
{
"node": "Get Issue",
"type": "main",
"index": 0
},
{
"node": "Issue Get Many",
"type": "main",
"index": 0
},
{
"node": "Update Issue",
"type": "main",
"index": 0
},
{
"node": "Delete Issue",
"type": "main",
"index": 0
}
]
]
},
"Add Comment": {
"main": [
[
{
"node": "Create Comment - Output",
"type": "main",
"index": 0
}
]
]
},
"Add Comment - with parent": {
"main": [
[
{
"node": "Create Comment with Parent",
"type": "main",
"index": 0
}
]
]
},
"Add link to issue": {
"main": [
[
{
"node": "Add Link output",
"type": "main",
"index": 0
}
]
]
},
"Create Issue": {
"main": [
[
{
"node": "Create Issue Response",
"type": "main",
"index": 0
}
]
]
},
"Get Issue": {
"main": [
[
{
"node": "Get Issue Output",
"type": "main",
"index": 0
}
]
]
},
"Issue Get Many": {
"main": [
[
{
"node": "Get Many Issues",
"type": "main",
"index": 0
}
]
]
},
"Update Issue": {
"main": [
[
{
"node": "Update Issue Response",
"type": "main",
"index": 0
}
]
]
},
"Delete Issue": {
"main": [
[
{
"node": "Delete Issue Response",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "2cc012ac-e157-4f3e-97d8-15fb2da556ef",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "0fa937d34dcabeff4bd6480d3b42cc95edf3bc20e6810819086ef1ce2623639d"
},
"id": "6FHfNEY4tcmoXVeg",
"tags": []
}
@@ -0,0 +1,43 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
import {
addCommentRequest,
addCommentWithParentRequest,
addCommentLink,
issueCreateRequest,
getIssueRequest,
getManyIssuesRequest,
updateIssueRequest,
deleteIssueRequest,
} from './apiRequest';
import {
commentCreateResponse,
commentCreateWithParentResponse,
attachmentLinkURLResponse,
issueCreateResponse,
getIssueResponse,
getManyIssueResponse,
issueUpdateResponse,
deleteIssueResponse,
} from './apiResponses';
describe('Linear', () => {
describe('Run Test Workflow', () => {
beforeAll(() => {
const mock = nock('https://api.linear.app');
mock.post('/graphql', addCommentRequest).reply(200, commentCreateResponse);
mock.post('/graphql', addCommentLink).reply(200, attachmentLinkURLResponse);
mock
.post('/graphql', addCommentWithParentRequest)
.reply(200, commentCreateWithParentResponse);
mock.post('/graphql', issueCreateRequest).reply(200, issueCreateResponse);
mock.post('/graphql', getIssueRequest).reply(200, getIssueResponse);
mock.post('/graphql', getManyIssuesRequest).reply(200, getManyIssueResponse);
mock.post('/graphql', updateIssueRequest).reply(200, issueUpdateResponse);
mock.post('/graphql', deleteIssueRequest).reply(200, deleteIssueResponse);
});
new NodeTestHarness().setupTests();
});
});
@@ -0,0 +1,245 @@
export const addCommentRequest = {
query: `mutation CommentCreate ($issueId: String!, $body: String!, $parentId: String) {
commentCreate(input: {issueId: $issueId, body: $body, parentId: $parentId}) {
success
comment {
id
}
}
}`,
variables: {
issueId: 'test-17',
body: 'test',
},
};
export const addCommentWithParentRequest = {
query: `mutation CommentCreate ($issueId: String!, $body: String!, $parentId: String) {
commentCreate(input: {issueId: $issueId, body: $body, parentId: $parentId}) {
success
comment {
id
}
}
}`,
variables: {
issueId: 'test-17',
body: 'Add to parent',
parentId: 'ff12069e-fac8-4b18-8455-cc6b29fa1e77',
},
};
export const addCommentLink = {
query: `mutation AttachmentLinkURL($url: String!, $issueId: String!) {
attachmentLinkURL(url: $url, issueId: $issueId) {
success
}
}`,
variables: {
issueId: 'test-17',
url: 'https://n8n.io',
},
};
export const issueCreateRequest = {
query: `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,
identifier,
title,
priority
archivedAt
assignee {
id
displayName
}
state {
id
name
}
createdAt
creator {
id
displayName
}
description
dueDate
cycle {
id
name
}
}
}
}`,
variables: {
teamId: '0a2994c1-5d99-48aa-ab22-8b5ba4711ebc',
title: 'This is a test issue',
assigneeId: '1c51f0c4-c552-4614-a534-8de1752ba7d7',
description: 'test description',
priorityId: 3,
stateId: '65a87a3a-5729-4d82-96bf-badccbeb49af',
},
};
export const getIssueRequest = {
query: `query Issue($issueId: String!) {
issue(id: $issueId) {
id,
identifier,
title,
priority,
archivedAt,
assignee {
id,
displayName
}
state {
id
name
}
createdAt
creator {
id
displayName
}
description
dueDate
cycle {
id
name
}
}
}`,
variables: {
issueId: 'test-18',
},
};
export const getManyIssuesRequest = {
query: `query Issue ($first: Int, $after: String){
issues (first: $first, after: $after){
nodes {
id,
identifier,
title,
priority
archivedAt
assignee {
id
displayName
}
state {
id
name
}
createdAt
creator {
id
displayName
}
description
dueDate
cycle {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}`,
variables: {
first: 1,
after: null,
},
};
export const updateIssueRequest = {
query: `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,
identifier,
title,
priority
archivedAt
assignee {
id
displayName
}
state {
id
name
}
createdAt
creator {
id
displayName
}
description
dueDate
cycle {
id
name
}
}
}
}`,
variables: {
issueId: 'test-18',
assigneeId: '1c51f0c4-c552-4614-a534-8de1752ba7d7',
description: 'New Description',
priorityId: 3,
stateId: '622493c0-f4ee-456d-af65-49a7611ede7a',
teamId: '0a2994c1-5d99-48aa-ab22-8b5ba4711ebc',
title: 'New Title',
},
};
export const deleteIssueRequest = {
query: `mutation IssueDelete ($issueId: String!) {
issueDelete(id: $issueId) {
success
}
}`,
variables: {
issueId: 'test-18',
},
};
@@ -0,0 +1,163 @@
export const commentCreateResponse = {
data: {
commentCreate: {
success: true,
comment: {
id: 'ff12069e-fac8-4b18-8455-cc6b29fa1e77',
},
},
},
};
export const commentCreateWithParentResponse = {
data: {
commentCreate: {
success: true,
comment: {
id: 'bd0e4d70-7964-4877-aa30-d81534027f44',
},
},
},
};
export const attachmentLinkURLResponse = {
data: {
attachmentLinkURL: {
success: true,
},
},
};
export const issueCreateResponse = {
data: {
issueCreate: {
success: true,
issue: {
id: '3c7316e3-4224-424d-8cc8-1dd3b96764b8',
identifier: 'TEST-18',
title: 'This is a test issue',
priority: 3,
archivedAt: null,
assignee: {
id: '1c51f0c4-c552-4614-a534-8de1752ba7d7',
displayName: 'nathan',
},
state: {
id: '65a87a3a-5729-4d82-96bf-badccbeb49af',
name: 'Backlog',
},
createdAt: '2025-06-12T10:38:35.296Z',
creator: {
id: '1c51f0c4-c552-4614-a534-8de1752ba7d7',
displayName: 'nathan',
},
description: 'test description',
dueDate: null,
cycle: null,
},
},
},
};
export const getIssueResponse = {
data: {
issue: {
id: '3c7316e3-4224-424d-8cc8-1dd3b96764b8',
identifier: 'TEST-18',
title: 'This is a test issue',
priority: 3,
archivedAt: null,
assignee: {
id: '1c51f0c4-c552-4614-a534-8de1752ba7d7',
displayName: 'nathan',
},
state: {
id: '65a87a3a-5729-4d82-96bf-badccbeb49af',
name: 'Backlog',
},
createdAt: '2025-06-12T10:38:35.296Z',
creator: {
id: '1c51f0c4-c552-4614-a534-8de1752ba7d7',
displayName: 'nathan',
},
description: 'test description',
dueDate: null,
cycle: null,
},
},
};
export const getManyIssueResponse = {
data: {
issues: {
nodes: [
{
id: '3c7316e3-4224-424d-8cc8-1dd3b96764b8',
identifier: 'TEST-18',
title: 'This is a test issue',
priority: 3,
archivedAt: null,
assignee: {
id: '1c51f0c4-c552-4614-a534-8de1752ba7d7',
displayName: 'nathan',
},
state: {
id: '65a87a3a-5729-4d82-96bf-badccbeb49af',
name: 'Backlog',
},
createdAt: '2025-06-12T10:38:35.296Z',
creator: {
id: '1c51f0c4-c552-4614-a534-8de1752ba7d7',
displayName: 'nathan',
},
description: 'test description',
dueDate: null,
cycle: null,
},
],
pageInfo: {
hasNextPage: true,
endCursor: '3c7316e3-4224-424d-8cc8-1dd3b96764b8',
},
},
},
};
export const issueUpdateResponse = {
data: {
issueUpdate: {
success: true,
issue: {
id: '3c7316e3-4224-424d-8cc8-1dd3b96764b8',
identifier: 'TEST-18',
title: 'New Title',
priority: 3,
archivedAt: null,
assignee: {
id: '1c51f0c4-c552-4614-a534-8de1752ba7d7',
displayName: 'nathan',
},
state: {
id: '622493c0-f4ee-456d-af65-49a7611ede7a',
name: 'Canceled',
},
createdAt: '2025-06-12T10:38:35.296Z',
creator: {
id: '1c51f0c4-c552-4614-a534-8de1752ba7d7',
displayName: 'nathan',
},
description: 'New Description',
dueDate: null,
cycle: null,
},
},
},
};
export const deleteIssueResponse = {
data: {
issueDelete: {
success: true,
},
},
};