Skip to content

Commit

Permalink
[MM-15411] /jira create command (#51)
Browse files Browse the repository at this point in the history
  • Loading branch information
cpoile authored and crspeller committed May 23, 2019
1 parent 7db8aa9 commit e9e58a6
Show file tree
Hide file tree
Showing 10 changed files with 108 additions and 41 deletions.
3 changes: 2 additions & 1 deletion server/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
const helpText = "###### Mattermost Jira Plugin - Slash Command Help\n" +
"* `/jira connect` - Connect your Mattermost account to your Jira account and subscribe to events\n" +
"* `/jira disconnect` - Disonnect your Mattermost account from your Jira account\n" +
"* `/jira create <text (optional)>` - Create a new Issue with 'text' inserted into the description field.\n" +
"* `/jira transition <issue-key> <state>` - Changes the state of a Jira issue.\n" +
"\nFor system administrators:\n" +
"* `/jira install cloud <URL>` - connect Mattermost to a cloud Jira instance located at <URL>\n" +
Expand Down Expand Up @@ -279,7 +280,7 @@ func getCommand() *model.Command {
DisplayName: "Jira",
Description: "Integration with Jira.",
AutoComplete: true,
AutoCompleteDesc: "Available commands: connect, disconnect, transition, install cloud, install server, help",
AutoCompleteDesc: "Available commands: connect, disconnect, create, transition, install cloud, install server, help",
AutoCompleteHint: "[command]",
}
}
Expand Down
71 changes: 42 additions & 29 deletions server/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in
api := ji.GetPlugin().API

create := &struct {
PostId string `json:"post_id"`
Fields jira.IssueFields `json:"fields"`
PostId string `json:"post_id"`
ChannelId string `json:"channel_id"`
Fields jira.IssueFields `json:"fields"`
}{}
err := json.NewDecoder(r.Body).Decode(&create)
if err != nil {
Expand All @@ -50,34 +51,47 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in
return http.StatusInternalServerError, err
}

// Lets add a permalink to the post in the Jira Description
post, appErr := api.GetPost(create.PostId)
if appErr != nil {
return http.StatusInternalServerError,
errors.WithMessage(appErr, "failed to load post "+create.PostId)
}
if post == nil {
return http.StatusInternalServerError,
errors.New("failed to load post " + create.PostId + ": not found")
}
var post *model.Post
var appErr *model.AppError

