diff --git a/config/default.yml b/config/default.yml index 2724107..e882568 100644 --- a/config/default.yml +++ b/config/default.yml @@ -25,6 +25,25 @@ projects: - REALMS - WEB +fieldShortcuts: + category: cf[11901] + confirmation: Confirmation Status + gamemode: Game Mode + mp: Mojang Priority + version: affectedVersion + +containsSearchSyntax: + - ado + - comment + - description + - environment + - summary + - text + +prebuiltClauses: + commenter: issueFunction in commented("by $?") + transitionedby: status changed by $! + request: invalidTicketEmoji: ⏸ noLinkEmoji: ⛔ diff --git a/config/template.yml b/config/template.yml index 7b15a99..054c390 100644 --- a/config/template.yml +++ b/config/template.yml @@ -49,6 +49,32 @@ projects: - - ... +# Key-value pairs of field names that should be replaced when using search shortcuts. +# The key should contain the search shortcut to be used. +# The value should contain the valid JQL name for the field. +fieldShortcuts: + : + : + : + # ... + +# Jira field names that use the "contains" JQL operator (~), rather than the "equals" operator (=). +containsSearchSyntax: + - + - + - + +# Key-value pairs of prepared JQL clauses that are filled with the search argument. +# The key should contain the search shortcut to be used. +# The value should contain the prepared clauses, with a dollar sign where the argument should be inserted. +# The dollar sign should be followed by a question mark if quotes within the argument should be escaped. +# The dollar sign should be followed by an exclamation point if quotes within the argument should not be escaped. +prebuiltClauses: + : + : + : + # ... + # Settings about channels that handle user requests. request: # The IDs of the server's request channels. diff --git a/src/BotConfig.ts b/src/BotConfig.ts index 938f424..7fa6ff4 100644 --- a/src/BotConfig.ts +++ b/src/BotConfig.ts @@ -122,6 +122,10 @@ export default class BotConfig { public static projects: string[]; + public static fieldShortcuts: Map; + public static containsSearchSyntax: string[]; + public static prebuiltClauses: Map; + public static request: RequestConfig; public static roleGroups: RoleGroupConfig[]; @@ -149,6 +153,10 @@ export default class BotConfig { this.projects = config.get( 'projects' ); + this.fieldShortcuts = new Map( Object.entries( config.get( 'fieldShortcuts' ) ) ); + this.containsSearchSyntax = config.get( 'containsSearchSyntax' ); + this.prebuiltClauses = new Map( Object.entries( config.get( 'prebuiltClauses' ) ) ); + this.request = new RequestConfig(); this.roleGroups = getOrDefault( 'roleGroups', [] ); diff --git a/src/commands/HelpCommand.ts b/src/commands/HelpCommand.ts index 42fc167..e21e640 100644 --- a/src/commands/HelpCommand.ts +++ b/src/commands/HelpCommand.ts @@ -24,6 +24,10 @@ export default class HelpCommand extends PrefixCommand { \`!jira ping\` - Sends a message to check if the bot is running. \`!jira search \` - Searches for text and returns the results from the bug tracker. + + \`!jira search :jql \` - Searches using the content of a JQL query and returns the results. + + \`!jira search _ \` - Searches a field for specific content and returns the results. These arguments can be used multiple times for multiple fields. \`!jira tips\` - Sends helpful info on how to use the bug tracker and this Discord server.` ) diff --git a/src/commands/SearchCommand.ts b/src/commands/SearchCommand.ts index 1eb0f15..1a72d82 100644 --- a/src/commands/SearchCommand.ts +++ b/src/commands/SearchCommand.ts @@ -1,4 +1,4 @@ -import { Message, MessageEmbed, Util } from 'discord.js'; +import { Message, MessageEmbed } from 'discord.js'; import PrefixCommand from './PrefixCommand'; import BotConfig from '../BotConfig'; import MojiraBot from '../MojiraBot'; @@ -11,24 +11,92 @@ export default class SearchCommand extends PrefixCommand { return false; } - const plainArgs = args.replace( /"|<|>/g, '' ); + const plainArgs = args.replace( /[<>!]/g, '' ); + let searchFilter: string; + if ( plainArgs.includes( ':jql' ) ) { + searchFilter = plainArgs.split( ':jql' ).slice( 1 ).join( ' ' ).trim(); + } else { + const modifierRegex = new RegExp( /(^|\s)(_|-)[a-z]+\s(([a-zA-Z0-9_]+)|(["'][^"']+["']))/, 'g' ); + const modifiers = plainArgs.match( modifierRegex ); + const textArgs = plainArgs.replace( modifierRegex, '' ).trim(); + const modifierStrings = []; + + if ( modifiers ) { + for ( const spacedModifier of modifiers ) { + const modifier = spacedModifier.trim(); + const operation = modifier.charAt( 0 ); + let field = modifier.split( /\s/g )[0].substring( 1 ); + const value = modifier.split( /\s/g ).slice( 1 ).join( ' ' ); + + BotConfig.fieldShortcuts.forEach( ( replaced: string, original: string ) => { + const fieldRegex = new RegExp( original, 'g' ); + const quoteChar = replaced.split( /\s/g ).length > 1 ? '"' : ''; + const quotedReplaced = `${ quoteChar }${ replaced }${ quoteChar }`; + field = field.replace( fieldRegex, quotedReplaced ); + } ); + + let forcedPush = ''; + BotConfig.prebuiltClauses.forEach( ( fun: string, original: string ) => { + if ( field == original ) { + const quotedReplaced = value.replace( /["]/g, '\\$&' ); + const filledFunction = fun + .replace( /\$\?/g, quotedReplaced ) + .replace( /\$!/g, value ); + forcedPush = filledFunction; + } + } ); + + if ( forcedPush.length > 0 ) { + if ( operation == '-' ) forcedPush = `NOT (${ forcedPush })`; + modifierStrings.push( forcedPush ); + continue; + } + + let clause: string; + if ( value.toUpperCase() == 'EMPTY' ) { + clause = `${ field } is ${ operation == '-' ? 'NOT ' : '' }EMPTY`; + } else { + clause = `${ field } ${ operation == '-' ? '!' : '' }${ BotConfig.containsSearchSyntax.includes( field ) ? '~' : '=' } ${ value }`; + } + + modifierStrings.push( clause ); + } + } else { + modifierStrings.push( `text ~ "${ textArgs }"` ); + } + + searchFilter = modifierStrings.join( ' AND ' ); + + if ( !searchFilter.toLowerCase().includes( 'text ~ ' ) && textArgs.length > 0 ) { + searchFilter += ` AND text ~ "${ textArgs.replace( /["']/g, '\\&' ) }"`; + } + if ( !searchFilter.toUpperCase().includes( ' ORDER BY ' ) ) { + searchFilter += ' ORDER BY created, updated DESC'; + } + } + try { - const embed = new MessageEmbed(); - const searchFilter = `text ~ "${ plainArgs }" AND project in (${ BotConfig.projects.join( ', ' ) })`; + const embed = new MessageEmbed() + .setColor( 'BLUE' ); + const searchResults = await MojiraBot.jira.issueSearch.searchForIssuesUsingJql( { jql: searchFilter, maxResults: BotConfig.maxSearchResults, fields: [ 'key', 'summary' ], } ); - if ( !searchResults.issues ) { - embed.setTitle( `No results found for "${ Util.escapeMarkdown( plainArgs ) }"` ); + if ( searchFilter.length > 0 ) { + embed.addField( 'JQL query', `\`\`\`${ searchFilter.replace( /```/g, '` ` `' ) }\`\`\``, false ); + } + + if ( !searchResults.issues || searchResults.issues.length == 0 ) { + embed.setTitle( 'No results found' ); await message.channel.send( { embeds: [embed] } ); return false; } - embed.setTitle( '**Results:**' ); + embed.setTitle( `${ searchResults.total } result${ searchResults.total != 1 ? 's' : '' }` ); embed.setFooter( { text: message.author.tag, iconURL: message.author.avatarURL() } ); for ( const issue of searchResults.issues ) { @@ -36,12 +104,14 @@ export default class SearchCommand extends PrefixCommand { } const escapedJql = encodeURIComponent( searchFilter ).replace( /\(/g, '%28' ).replace( /\)/g, '%29' ); - embed.setDescription( `__[See all results](https://bugs.mojang.com/issues/?jql=${ escapedJql })__` ); + embed.setDescription( `[See all results](https://bugs.mojang.com/issues/?jql=${ escapedJql })` ); await message.channel.send( { embeds: [embed] } ); } catch { - const embed = new MessageEmbed(); - embed.setTitle( `No results found for "${ Util.escapeMarkdown( plainArgs ) }"` ); + const embed = new MessageEmbed() + .setTitle( 'Failed to search issues' ) + .setColor( 'RED' ) + .addField( 'JQL query', `\`\`\`${ searchFilter.replace( /```/g, '` ` `' ) }\`\`\`` ); await message.channel.send( { embeds: [embed] } ); return false; }