mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
feat(Trello Node) Add support for board members and credential tests (#3201)
* adds support for trello board member operations: inviteMemberByEmail, addMember, removeMember, getMembers * lintfix * format fixes * remove unnecessary variable and assign to qs on same line * fix description * Moved Board Members to their own resource * Removed members from board resource... * Added return all limits to get members * adds info about Trello premium feature in description * Improvements from internal review * ⚡ Improvements * Changed credentials to use new system and implemented test * ⚡ Improvements * fix(core): Fix issue with fixedCollection having all default values * 👕 Fix lint issue Co-authored-by: Jonathan Bennetts <jonathan.bennetts@gmail.com> Co-authored-by: ricardo <ricardoespinoza105@gmail.com> Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
parent
7ced65484f
commit
d8870ecbff
|
@ -1,9 +1,11 @@
|
||||||
import {
|
import {
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
ICredentialTestRequest,
|
||||||
ICredentialType,
|
ICredentialType,
|
||||||
|
IHttpRequestOptions,
|
||||||
INodeProperties,
|
INodeProperties,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
|
||||||
export class TrelloApi implements ICredentialType {
|
export class TrelloApi implements ICredentialType {
|
||||||
name = 'trelloApi';
|
name = 'trelloApi';
|
||||||
displayName = 'Trello API';
|
displayName = 'Trello API';
|
||||||
|
@ -13,19 +15,36 @@ export class TrelloApi implements ICredentialType {
|
||||||
displayName: 'API Key',
|
displayName: 'API Key',
|
||||||
name: 'apiKey',
|
name: 'apiKey',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
required: true,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'API Token',
|
displayName: 'API Token',
|
||||||
name: 'apiToken',
|
name: 'apiToken',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
required: true,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'OAuth Secret',
|
displayName: 'OAuth Secret',
|
||||||
name: 'oauthSecret',
|
name: 'oauthSecret',
|
||||||
type: 'string',
|
type: 'hidden',
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async authenticate(credentials: ICredentialDataDecryptedObject, requestOptions: IHttpRequestOptions): Promise<IHttpRequestOptions> {
|
||||||
|
requestOptions.qs = {
|
||||||
|
...requestOptions.qs,
|
||||||
|
'key': credentials.apiKey,
|
||||||
|
'token': credentials.apiToken,
|
||||||
|
};
|
||||||
|
return requestOptions;
|
||||||
|
}
|
||||||
|
test: ICredentialTestRequest = {
|
||||||
|
request: {
|
||||||
|
baseURL: 'https://api.trello.com',
|
||||||
|
url: '=/1/tokens/{{$credentials.apiToken}}/member',
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -3663,7 +3663,7 @@ export class Pipedrive implements INodeType {
|
||||||
loadOptionsMethod: 'getFilters',
|
loadOptionsMethod: 'getFilters',
|
||||||
},
|
},
|
||||||
default: '',
|
default: '',
|
||||||
description: 'ID of the filter to use.',
|
description: 'ID of the filter to use',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -455,5 +455,4 @@ export const boardFields: INodeProperties[] = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
337
packages/nodes-base/nodes/Trello/BoardMemberDescription.ts
Normal file
337
packages/nodes-base/nodes/Trello/BoardMemberDescription.ts
Normal file
|
@ -0,0 +1,337 @@
|
||||||
|
import {
|
||||||
|
INodeProperties,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
export const boardMemberOperations: INodeProperties[] = [
|
||||||
|
// ----------------------------------
|
||||||
|
// boardMember
|
||||||
|
// ----------------------------------
|
||||||
|
{
|
||||||
|
displayName: 'Operation',
|
||||||
|
name: 'operation',
|
||||||
|
type: 'options',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Add',
|
||||||
|
value: 'add',
|
||||||
|
description: 'Add member to board using member ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Get All',
|
||||||
|
value: 'getAll',
|
||||||
|
description: 'Get all members of a board',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Invite',
|
||||||
|
value: 'invite',
|
||||||
|
description: 'Invite a new member to a board via email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Remove',
|
||||||
|
value: 'remove',
|
||||||
|
description: 'Remove member from board using member ID',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'add',
|
||||||
|
description: 'The operation to perform.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const boardMemberFields: INodeProperties[] = [
|
||||||
|
// ----------------------------------
|
||||||
|
// boardMember:getAll
|
||||||
|
// ----------------------------------
|
||||||
|
{
|
||||||
|
displayName: 'Board ID',
|
||||||
|
name: 'id',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'getAll',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: 'The ID of the board to get members from',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Return All',
|
||||||
|
name: 'returnAll',
|
||||||
|
type: 'boolean',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'getAll',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: false,
|
||||||
|
description: 'Whether to return all results or only up to a given limit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Limit',
|
||||||
|
name: 'limit',
|
||||||
|
type: 'number',
|
||||||
|
description: 'Max number of results to return',
|
||||||
|
default: 20,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'getAll',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
returnAll: [
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------
|
||||||
|
// boardMember:add
|
||||||
|
// ----------------------------------
|
||||||
|
{
|
||||||
|
displayName: 'Board ID',
|
||||||
|
name: 'id',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'add',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: 'The ID of the board to add member to',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Member ID',
|
||||||
|
name: 'idMember',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'add',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: 'The ID of the member to add to the board',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Type',
|
||||||
|
name: 'type',
|
||||||
|
type: 'options',
|
||||||
|
required: true,
|
||||||
|
default: 'normal',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'add',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Normal',
|
||||||
|
value: 'normal',
|
||||||
|
description: 'Invite as normal member',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Admin',
|
||||||
|
value: 'admin',
|
||||||
|
description: 'Invite as admin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Observer',
|
||||||
|
value: 'observer',
|
||||||
|
description: 'Invite as observer (Trello premium feature)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: 'Determines the type of membership the user being added should have',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Additional Fields',
|
||||||
|
name: 'additionalFields',
|
||||||
|
type: 'collection',
|
||||||
|
placeholder: 'Add Field',
|
||||||
|
default: {},
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'add',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'Allow Billable Guest',
|
||||||
|
name: 'allowBillableGuest',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
description: 'Allows organization admins to add multi-board guests onto a board',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------
|
||||||
|
// boardMember:invite
|
||||||
|
// ----------------------------------
|
||||||
|
{
|
||||||
|
displayName: 'Board ID',
|
||||||
|
name: 'id',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'invite',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: 'The ID of the board to invite member to',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Email',
|
||||||
|
name: 'email',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'invite',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: 'The ID of the board to update',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Additional Fields',
|
||||||
|
name: 'additionalFields',
|
||||||
|
type: 'collection',
|
||||||
|
placeholder: 'Add Field',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'invite',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'Type',
|
||||||
|
name: 'type',
|
||||||
|
type: 'options',
|
||||||
|
default: 'normal',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Normal',
|
||||||
|
value: 'normal',
|
||||||
|
description: 'Invite as normal member',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Admin',
|
||||||
|
value: 'admin',
|
||||||
|
description: 'Invite as admin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Observer',
|
||||||
|
value: 'observer',
|
||||||
|
description: 'Invite as observer (Trello premium feature)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: 'Determines the type of membership the user being added should have',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Full Name',
|
||||||
|
name: 'fullName',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
description: 'The full name of the user to add as a member of the board. Must have a length of at least 1 and cannot begin nor end with a space.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------
|
||||||
|
// boardMember:remove
|
||||||
|
// ----------------------------------
|
||||||
|
{
|
||||||
|
displayName: 'Board ID',
|
||||||
|
name: 'id',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'remove',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: 'The ID of the board to remove member from',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Member ID',
|
||||||
|
name: 'idMember',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
operation: [
|
||||||
|
'remove',
|
||||||
|
],
|
||||||
|
resource: [
|
||||||
|
'boardMember',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: 'The ID of the member to remove from the board',
|
||||||
|
},
|
||||||
|
];
|
|
@ -9,7 +9,9 @@ import {
|
||||||
} from 'request';
|
} from 'request';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataObject, NodeApiError, NodeOperationError,
|
IDataObject,
|
||||||
|
JsonObject,
|
||||||
|
NodeApiError,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -22,16 +24,9 @@ import {
|
||||||
* @returns {Promise<any>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: IDataObject): Promise<any> { // tslint:disable-line:no-any
|
export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: IDataObject): Promise<any> { // tslint:disable-line:no-any
|
||||||
const credentials = await this.getCredentials('trelloApi');
|
|
||||||
|
|
||||||
query = query || {};
|
query = query || {};
|
||||||
|
|
||||||
query.key = credentials.apiKey;
|
|
||||||
query.token = credentials.apiToken;
|
|
||||||
|
|
||||||
const options: OptionsWithUri = {
|
const options: OptionsWithUri = {
|
||||||
headers: {
|
|
||||||
},
|
|
||||||
method,
|
method,
|
||||||
body,
|
body,
|
||||||
qs: query,
|
qs: query,
|
||||||
|
@ -40,9 +35,9 @@ export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoa
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.helpers.request!(options);
|
return await this.helpers.requestWithAuthentication.call(this, 'trelloApi', options);
|
||||||
} catch (error) {
|
} catch(error) {
|
||||||
throw new NodeApiError(this.getNode(), error);
|
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,11 @@ import {
|
||||||
boardOperations,
|
boardOperations,
|
||||||
} from './BoardDescription';
|
} from './BoardDescription';
|
||||||
|
|
||||||
|
import {
|
||||||
|
boardMemberFields,
|
||||||
|
boardMemberOperations,
|
||||||
|
} from './BoardMemberDescription';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cardFields,
|
cardFields,
|
||||||
cardOperations,
|
cardOperations,
|
||||||
|
@ -84,6 +89,10 @@ export class Trello implements INodeType {
|
||||||
name: 'Board',
|
name: 'Board',
|
||||||
value: 'board',
|
value: 'board',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Board Member',
|
||||||
|
value: 'boardMember',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Card',
|
name: 'Card',
|
||||||
value: 'card',
|
value: 'card',
|
||||||
|
@ -114,6 +123,7 @@ export class Trello implements INodeType {
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
...attachmentOperations,
|
...attachmentOperations,
|
||||||
...boardOperations,
|
...boardOperations,
|
||||||
|
...boardMemberOperations,
|
||||||
...cardOperations,
|
...cardOperations,
|
||||||
...cardCommentOperations,
|
...cardCommentOperations,
|
||||||
...checklistOperations,
|
...checklistOperations,
|
||||||
|
@ -125,6 +135,7 @@ export class Trello implements INodeType {
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
...attachmentFields,
|
...attachmentFields,
|
||||||
...boardFields,
|
...boardFields,
|
||||||
|
...boardMemberFields,
|
||||||
...cardFields,
|
...cardFields,
|
||||||
...cardCommentFields,
|
...cardCommentFields,
|
||||||
...checklistFields,
|
...checklistFields,
|
||||||
|
@ -216,7 +227,68 @@ export class Trello implements INodeType {
|
||||||
} else {
|
} else {
|
||||||
throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not known!`);
|
throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not known!`);
|
||||||
}
|
}
|
||||||
|
} else if (resource === 'boardMember') {
|
||||||
|
if (operation === 'getAll') {
|
||||||
|
// ----------------------------------
|
||||||
|
// getAll
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
requestMethod = 'GET';
|
||||||
|
|
||||||
|
const id = this.getNodeParameter('id', i) as string;
|
||||||
|
returnAll = this.getNodeParameter('returnAll', i) as boolean;
|
||||||
|
if (returnAll === false) {
|
||||||
|
qs.limit = this.getNodeParameter('limit', i) as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = `boards/${id}/members`;
|
||||||
|
|
||||||
|
} else if (operation === 'add') {
|
||||||
|
// ----------------------------------
|
||||||
|
// add
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
requestMethod = 'PUT';
|
||||||
|
|
||||||
|
const id = this.getNodeParameter('id', i) as string;
|
||||||
|
const idMember = this.getNodeParameter('idMember', i) as string;
|
||||||
|
|
||||||
|
endpoint = `boards/${id}/members/${idMember}`;
|
||||||
|
|
||||||
|
qs.type = this.getNodeParameter('type', i) as string;
|
||||||
|
qs.allowBillableGuest = this.getNodeParameter('additionalFields.allowBillableGuest', i, false) as boolean;
|
||||||
|
|
||||||
|
} else if (operation === 'invite') {
|
||||||
|
// ----------------------------------
|
||||||
|
// invite
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
requestMethod = 'PUT';
|
||||||
|
|
||||||
|
const id = this.getNodeParameter('id', i) as string;
|
||||||
|
|
||||||
|
endpoint = `boards/${id}/members`;
|
||||||
|
|
||||||
|
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
|
||||||
|
|
||||||
|
qs.email = this.getNodeParameter('email', i) as string;
|
||||||
|
qs.type = additionalFields.type as string;
|
||||||
|
body.fullName = additionalFields.fullName as string;
|
||||||
|
} else if (operation === 'remove') {
|
||||||
|
// ----------------------------------
|
||||||
|
// remove
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
requestMethod = 'DELETE';
|
||||||
|
|
||||||
|
const id = this.getNodeParameter('id', i) as string;
|
||||||
|
const idMember = this.getNodeParameter('idMember', i) as string;
|
||||||
|
|
||||||
|
endpoint = `boards/${id}/members/${idMember}`;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not known!`);
|
||||||
|
}
|
||||||
} else if (resource === 'card') {
|
} else if (resource === 'card') {
|
||||||
|
|
||||||
if (operation === 'create') {
|
if (operation === 'create') {
|
||||||
|
|
|
@ -159,10 +159,10 @@ export class TrelloTrigger implements INodeType {
|
||||||
|
|
||||||
const bodyData = this.getBodyData();
|
const bodyData = this.getBodyData();
|
||||||
|
|
||||||
const credentials = await this.getCredentials('trelloApi');
|
|
||||||
|
|
||||||
// TODO: Check why that does not work as expected even though it gets done as described
|
// TODO: Check why that does not work as expected even though it gets done as described
|
||||||
// https://developers.trello.com/page/webhooks
|
// https://developers.trello.com/page/webhooks
|
||||||
|
|
||||||
|
//const credentials = await this.getCredentials('trelloApi');
|
||||||
// // Check if the request is valid
|
// // Check if the request is valid
|
||||||
// const headerData = this.getHeaderData() as IDataObject;
|
// const headerData = this.getHeaderData() as IDataObject;
|
||||||
// const webhookUrl = this.getNodeWebhookUrl('default');
|
// const webhookUrl = this.getNodeWebhookUrl('default');
|
||||||
|
|
Loading…
Reference in a new issue