permalink, err := getPermaLink(ji, create.PostId, post)
if err != nil {
return http.StatusInternalServerError,
errors.New("failed to get permalink for " + create.PostId + ": not found")
}
// If this issue is attached to a post, lets add a permalink to the post in the Jira Description
if create.PostId != "" {
post, appErr = api.GetPost(create.PostId)
if appErr != nil {
return http.StatusInternalServerError,
errors.WithMessage(appErr, "failed to load post "+create.PostId)
}
if post == nil {
return http.StatusInternalServerError,
errors.New("failed to load post " + create.PostId + ": not found")
}
permalink, err2 := getPermaLink(ji, create.PostId, post)
if err2 != nil {
return http.StatusInternalServerError,
errors.New("failed to get permalink for " + create.PostId + ": not found")
}

if len(create.Fields.Description) > 0 {
create.Fields.Description += fmt.Sprintf("\n%v", permalink)
} else {
create.Fields.Description = permalink
if len(create.Fields.Description) > 0 {
create.Fields.Description += fmt.Sprintf("\n%v", permalink)
} else {
create.Fields.Description = permalink
}
}

created, resp, err := jiraClient.Issue.Create(&jira.Issue{
Fields: &create.Fields,
})

// For now, if we are not attaching to a post, leave postId blank (this will only affect the error message)
postId := ""
channelId := create.ChannelId
if post != nil {
channelId = post.ChannelId
postId = create.PostId
}

if err != nil {
message := "failed to create the issue, postId: " + create.PostId
message := "failed to create the issue, postId: " + create.PostId + ", channelId: " + channelId
if resp != nil {
bb, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()
Expand All @@ -87,7 +101,7 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in
}

// Upload file attachments in the background
if len(post.FileIds) > 0 {
if post != nil && len(post.FileIds) > 0 {
go func() {
for _, fileId := range post.FileIds {
info, ae := api.GetFileInfo(fileId)
Expand All @@ -107,7 +121,6 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in
api.LogError("failed to attach file to issue: "+e.Error(), "file", info.Path, "issue", created.Key)
return
}

}
}()
}
Expand All @@ -116,26 +129,26 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in
reply := &model.Post{
// TODO: Why is this not created.Self?
Message: fmt.Sprintf("Created a Jira issue %v/browse/%v", ji.GetURL(), created.Key),
ChannelId: post.ChannelId,
RootId: create.PostId,
ChannelId: channelId,
RootId: postId,
UserId: mattermostUserId,
}
_, appErr = api.CreatePost(reply)
if appErr != nil {
return http.StatusInternalServerError,
errors.WithMessage(appErr, "failed to create notification post "+create.PostId)
errors.WithMessage(appErr, "failed to create notification post, postId: "+postId+", channelId: "+channelId)
}

userBytes, err := json.Marshal(created)
if err != nil {
return http.StatusInternalServerError,
errors.WithMessage(err, "failed to marshal response "+create.PostId)
errors.WithMessage(err, "failed to marshal response, postId: "+postId+", channelId: "+channelId)
}
w.Header().Set("Content-Type", "application/json")
_, err = w.Write(userBytes)
if err != nil {
return http.StatusInternalServerError,
errors.WithMessage(err, "failed to write response "+create.PostId)
errors.WithMessage(err, "failed to write response, postId: "+postId+", channelId: "+channelId)
}
return http.StatusOK, nil
}
Expand Down
1 change: 1 addition & 0 deletions webapp/src/action_types/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import PluginId from 'plugin_id';
export default {
CLOSE_CREATE_ISSUE_MODAL: `${PluginId}_close_create_modal`,
OPEN_CREATE_ISSUE_MODAL: `${PluginId}_open_create_modal`,
OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST: `${PluginId}_open_create_modal_without_post`,

CLOSE_ATTACH_COMMENT_TO_ISSUE_MODAL: `${PluginId}_close_attach_modal`,
OPEN_ATTACH_COMMENT_TO_ISSUE_MODAL: `${PluginId}_open_attach_modal`,
Expand Down
8 changes: 8 additions & 0 deletions webapp/src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export const openCreateModal = (postId) => {
};
};

export const openCreateModalWithoutPost = (description, channelId) => (dispatch) => dispatch({
type: ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST,
data: {
description,
channelId,
},
});

export const closeCreateModal = () => {
return {
type: ActionTypes.CLOSE_CREATE_ISSUE_MODAL,
Expand Down
18 changes: 15 additions & 3 deletions webapp/src/components/modals/create_issue/create_issue.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export default class CreateIssueModal extends PureComponent {
close: PropTypes.func.isRequired,
create: PropTypes.func.isRequired,
post: PropTypes.object,
description: PropTypes.string,
channelId: PropTypes.string,
theme: PropTypes.object.isRequired,
visible: PropTypes.bool.isRequired,
jiraIssueMetadata: PropTypes.object,
Expand All @@ -51,6 +53,11 @@ export default class CreateIssueModal extends PureComponent {
const fields = {...this.state.fields};
fields.description = this.props.post.message;
this.setState({fields}); //eslint-disable-line react/no-did-update-set-state
} else if (this.props.channelId && (this.props.channelId !== prevProps.channelId || this.props.description !== prevProps.description)) {
this.props.fetchJiraIssueMetadata();
const fields = {...this.state.fields};
fields.description = this.props.description;
this.setState({fields}); //eslint-disable-line react/no-did-update-set-state
}
}

Expand All @@ -59,9 +66,14 @@ export default class CreateIssueModal extends PureComponent {
e.preventDefault();
}

const {post, channelId} = this.props;

const postId = (post) ? post.id : '';

const issue = {
post_id: this.props.post.id,
fields: this.state.fields,
post_id: postId,
channel_id: channelId,
};

this.setState({submitting: true});
Expand Down Expand Up @@ -133,7 +145,7 @@ export default class CreateIssueModal extends PureComponent {
}

render() {
const {post, visible, theme, jiraIssueMetadata} = this.props;
const {visible, theme, jiraIssueMetadata} = this.props;
const {error, submitting} = this.state;
const style = getStyle(theme);

Expand All @@ -146,7 +158,7 @@ export default class CreateIssueModal extends PureComponent {
console.error('render error', error); //eslint-disable-line no-console
}

if (!post || !jiraIssueMetadata || !jiraIssueMetadata.projects) {
if (!jiraIssueMetadata || !jiraIssueMetadata.projects) {
component = <Loading/>;
} else {
const issueOptions = getIssueValues(jiraIssueMetadata, this.state.projectKey);
Expand Down
8 changes: 5 additions & 3 deletions webapp/src/components/modals/create_issue/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,22 @@ import {bindActionCreators} from 'redux';
import {getPost} from 'mattermost-redux/selectors/entities/posts';

import {closeCreateModal, createIssue, fetchJiraIssueMetadata} from 'actions';
import {isCreateModalVisible, getCreateModalForPostId, getJiraIssueMetadata} from 'selectors';
import {isCreateModalVisible, getCreateModal, getJiraIssueMetadata} from 'selectors';

import CreateIssue from './create_issue';

const mapStateToProps = (state) => {
const postId = getCreateModalForPostId(state);
const post = getPost(state, postId);
const {postId, description, channelId} = getCreateModal(state);
const post = (postId) ? getPost(state, postId) : null;

const jiraIssueMetadata = getJiraIssueMetadata(state);

return {
visible: isCreateModalVisible(state),
jiraIssueMetadata,
post,
description,
channelId,
};
};

Expand Down
19 changes: 19 additions & 0 deletions webapp/src/hooks/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {openCreateModalWithoutPost} from '../actions';

export default class Hooks {
constructor(store) {
this.store = store;
}

slashCommandWillBePostedHook = (message, contextArgs) => {
if (message && (message.startsWith('/jira create ') || message === '/jira create')) {
const description = message.slice(12).trim();
this.store.dispatch(openCreateModalWithoutPost(description, contextArgs.channel_id));
return Promise.resolve({});
}
return Promise.resolve({message, args: contextArgs});
}
}
4 changes: 4 additions & 0 deletions webapp/src/plugin.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import PluginId from 'plugin_id';

import reducers from './reducers';
import {handleConnectChange, getConnected, openChannelSettings} from './actions';
import Hooks from './hooks/hooks';

export default class Plugin {
async initialize(registry, store) {
Expand All @@ -32,6 +33,9 @@ export default class Plugin {
);
registry.registerRootComponent(AttachCommentToIssueModal);
registry.registerPostDropdownMenuComponent(AttachCommentToIssuePostMenuAction);

const hooks = new Hooks(store);
registry.registerSlashCommandWillBePostedHook(hooks.slashCommandWillBePostedHook);
} catch (err) {
throw err;
} finally {
Expand Down
15 changes: 11 additions & 4 deletions webapp/src/reducers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function connected(state = false, action) {
const createModalVisible = (state = false, action) => {
switch (action.type) {
case ActionTypes.OPEN_CREATE_ISSUE_MODAL:
case ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST:
return true;
case ActionTypes.CLOSE_CREATE_ISSUE_MODAL:
return false;
Expand All @@ -25,12 +26,18 @@ const createModalVisible = (state = false, action) => {
}
};

const createModalForPostId = (state = '', action) => {
const createModal = (state = '', action) => {
switch (action.type) {
case ActionTypes.OPEN_CREATE_ISSUE_MODAL:
return action.data.postId;
case ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST:
return {
...state,
postId: action.data.postId,
description: action.data.description,
channelId: action.data.channelId,
};
case ActionTypes.CLOSE_CREATE_ISSUE_MODAL:
return '';
return {};
default:
return state;
}
Expand Down Expand Up @@ -93,7 +100,7 @@ const channelSubscripitons = (state = {}, action) => {
export default combineReducers({
connected,
createModalVisible,
createModalForPostId,
createModal,
attachCommentToIssueModalVisible,
attachCommentToIssueModalForPostId,
jiraIssueMetadata,
Expand Down
2 changes: 1 addition & 1 deletion webapp/src/selectors/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const getCurrentUserLocale = createSelector(

export const isCreateModalVisible = (state) => getPluginState(state).createModalVisible;

export const getCreateModalForPostId = (state) => getPluginState(state).createModalForPostId;
export const getCreateModal = (state) => getPluginState(state).createModal;

export const isAttachCommentToIssueModalVisible = (state) => getPluginState(state).attachCommentToIssueModalVisible;

Expand Down

0 comments on commit e9e58a6

Please sign in to comment.