Skip to content

Commit

Permalink
RHS Subscriptions (#333)
Browse files Browse the repository at this point in the history
* add .nvmrc

* GitLab subscriptions RHS

Expose a RHS experience for GitLab that showcases the current channels's subscriptions, as well as information on the user currently logged in.

* fix PropTypes

* ensure subscription key is unique

* refactor subscriptionsr response

* show subscription features

* implement empty webapp test script

* avoid re-render on new array

* avoid unnecessary Promise.all

* nvm v14.21.1

* consistent action return types

* enhance PropTypes with isRequired

* ensure unique subscriptions key

* avoid depending on url.JoinPath from Go 1.19

* switch to styled-components

* log error on failed marshal

* switch to vanilla CSS

* revert package.json

* restore npm test
  • Loading branch information
lieut-data committed Dec 1, 2022
1 parent 609aed6 commit 2275492
Show file tree
Hide file tree
Showing 19 changed files with 1,249 additions and 11 deletions.
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
14.21.1
Binary file added public/app-bar-icon.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 65 additions & 0 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"runtime/debug"
"strconv"
"strings"
Expand All @@ -20,6 +22,7 @@ import (
"github.com/mattermost/mattermost-server/v6/plugin"

"github.com/mattermost/mattermost-plugin-gitlab/server/gitlab"
"github.com/mattermost/mattermost-plugin-gitlab/server/subscription"
)

const (
Expand Down Expand Up @@ -57,6 +60,8 @@ func (p *Plugin) initializeAPI() {
apiRouter.HandleFunc("/unreads", p.checkAuth(p.attachUserContext(p.getUnreads), ResponseTypePlain)).Methods(http.MethodGet)

apiRouter.HandleFunc("/settings", p.checkAuth(p.attachUserContext(p.updateSettings), ResponseTypePlain)).Methods(http.MethodPost)

apiRouter.HandleFunc("/channel/{channel_id:[A-Za-z0-9]+}/subscriptions", p.checkAuth(p.attachUserContext(p.getChannelSubscriptions), ResponseTypeJSON)).Methods(http.MethodGet)
}

type Context struct {
Expand Down Expand Up @@ -595,3 +600,63 @@ func (p *Plugin) updateSettings(c *UserContext, w http.ResponseWriter, r *http.R

p.writeAPIResponse(w, info.Settings)
}

type SubscriptionResponse struct {
RepositoryName string `json:"repository_name"`
RepositoryURL string `json:"repository_url"`
Features []string `json:"features"`
CreatorID string `json:"creator_id"`
}

func subscriptionsToResponse(config *configuration, subscriptions []*subscription.Subscription) []SubscriptionResponse {
gitlabURL, _ := url.Parse(config.GitlabURL)

subscriptionResponses := make([]SubscriptionResponse, 0, len(subscriptions))

for _, subscription := range subscriptions {
features := []string{}
if len(subscription.Features) > 0 {
features = strings.Split(subscription.Features, ",")
}

repositoryURL := *gitlabURL
repositoryURL.Path = path.Join(gitlabURL.EscapedPath(), subscription.Repository)

subscriptionResponses = append(subscriptionResponses, SubscriptionResponse{
RepositoryName: subscription.Repository,
RepositoryURL: repositoryURL.String(),
Features: features,
CreatorID: subscription.CreatorID,
})
}

return subscriptionResponses
}

func (p *Plugin) getChannelSubscriptions(c *UserContext, w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
channelID := vars["channel_id"]

if !p.API.HasPermissionToChannel(c.UserID, channelID, model.PermissionReadChannel) {
p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Not authorized.", StatusCode: http.StatusUnauthorized})
return
}

config := p.getConfiguration()
subscriptions, err := p.GetSubscriptionsByChannel(channelID)
if err != nil {
p.API.LogError("unable to get subscriptions by channel", "err", err.Error())
p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Unable to get subscriptions by channel.", StatusCode: http.StatusInternalServerError})
return
}

resp := subscriptionsToResponse(config, subscriptions)

b, err := json.Marshal(resp)
if err != nil {
p.API.LogError("failed to marshal channel subscriptions response", "err", err.Error())
p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Encountered an unexpected error. Please try again.", StatusCode: http.StatusInternalServerError})
} else if _, err := w.Write(b); err != nil {
p.API.LogError("can't write api error http response", "err", err.Error())
}
}
141 changes: 141 additions & 0 deletions server/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package main

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"

"github.com/mattermost/mattermost-plugin-gitlab/server/gitlab"
)

func TestGetChannelSubscriptions(t *testing.T) {
setupPlugin := func(t *testing.T) (*Plugin, *plugintest.API) {
t.Helper()

config := configuration{
GitlabURL: "https://example.com",
GitlabOAuthClientID: "client_id",
GitlabOAuthClientSecret: "secret",
EncryptionKey: "aaaaaaaaaaaaaaaa",
}

plugin := Plugin{configuration: &config}
plugin.initializeAPI()

token := oauth2.Token{
AccessToken: "access_token",
Expiry: time.Now().Add(1 * time.Hour),
}
info := gitlab.UserInfo{
UserID: "user_id",
Token: &token,
GitlabUsername: "gitlab_username",
GitlabUserID: 0,
}
encryptedToken, err := encrypt([]byte(config.EncryptionKey), info.Token.AccessToken)
require.NoError(t, err)

info.Token.AccessToken = encryptedToken

jsonInfo, err := json.Marshal(info)
require.NoError(t, err)

mock := &plugintest.API{}
plugin.SetAPI(mock)

mock.On("KVGet", "user_id_gitlabtoken").Return(jsonInfo, nil).Once()

return &plugin, mock
}

t.Run("no permission to channel", func(t *testing.T) {
plugin, mock := setupPlugin(t)

mock.On("HasPermissionToChannel", "user_id", "id", model.PermissionReadChannel).Return(false, nil).Once()

w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/channel/id/subscriptions", nil)
r.Header.Set("Mattermost-User-ID", "user_id")

plugin.ServeHTTP(nil, w, r)

result := w.Result()
assert.NotNil(t, result)
assert.Equal(t, http.StatusUnauthorized, result.StatusCode)
})

t.Run("no subscriptions", func(t *testing.T) {
plugin, mock := setupPlugin(t)

mock.On("HasPermissionToChannel", "user_id", "id", model.PermissionReadChannel).Return(true, nil).Once()
mock.On("KVGet", SubscriptionsKey).Return([]byte(`{"Repositories":{"repo1":[]}}`), nil)

w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/channel/id/subscriptions", nil)
r.Header.Set("Mattermost-User-ID", "user_id")

plugin.ServeHTTP(nil, w, r)

result := w.Result()
defer result.Body.Close()
data, err := io.ReadAll(result.Body)
require.NoError(t, err)

assert.NotNil(t, result)
assert.Equal(t, http.StatusOK, result.StatusCode)
assert.Equal(t, "[]", string(data))
})

t.Run("no subscriptions for channel", func(t *testing.T) {
plugin, mock := setupPlugin(t)

mock.On("HasPermissionToChannel", "user_id", "id", model.PermissionReadChannel).Return(true, nil).Once()
mock.On("KVGet", SubscriptionsKey).Return([]byte(`{"Repositories":{"namespace":[{"ChannelID":"other","Repository":"repo1"}]}}`), nil)

w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/channel/id/subscriptions", nil)
r.Header.Set("Mattermost-User-ID", "user_id")

plugin.ServeHTTP(nil, w, r)

result := w.Result()
defer result.Body.Close()
data, err := io.ReadAll(result.Body)
require.NoError(t, err)

assert.NotNil(t, result)
assert.Equal(t, http.StatusOK, result.StatusCode)
assert.Equal(t, "[]", string(data))
})

t.Run("subscriptions for channel", func(t *testing.T) {
plugin, mock := setupPlugin(t)

mock.On("HasPermissionToChannel", "user_id", "id", model.PermissionReadChannel).Return(true, nil).Once()
mock.On("KVGet", SubscriptionsKey).Return([]byte(`{"Repositories":{"namespace":[{"ChannelID":"other","Repository":"repo1","Features":"feature1,feature2","CreatorID":"creator1"},{"ChannelID":"id","Repository":"repo2","Features":"feature3,feature4","CreatorID":"creator2"},{"ChannelID":"id", "Repository":"repo3","Features":"feature5","CreatorID":"creator3"},{"ChannelID":"id","Repository":"repo4-empty"}]}}`), nil)

w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/channel/id/subscriptions", nil)
r.Header.Set("Mattermost-User-ID", "user_id")

plugin.ServeHTTP(nil, w, r)

result := w.Result()
defer result.Body.Close()
data, err := io.ReadAll(result.Body)
require.NoError(t, err)

assert.NotNil(t, result)
assert.Equal(t, http.StatusOK, result.StatusCode)
assert.Equal(t, `[{"repository_name":"repo2","repository_url":"https://example.com/repo2","features":["feature3","feature4"],"creator_id":"creator2"},{"repository_name":"repo3","repository_url":"https://example.com/repo3","features":["feature5"],"creator_id":"creator3"},{"repository_name":"repo4-empty","repository_url":"https://example.com/repo4-empty","features":[],"creator_id":""}]`, string(data))
})
}
5 changes: 5 additions & 0 deletions server/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,8 @@ func (p *Plugin) subscriptionDelete(info *gitlab.UserInfo, config *configuration
return "Subscription not found, please check repository name.", nil
}

p.sendChannelSubscriptionsUpdated(channelID)

return fmt.Sprintf("Successfully deleted subscription for %s.", normalizedPath), nil
}

Expand Down Expand Up @@ -568,6 +570,9 @@ func (p *Plugin) subscriptionsAddCommand(ctx context.Context, info *gitlab.UserI
fullPath,
)
}

