PLT-6965 jira integration (plus plugin scaffolding) (#6918)

* plugin scaffolding / jira integration

* add vendored testify packages

* webhook fix

* don't change i18n ids

* support configuration watching

* add basic jira plugin configuration to admin console

* fix eslint errors

* fix another eslint warning

* polish

* undo unintentional config.json commit >:(

* test fix

* add jira plugin diagnostics, remove dm support, add bot tag, generate web-safe secrets

* rebase, implement requested changes

* requested changes

* remove tests and minimize makefile change

* add missing license headers

* add missing comma

* remove bad line from Makefile
Этот коммит содержится в:
Chris
2017-08-02 01:36:54 -07:00
коммит произвёл GitHub
родитель c6bf235ec2
Коммит 65817e13c7
24 изменённых файлов: 891 добавлений и 33 удалений

Просмотреть файл

@@ -522,6 +522,15 @@ export default class AdminSidebar extends React.Component {
/>
}
/>
<AdminSidebarSection
name='jira'
title={
<FormattedMessage
id='admin.sidebar.jira'
defaultMessage='JIRA (Beta)'
/>
}
/>
{webrtcSettings}
<AdminSidebarSection
name='external'

Просмотреть файл

@@ -20,7 +20,8 @@ export default class GeneratedSetting extends React.Component {
disabled: PropTypes.bool.isRequired,
disabledText: PropTypes.node,
helpText: PropTypes.node.isRequired,
regenerateText: PropTypes.node
regenerateText: PropTypes.node,
regenerateHelpText: PropTypes.node
};
}
@@ -58,6 +59,15 @@ export default class GeneratedSetting extends React.Component {
);
}
let regenerateHelpText = null;
if (this.props.regenerateHelpText) {
regenerateHelpText = (
<div className='help-text'>
{this.props.regenerateHelpText}
</div>
);
}
return (
<div className='form-group'>
<label
@@ -88,6 +98,7 @@ export default class GeneratedSetting extends React.Component {
{this.props.regenerateText}
</button>
</div>
{regenerateHelpText}
</div>
</div>
);

Просмотреть файл

@@ -80,7 +80,12 @@ export default class SuggestionBox extends React.Component {
/**
* Function called when @mention is clicked
*/
popoverMentionKeyClick: PropTypes.bool
popoverMentionKeyClick: PropTypes.bool,
/**
* The number of characters required to show the suggestion list, defaults to 1
*/
requiredCharacters: PropTypes.number
}
static defaultProps = {
@@ -88,13 +93,15 @@ export default class SuggestionBox extends React.Component {
listStyle: 'top',
renderDividers: false,
completeOnTab: true,
isRHS: false
isRHS: false,
requiredCharacters: 1
}
constructor(props) {
super(props);
this.handleBlur = this.handleBlur.bind(this);
this.handleFocus = this.handleFocus.bind(this);
this.handlePopoverMentionKeyClick = this.handlePopoverMentionKeyClick.bind(this);
this.handleCompleteWord = this.handleCompleteWord.bind(this);
@@ -163,11 +170,22 @@ export default class SuggestionBox extends React.Component {
}
}
handleFocus() {
setTimeout(() => {
const textbox = this.getTextbox();
const pretext = textbox.value.substring(0, textbox.selectionEnd);
if (pretext.length >= this.props.requiredCharacters) {
GlobalActions.emitSuggestionPretextChanged(this.suggestionId, pretext);
}
});
}
handleChange(e) {
const textbox = this.getTextbox();
const pretext = textbox.value.substring(0, textbox.selectionEnd);
if (!this.composing && SuggestionStore.getPretext(this.suggestionId) !== pretext) {
if (!this.composing && SuggestionStore.getPretext(this.suggestionId) !== pretext && pretext.length >= this.props.requiredCharacters) {
GlobalActions.emitSuggestionPretextChanged(this.suggestionId, pretext);
}
@@ -228,7 +246,8 @@ export default class SuggestionBox extends React.Component {
const suffix = text.substring(caret);
this.refs.textbox.value = prefix + term + ' ' + suffix;
const newValue = prefix + term + ' ' + suffix;
this.refs.textbox.value = newValue;
if (this.props.onChange) {
// fake an input event to send back to parent components
@@ -242,9 +261,10 @@ export default class SuggestionBox extends React.Component {
if (this.props.onItemSelected) {
const items = SuggestionStore.getItems(this.suggestionId);
for (const i of items) {
if (i.name === term) {
this.props.onItemSelected(i);
const terms = SuggestionStore.getTerms(this.suggestionId);
for (let i = 0; i < terms.length; i++) {
if (terms[i] === term) {
this.props.onItemSelected(items[i]);
break;
}
}
@@ -254,7 +274,9 @@ export default class SuggestionBox extends React.Component {
// set the caret position after the next rendering
window.requestAnimationFrame(() => {
Utils.setCaretPosition(textbox, prefix.length + term.length + 1);
if (textbox.value === newValue) {
Utils.setCaretPosition(textbox, newValue.length);
}
});
for (const provider of this.props.providers) {
@@ -277,7 +299,9 @@ export default class SuggestionBox extends React.Component {
e.preventDefault();
} else if (e.which === KeyCodes.ENTER || (this.props.completeOnTab && e.which === KeyCodes.TAB)) {
this.handleCompleteWord(SuggestionStore.getSelection(this.suggestionId), SuggestionStore.getSelectedMatchedPretext(this.suggestionId));
this.props.onKeyDown(e);
if (this.props.onKeyDown) {
this.props.onKeyDown(e);
}
e.preventDefault();
} else if (e.which === KeyCodes.ESCAPE) {
GlobalActions.emitClearSuggestions(this.suggestionId);
@@ -321,11 +345,14 @@ export default class SuggestionBox extends React.Component {
Reflect.deleteProperty(props, 'completeOnTab');
Reflect.deleteProperty(props, 'isRHS');
Reflect.deleteProperty(props, 'popoverMentionKeyClick');
Reflect.deleteProperty(props, 'requiredCharacters');
const childProps = {
ref: 'textbox',
onBlur: this.handleBlur,
onFocus: this.handleFocus,
onInput: this.handleChange,
onChange() { /* this is only here to suppress warnings about onChange not being implemented for read-write inputs */ },
onCompositionStart: this.handleCompositionStart,
onCompositionUpdate: this.handleCompositionUpdate,
onCompositionEnd: this.handleCompositionEnd,
@@ -337,6 +364,7 @@ export default class SuggestionBox extends React.Component {
textbox = (
<input
type='text'
autoComplete='off'
{...props}
{...childProps}
/>
@@ -345,6 +373,7 @@ export default class SuggestionBox extends React.Component {
textbox = (
<input
type='search'
autoComplete='off'
{...props}
{...childProps}
/>
@@ -364,7 +393,7 @@ export default class SuggestionBox extends React.Component {
return (
<div ref='container'>
{textbox}
{this.props.value &&
{this.props.value.length >= this.props.requiredCharacters &&
<SuggestionListComponent
suggestionId={this.suggestionId}
location={listStyle}

Просмотреть файл

@@ -481,6 +481,7 @@
"admin.integrations.custom": "Custom Integrations",
"admin.integrations.external": "External Services",
"admin.integrations.webrtc": "Mattermost WebRTC",
"admin.plugins.jira": "JIRA (Beta)",
"admin.ldap.baseDesc": "The Base DN is the Distinguished Name of the location where Mattermost should start its search for users in the AD/LDAP tree.",
"admin.ldap.baseEx": "E.g.: \"ou=Unit Name,dc=corp,dc=example,dc=com\"",
"admin.ldap.baseTitle": "BaseDN:",
@@ -626,6 +627,18 @@
"admin.password.requirementsDescription": "Character types required in a valid password.",
"admin.password.symbol": "At least one symbol (e.g. \"~!@#$%^&*()\")",
"admin.password.uppercase": "At least one uppercase letter",
"admin.plugins.jira.enabledLabel": "Enabled:",
"admin.plugins.jira.enabledDescription": "When true, you can configure JIRA webhooks to post message in Mattermost. To help combat phishing attacks, all posts are labelled by a BOT tag.",
"admin.plugins.jira.secretLabel": "Secret:",
"admin.plugins.jira.secretDescription": "This secret is used to authenticate to Mattermost.",
"admin.plugins.jira.secretRegenerateDescription": "Regenerates the secret for the webhook URL endpoint. Regenerating the secret invalidates your existing JIRA integrations.",
"admin.plugins.jira.userLabel": "User:",
"admin.plugins.jira.userDescription": "Select the username that this integration is attached to.",
"admin.plugins.jira.setupDescription": "Use this webhook URL to set up the JIRA integration. See {webhookDocsLink} to learn more.",
"admin.plugins.jira.webhookDocsLink": "documentation",
"admin.plugins.jira.secretParamPlaceholder": "secret",
"admin.plugins.jira.teamParamPlaceholder": "teamname",
"admin.plugins.jira.channelParamNamePlaceholder": "channelname",
"admin.privacy.showEmailDescription": "When false, hides the email address of members from everyone except System Administrators.",
"admin.privacy.showEmailTitle": "Show Email Address: ",
"admin.privacy.showFullNameDescription": "When false, hides the full name of members from everyone except System Administrators. Username is shown in place of full name.",
@@ -832,6 +845,7 @@
"admin.sidebar.general": "General",
"admin.sidebar.gitlab": "GitLab",
"admin.sidebar.integrations": "Integrations",
"admin.sidebar.jira": "JIRA (Beta)",
"admin.sidebar.ldap": "AD/LDAP",
"admin.sidebar.legalAndSupport": "Legal and Support",
"admin.sidebar.license": "Edition and License",

251
webapp/plugins/jira/components/settings.jsx Обычный файл
Просмотреть файл

@@ -0,0 +1,251 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import crypto from 'crypto';
import Suggestion from 'components/suggestion/suggestion.jsx';
import Provider from 'components/suggestion/provider.jsx';
import SuggestionBox from 'components/suggestion/suggestion_box.jsx';
import SuggestionList from 'components/suggestion/suggestion_list.jsx';
import {autocompleteUsersInTeam} from 'actions/user_actions.jsx';
import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
import {Client4} from 'mattermost-redux/client';
import {ActionTypes} from 'utils/constants.jsx';
import * as Utils from 'utils/utils.jsx';
import AdminSettings from 'components/admin_console/admin_settings.jsx';
import {FormattedMessage} from 'react-intl';
import SettingsGroup from 'components/admin_console/settings_group.jsx';
import BooleanSetting from 'components/admin_console/boolean_setting.jsx';
import GeneratedSetting from 'components/admin_console/generated_setting.jsx';
import Setting from 'components/admin_console/setting.jsx';
import './style.scss';
class UserSuggestion extends Suggestion {
render() {
const {item, isSelection} = this.props;
let className = 'jirabots__name';
if (isSelection) {
className += ' suggestion--selected';
}
const username = item.username;
let description = '';
if ((item.first_name || item.last_name) && item.nickname) {
description = `- ${Utils.getFullName(item)} (${item.nickname})`;
} else if (item.nickname) {
description = `- (${item.nickname})`;
} else if (item.first_name || item.last_name) {
description = `- ${Utils.getFullName(item)}`;
}
return (
<div
className={className}
onClick={this.handleClick}
>
<div className='pull-left'>
<img
className='jirabot__image'
src={Client4.getUsersRoute() + '/' + item.id + '/image?_=' + (item.last_picture_update || 0)}
/>
</div>
<div className='pull-left jirabot--align'>
<span>
{'@' + username}
</span>
<span className='jirabot__fullname'>
{' '}
{description}
</span>
</div>
</div>
);
}
}
class UserProvider extends Provider {
handlePretextChanged(suggestionId, pretext) {
const normalizedPretext = pretext.toLowerCase();
this.startNewRequest(suggestionId, normalizedPretext);
autocompleteUsersInTeam(
normalizedPretext,
(data) => {
if (this.shouldCancelDispatch(normalizedPretext)) {
return;
}
const users = Object.assign([], data.users);
AppDispatcher.handleServerAction({
type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS,
id: suggestionId,
matchedPretext: normalizedPretext,
terms: users.map((user) => user.username),
items: users,
component: UserSuggestion
});
}
);
return true;
}
}
export default class JIRASettings extends AdminSettings {
constructor(props) {
super(props);
this.getConfigFromState = this.getConfigFromState.bind(this);
this.renderSettings = this.renderSettings.bind(this);
this.handleSecretChange = this.handleSecretChange.bind(this);
this.handleEnabledChange = this.handleEnabledChange.bind(this);
this.handleUserSelected = this.handleUserSelected.bind(this);
this.userSuggestionProviders = [new UserProvider()];
}
getConfigFromState(config) {
config.PluginSettings.Plugins = {
jira: {
Enabled: this.state.enabled,
Secret: this.state.secret,
UserName: this.state.userName
}
};
return config;
}
getStateFromConfig(config) {
const settings = config.PluginSettings;
const ret = {
enabled: false,
secret: '',
userName: '',
siteURL: config.ServiceSettings.SiteURL
};
if (typeof settings.Plugins !== 'undefined' && typeof settings.Plugins.jira !== 'undefined') {
ret.enabled = settings.Plugins.jira.Enabled || settings.Plugins.jira.enabled || false;
ret.secret = settings.Plugins.jira.Secret || settings.Plugins.jira.secret || '';
ret.userName = settings.Plugins.jira.UserName || settings.Plugins.jira.username || '';
}
return ret;
}
handleSecretChange(id, secret) {
this.handleChange(id, secret.replace('+', '-').replace('/', '_'));
}
handleEnabledChange(enabled) {
if (enabled && this.state.secret === '') {
this.handleSecretChange('secret', crypto.randomBytes(256).toString('base64').substring(0, 32));
}
this.handleChange('enabled', enabled);
}
handleUserSelected(user) {
this.handleChange('userName', user.username);
}
renderTitle() {
return Utils.localizeMessage('admin.plugins.jira', 'JIRA (Beta)');
}
renderSettings() {
var webhookDocsLink = (
<a
href='https://about.mattermost.com/default-jira-plugin'
target='_blank'
rel='noopener noreferrer'
>
<FormattedMessage
id='admin.plugins.jira.webhookDocsLink'
defaultMessage='documentation'
/>
</a>
);
return (
<SettingsGroup>
<BooleanSetting
id='enabled'
label={Utils.localizeMessage('admin.plugins.jira.enabledLabel', 'Enabled:')}
helpText={Utils.localizeMessage('admin.plugins.jira.enabledDescription', 'When true, you can configure JIRA webhooks to post message in Mattermost. To help combat phishing attacks, all posts are labelled by a BOT tag.')}
value={this.state.enabled}
onChange={(id, value) => this.handleEnabledChange(value)}
/>
<Setting
label={Utils.localizeMessage('admin.plugins.jira.userLabel', 'User:')}
helpText={Utils.localizeMessage('admin.plugins.jira.userDescription', 'Select the username that this integration is attached to.')}
inputId='userName'
>
<div
className='jirabots__dropdown'
>
<SuggestionBox
id='userName'
className='form-control'
placeholder={Utils.localizeMessage('search_bar.search', 'Search')}
value={this.state.userName}
onChange={(e) => this.handleChange('userName', e.target.value)}
onItemSelected={this.handleUserSelected}
listComponent={SuggestionList}
listStyle='bottom'
providers={this.userSuggestionProviders}
disabled={!this.state.enabled}
type='input'
requiredCharacters={0}
/>
</div>
</Setting>
<GeneratedSetting
id='secret'
label={Utils.localizeMessage('admin.plugins.jira.secretLabel', 'Secret:')}
helpText={Utils.localizeMessage('admin.plugins.jira.secretDescription', 'This secret is used to authenticate to Mattermost.')}
regenerateHelpText={Utils.localizeMessage('admin.plugins.jira.secretRegenerateDescription', 'Regenerates the secret for the webhook URL endpoint. Regenerating the secret invalidates your existing JIRA integrations.')}
value={this.state.secret}
onChange={this.handleSecretChange}
disabled={!this.state.enabled}
/>
<div className='banner'>
<div className='banner__content'>
<p>
<FormattedMessage
id='admin.plugins.jira.setupDescription'
defaultMessage='Use this webhook URL to set up the JIRA integration. See {webhookDocsLink} to learn more.'
values={{
webhookDocsLink
}}
/>
</p>
<p>
<code
dangerouslySetInnerHTML={{
__html: encodeURI(this.state.siteURL) +
'/plugins/jira/webhook?secret=' +
(this.state.secret ? encodeURIComponent(this.state.secret) : ('<b>' + Utils.localizeMessage('admin.plugins.jira.secretParamPlaceholder', 'secret') + '</b>')) +
'&team=<b>' +
Utils.localizeMessage('admin.plugins.jira.teamParamPlaceholder', 'teamname') +
'</b>&channel=<b>' +
Utils.localizeMessage('admin.plugins.jira.channelParamNamePlaceholder', 'channelname') +
'</b>'
}}
/>
</p>
</div>
</div>
</SettingsGroup>
);
}
}

60
webapp/plugins/jira/components/style.scss Обычный файл
Просмотреть файл

@@ -0,0 +1,60 @@
@charset 'UTF-8';
@import 'compass/utilities';
@import 'compass/css3';
.jirabots__dropdown {
position: relative;
}
.jirabots__dropdown::before {
position: absolute;
top: 13px;
right: 8px;
content: " ";
pointer-events: none;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #e2e2e2;
}
.jirabots__name {
@include clearfix;
cursor: pointer;
font-size: 13px;
line-height: 20px;
margin: 0;
padding: 6px 10px;
position: relative;
white-space: nowrap;
width: 100%;
z-index: 101;
}
.jirabot__image {
@include border-radius(20px);
display: block;
font-size: 15px;
height: 16px;
line-height: 16px;
margin-right: 7px;
margin-top: 3px;
text-align: center;
width: 16px;
.jirabot--align {
display: inline-block;
max-width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.jirabot__fullname {
@include opacity(.5);
}

Просмотреть файл

@@ -46,6 +46,7 @@ import LicenseSettings from 'components/admin_console/license_settings.jsx';
import Audits from 'components/admin_console/audits';
import Logs from 'components/admin_console/server_logs';
import ElasticsearchSettings from 'components/admin_console/elasticsearch_settings.jsx';
import JIRASettings from 'plugins/jira/components/settings.jsx';
export default (
<Route>
@@ -159,6 +160,10 @@ export default (
path='webrtc'
component={WebrtcSettings}
/>
<Route
path='jira'
component={JIRASettings}
/>
</Route>
<Route path='files'>
<IndexRedirect to='storage'/>

Просмотреть файл

@@ -305,6 +305,10 @@
.status-icon-error {
color: #ea6262;
}
.suggestion--selected {
background-color: #e2e2e2;
}
}
.brand-img {