Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MI-2337]: Supported filtering on comment visibility for subscriptions #18

Merged
merged 7 commits into from
Nov 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions server/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import (
)

const autocompleteSearchRoute = "2/jql/autocompletedata/suggestions"
const commentVisibilityRoute = "2/user"
const userSearchRoute = "2/user/assignable/search"
const unrecognizedEndpoint = "_unrecognized"
const visibleToAllUsers = "visible-to-all-users"

// Client is the combined interface for all upstream APIs and convenience methods.
type Client interface {
Expand Down Expand Up @@ -64,6 +66,7 @@ type SearchService interface {
SearchUsersAssignableToIssue(issueKey, query string, maxResults int) ([]jira.User, error)
SearchUsersAssignableInProject(projectKey, query string, maxResults int) ([]jira.User, error)
SearchAutoCompleteFields(params map[string]string) (*AutoCompleteResult, error)
SearchCommentVisibilityFields(params map[string]string) (*CommentVisibilityResult, error)
}

// IssueService is the interface for issue-related APIs.
Expand Down Expand Up @@ -252,6 +255,18 @@ type AutoCompleteResult struct {
Results []Result `json:"results"`
}

type Item struct {
Name string `json:"name"`
}

type Group struct {
Items []Item `json:"items"`
}

type CommentVisibilityResult struct {
Groups Group `json:"groups"`
}

// SearchAutoCompleteFields searches fieldValue specified in the params and returns autocomplete suggestions
// for that fieldValue
func (client JiraClient) SearchAutoCompleteFields(params map[string]string) (*AutoCompleteResult, error) {
Expand All @@ -264,6 +279,17 @@ func (client JiraClient) SearchAutoCompleteFields(params map[string]string) (*Au
return result, nil
}

// SearchCommentVisibilityFields searches fieldValue specified in the params and returns the comment visibility suggestions
// for that fieldValue
func (client JiraClient) SearchCommentVisibilityFields(params map[string]string) (*CommentVisibilityResult, error) {
result := &CommentVisibilityResult{}
if err := client.RESTGet(commentVisibilityRoute, params, result); err != nil {
return nil, err
}
result.Groups.Items = append(result.Groups.Items, Item{visibleToAllUsers})
return result, nil
}

// DoTransition executes a transition on an issue.
func (client JiraClient) DoTransition(issueKey, transitionID string) error {
resp, err := client.Jira.Issue.DoTransition(issueKey, transitionID)
Expand Down
3 changes: 3 additions & 0 deletions server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const (
routeAPIGetJiraProjectMetadata = "/api/v2/get-jira-project-metadata"
routeAPIGetSearchIssues = "/api/v2/get-search-issues"
routeAPIGetAutoCompleteFields = "/api/v2/get-search-autocomplete-fields"
routeAPIGetCommentVisibilityFields = "/api/v2/get-comment-visibility-fields"
routeAPIGetSearchUsers = "/api/v2/get-search-users"
routeAPIAttachCommentToIssue = "/api/v2/attach-comment-to-issue"
routeAPIUserInfo = "/api/v2/userinfo"
Expand Down Expand Up @@ -126,6 +127,8 @@ func (p *Plugin) serveHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req
return p.httpGetSearchIssues(w, r)
case routeAPIGetAutoCompleteFields:
return p.httpGetAutoCompleteFields(w, r)
case routeAPIGetCommentVisibilityFields:
return p.httpGetCommentVisibilityFields(w, r)
case routeAPIGetSearchUsers:
return p.httpGetSearchUsers(w, r)
case routeAPIAttachCommentToIssue:
Expand Down
57 changes: 51 additions & 6 deletions server/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ import (
)

const (
labelsField = "labels"
statusField = "status"
reporterField = "reporter"
priorityField = "priority"
descriptionField = "description"
resolutionField = "resolution"
labelsField = "labels"
statusField = "status"
reporterField = "reporter"
priorityField = "priority"
descriptionField = "description"
resolutionField = "resolution"
headerMattermostUserID = "Mattermost-User-ID"
instanceIDQueryParam = "instance_id"
fieldValueQueryParam = "fieldValue"
expandQueryParam = "expand"
)

func makePost(userID, channelID, message string) *model.Post {
Expand Down Expand Up @@ -404,6 +408,47 @@ func (p *Plugin) GetCreateIssueMetadataForProjects(instanceID, mattermostUserID
})
}

func (p *Plugin) httpGetCommentVisibilityFields(w http.ResponseWriter, r *http.Request) (int, error) {
if r.Method != http.MethodGet {
return http.StatusMethodNotAllowed, fmt.Errorf("Request: " + r.Method + " is not allowed, must be GET")
}

mattermostUserID := r.Header.Get(headerMattermostUserID)
if mattermostUserID == "" {
return http.StatusUnauthorized, errors.New("not authorized")
}

instanceID := r.FormValue(instanceIDQueryParam)
client, _, connection, err := p.getClient(types.ID(instanceID), types.ID(mattermostUserID))
if err != nil {
return http.StatusInternalServerError, err
}

params := map[string]string{
"fieldValue": r.FormValue(fieldValueQueryParam),
"expand": r.FormValue(expandQueryParam),
"accountId": connection.AccountID,
}
response, err := client.SearchCommentVisibilityFields(params)
if err != nil {
return http.StatusInternalServerError, err
}
if response == nil {
return http.StatusInternalServerError, errors.New("failed to return the response")
}

jsonResponse, err := json.Marshal(response)
if err != nil {
return http.StatusInternalServerError, errors.WithMessage(err, "failed to marshal the response")
}

w.Header().Set("Content-Type", "application/json")
if _, err = w.Write(jsonResponse); err != nil {
return http.StatusInternalServerError, errors.WithMessage(err, "failed to write the response")
}
return http.StatusOK, nil
}

func (p *Plugin) httpGetSearchIssues(w http.ResponseWriter, r *http.Request) (int, error) {
if r.Method != http.MethodGet {
return respondErr(w, http.StatusMethodNotAllowed,
Expand Down
13 changes: 10 additions & 3 deletions server/subscribe.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
FilterEmpty = "empty"

MaxSubscriptionNameLength = 100
CommentVisibility = "commentVisibility"
)

type FieldFilter struct {
Expand Down Expand Up @@ -123,7 +124,7 @@ func (p *Plugin) getUserID() string {
return p.getConfig().botUserID
}

func (p *Plugin) matchesSubsciptionFilters(wh *webhook, filters SubscriptionFilters) bool {
func (p *Plugin) matchesSubsciptionFilters(wh *webhook, filters SubscriptionFilters, visibilityAttribute string) bool {
webhookEvents := wh.Events()
foundEvent := false
eventTypes := filters.Events
Expand Down Expand Up @@ -159,6 +160,12 @@ func (p *Plugin) matchesSubsciptionFilters(wh *webhook, filters SubscriptionFilt
}

value := getIssueFieldValue(&wh.JiraWebhook.Issue, field.Key)
if visibilityAttribute != "" {
value[visibilityAttribute] = true
} else if field.Key == CommentVisibility {
value[visibleToAllUsers] = true
}

containsAny := value.ContainsAny(field.Values.Elems()...)
containsAll := value.ContainsAll(field.Values.Elems()...)

Expand All @@ -174,7 +181,7 @@ func (p *Plugin) matchesSubsciptionFilters(wh *webhook, filters SubscriptionFilt
return validFilter
}

func (p *Plugin) getChannelsSubscribed(wh *webhook, instanceID types.ID) ([]ChannelSubscription, error) {
func (p *Plugin) getChannelsSubscribed(wh *webhook, instanceID types.ID, visibilityAttribute string) ([]ChannelSubscription, error) {
subs, err := p.getSubscriptions(instanceID)
if err != nil {
return nil, err
Expand All @@ -183,7 +190,7 @@ func (p *Plugin) getChannelsSubscribed(wh *webhook, instanceID types.ID) ([]Chan
var channelSubscriptions []ChannelSubscription
subIds := subs.Channel.ByID
for _, sub := range subIds {
if p.matchesSubsciptionFilters(wh, sub.Filters) {
if p.matchesSubsciptionFilters(wh, sub.Filters, visibilityAttribute) {
channelSubscriptions = append(channelSubscriptions, sub)
}
}
Expand Down
2 changes: 1 addition & 1 deletion server/subscribe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1385,7 +1385,7 @@ func TestGetChannelsSubscribed(t *testing.T) {
wh, err := ParseWebhook(bb)
assert.Nil(t, err)

actual, err := p.getChannelsSubscribed(wh.(*webhook), testInstance1.InstanceID)
actual, err := p.getChannelsSubscribed(wh.(*webhook), testInstance1.InstanceID, "")
assert.Nil(t, err)
assert.Equal(t, len(tc.ChannelSubscriptions), len(actual))
actualChannelIDs := NewStringSet()
Expand Down
27 changes: 26 additions & 1 deletion server/webhook_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package main

import (
jira "github.com/andygrunwald/go-jira"

"github.com/mattermost/mattermost-plugin-jira/server/utils/types"
)

Expand Down Expand Up @@ -49,7 +51,30 @@ func (ww webhookWorker) process(msg *webhookMessage) (err error) {
return err
}

channelsSubscribed, err := ww.p.getChannelsSubscribed(v, msg.InstanceID)
// To check if this is a comment-related webhook payload
isCommentEvent := wh.Events().Intersection(commentEvents).Len() > 0
visibilityAttribute := ""
if isCommentEvent {
mattermostUserID, er := ww.p.userStore.LoadMattermostUserID(msg.InstanceID, v.JiraWebhook.Comment.Author.AccountID)
if er != nil {
ww.p.API.LogInfo("Commentator is not connected with the mattermost", "Error", er.Error())
return er
}

client, _, _, er := ww.p.getClient(msg.InstanceID, mattermostUserID)
if er != nil {
return er
}

comment := jira.Comment{}
if er = client.RESTGet(v.JiraWebhook.Comment.Self, nil, &comment); er != nil {
return er
}

visibilityAttribute = comment.Visibility.Value
}

channelsSubscribed, err := ww.p.getChannelsSubscribed(v, msg.InstanceID, visibilityAttribute)
if err != nil {
return err
}
Expand Down
7 changes: 7 additions & 0 deletions webapp/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ export const searchAutoCompleteFields = (params) => {
};
};

export const searchCommentVisibilityFields = (params) => {
return async (dispatch, getState) => {
const url = `${getPluginServerRoute(getState())}/api/v2/get-comment-visibility-fields`;
return doFetchWithResponse(`${url}${buildQueryString(params)}`);
};
};

export const searchUsers = (params) => {
return async (dispatch, getState) => {
const url = getPluginServerRoute(getState()) + '/api/v2/get-search-users';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';

import {searchCommentVisibilityFields} from 'actions';

import JiraCommentVisibilitySelector from './jira_commentvisibility_selector';

const mapDispatchToProps = (dispatch) => bindActionCreators({searchCommentVisibilityFields}, dispatch);

export default connect(null, mapDispatchToProps)(JiraCommentVisibilitySelector);
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import React from 'react';

import {ReactSelectOption} from 'types/model';

import BackendSelector, {Props as BackendSelectorProps} from '../backend_selector';

const stripHTML = (text: string) => {
if (!text) {
return text;
}

const doc = new DOMParser().parseFromString(text, 'text/html');
return doc.body.textContent || '';
};

type Props = BackendSelectorProps & {
Nityanand13 marked this conversation as resolved.
Show resolved Hide resolved
searchCommentVisibilityFields: (params: {fieldValue: string}) => (
Promise<{data: {groups: {items: {name: string}[]}}; error?: Error}>
);
fieldName: string;
};

const JiraCommentVisibilitySelector = (props: Props) => {
const {value, isMulti, instanceID, searchCommentVisibilityFields} = props;
const fetchInitialSelectedValues = async (): Promise<ReactSelectOption[]> =>
((!value || (isMulti && !value.length)) ? [] : commentVisibilityFields(''));

const commentVisibilityFields = async (inputValue: string): Promise<ReactSelectOption[]> => {
const params = {
fieldValue: inputValue,
instance_id: instanceID,
Nityanand13 marked this conversation as resolved.
Show resolved Hide resolved
expand: 'groups',
};
return searchCommentVisibilityFields(params).then(({data}) => {
return data.groups.items.map((suggestion) => ({
value: suggestion.name,
label: stripHTML(suggestion.name),
}));
});
};

return (
<BackendSelector
{...props}
fetchInitialSelectedValues={fetchInitialSelectedValues}
search={commentVisibilityFields}
/>
);
};

export default JiraCommentVisibilitySelector;
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import {Theme} from 'mattermost-redux/types/preferences';
import ReactSelectSetting from 'components/react_select_setting';
import JiraEpicSelector from 'components/data_selectors/jira_epic_selector';

import {isEpicLinkField, isMultiSelectField, isLabelField} from 'utils/jira_issue_metadata';
import {isEpicLinkField, isMultiSelectField, isLabelField, isCommentVisibilityField} from 'utils/jira_issue_metadata';
import {FilterField, FilterValue, ReactSelectOption, IssueMetadata, IssueType, FilterFieldInclusion} from 'types/model';
import ConfirmModal from 'components/confirm_modal';
import JiraAutoCompleteSelector from 'components/data_selectors/jira_autocomplete_selector';
import JiraCommentVisibilitySelector from 'components/data_selectors/jira_commentvisibility_selector';

export type Props = {
fields: FilterField[];
Expand Down Expand Up @@ -244,7 +245,16 @@ export default class ChannelSubscriptionFilter extends React.PureComponent<Props
};

let valueSelector;
if (isEpicLinkField(this.props.field)) {
if (isCommentVisibilityField(field)) {
valueSelector = (
<JiraCommentVisibilitySelector
{...selectProps}
fieldName={field.name}
value={value.values}
onChange={this.handleEpicLinkChange}
/>
);
} else if (isEpicLinkField(this.props.field)) {
valueSelector = (
<JiraEpicSelector
{...selectProps}
Expand Down
18 changes: 17 additions & 1 deletion webapp/src/utils/jira_issue_metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,19 @@ export function getCustomFieldFiltersForProjects(metadata: IssueMetadata | null,
} as FilterField;
});

const result = populatedFields.concat(userDefinedFields);
const commentVisibility = selectFields.map((field) => {
return {
key: 'commentVisibility',
name: 'Comment Visibility',
schema: {
type: 'commentVisibility',
},
values: [],
issueTypes: field.validIssueTypes,
} as FilterField;
});

const result = populatedFields.concat(userDefinedFields, commentVisibility);
const epicLinkField = fields.find(isEpicLinkField);
if (epicLinkField) {
result.unshift({
Expand Down Expand Up @@ -264,6 +276,10 @@ export function isLabelField(field: JiraField | FilterField): boolean {
return field.schema.system === 'labels' || field.schema.custom === 'com.atlassian.jira.plugin.system.customfieldtypes:labels';
}

export function isCommentVisibilityField(field: JiraField | FilterField): boolean {
return field.key === 'commentVisibility';
}

export function isEpicIssueType(issueType: IssueType): boolean {
return issueType.name === 'Epic';
}
Expand Down