p.sendChannelSubscriptionsUpdated(channelID)

return fmt.Sprintf("Successfully subscribed to %s.%s", fullPath, hookStatusMessage)
}

Expand Down
2 changes: 2 additions & 0 deletions server/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ func getTestPlugin(t *testing.T, mockCtrl *gomock.Controller, hooks []*gitlab.We
var subVal []byte
api.On("KVGet", mock.Anything).Return(subVal, nil)
api.On("KVSet", mock.Anything, mock.Anything).Return(nil)
api.On("PublishWebSocketEvent", mock.Anything, mock.Anything, mock.Anything).Return(nil)

p.SetAPI(api)
return p
}
Expand Down
56 changes: 46 additions & 10 deletions server/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@ import (
)

const (
GitlabTokenKey = "_gitlabtoken"
GitlabUsernameKey = "_gitlabusername"
GitlabIDUsernameKey = "_gitlabidusername"
WsEventConnect = "gitlab_connect"
WsEventDisconnect = "gitlab_disconnect"
WsEventRefresh = "gitlab_refresh"
SettingNotifications = "notifications"
SettingReminders = "reminders"
SettingOn = "on"
SettingOff = "off"
GitlabTokenKey = "_gitlabtoken"
GitlabUsernameKey = "_gitlabusername"
GitlabIDUsernameKey = "_gitlabidusername"
WsEventConnect = "gitlab_connect"
WsEventDisconnect = "gitlab_disconnect"
WsEventRefresh = "gitlab_refresh"
WsChannelSubscriptionsUpdated = "gitlab_channel_subscriptions_updated"
SettingNotifications = "notifications"
SettingReminders = "reminders"
SettingOn = "on"
SettingOff = "off"

chimeraGitLabAppIdentifier = "plugin-gitlab"
)
Expand Down Expand Up @@ -553,6 +554,41 @@ func (p *Plugin) sendRefreshEvent(userID string) {
)
}

func (p *Plugin) sendChannelSubscriptionsUpdated(channelID string) {
config := p.getConfiguration()

subscriptions, err := p.GetSubscriptionsByChannel(channelID)
if err != nil {
p.API.LogWarn(
"unable to fetch subscriptions by channel",
"err", err.Error(),
)
return
}

var payload struct {
ChannelID string `json:"channel_id"`
Subscriptions []SubscriptionResponse `json:"subscriptions"`
}
payload.ChannelID = channelID
payload.Subscriptions = subscriptionsToResponse(config, subscriptions)

payloadJSON, err := json.Marshal(payload)
if err != nil {
p.API.LogWarn(
"unable to marshal payload for updated channel subscriptions",
"err", err.Error(),
)
return
}

p.API.PublishWebSocketEvent(
WsChannelSubscriptionsUpdated,
map[string]interface{}{"payload": string(payloadJSON)},
&model.WebsocketBroadcast{ChannelId: channelID},
)
}

// HasProjectHook checks if the subscribed GitLab Project or its parrent Group has a webhook
// with a URL that matches the Mattermost Site URL.
func (p *Plugin) HasProjectHook(ctx context.Context, user *gitlab.UserInfo, namespace string, project string) (bool, error) {
Expand Down
3 changes: 2 additions & 1 deletion webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"build": "webpack --mode=production --display-error-details --progress",
"lint": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx . --quiet",
"fix": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx . --quiet --fix"
"fix": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx . --quiet --fix",
"test": "echo 'no webapp tests implemented';"
},
"dependencies": {
"core-js": "3.1.4",
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 @@ -8,4 +8,5 @@ export default {
RECEIVED_UNREADS: `${id}_received_unreads`,
RECEIVED_CONNECTED: `${id}_received_connected`,
RECEIVED_GITLAB_USER: `${id}_received_gitlab_user`,
RECEIVED_CHANNEL_SUBSCRIPTIONS: `${id}_received_channel_subscriptions`,
};
25 changes: 25 additions & 0 deletions webapp/src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,28 @@ export function getGitlabUser(userID) {
return {data};
};
}

export function getChannelSubscriptions(channelId) {
return async (dispatch) => {
if (!channelId) {
return {};
}

let subscriptions;
try {
subscriptions = await Client.getChannelSubscriptions(channelId);
} catch (error) {
return {error};
}

dispatch({
type: ActionTypes.RECEIVED_CHANNEL_SUBSCRIPTIONS,
data: {
channelId,
subscriptions,
},
});

return {subscriptions};
};
}
4 changes: 4 additions & 0 deletions webapp/src/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export default class Client {
return this.doPost(`${this.url}/user`, {user_id: userID});
};

getChannelSubscriptions = async (channelID) => {
return this.doGet(`${this.url}/channel/${channelID}/subscriptions`);
};

doGet = async (url, body, headers = {}) => {
headers['X-Timezone-Offset'] = new Date().getTimezoneOffset();

Expand Down

0 comments on commit 2275492

Please sign in to comment.