diff --git a/server/plugin/command.go b/server/plugin/command.go index 601cb268b..a40eb3843 100644 --- a/server/plugin/command.go +++ b/server/plugin/command.go @@ -802,10 +802,32 @@ func (p *Plugin) handleSettings(_ *plugin.Context, _ *model.CommandArgs, paramet default: return "Invalid value. Accepted values are: \"on\" or \"off\" or \"on-change\" ." } + case settingVacationSync: + switch settingValue { + case settingOn: + userInfo.Settings.SyncVacationStatus = true + case settingOff: + userInfo.Settings.SyncVacationStatus = false + default: + return "Invalid value. Accepted values are: \"on\" or \"off\"." + } default: return "Unknown setting " + setting } + if setting == settingVacationSync { + onVacation := false + if userInfo.Settings.SyncVacationStatus { + user, err := p.client.User.Get(userInfo.UserID) + if err != nil { + p.client.Log.Warn("Failed to get user for vacation status sync", "userID", userInfo.UserID, "error", err.Error()) + } else { + onVacation = isOnVacation(user.GetCustomStatus()) + } + } + p.applyVacationStatus(context.Background(), userInfo, onVacation) + } + if setting == settingNotifications { if userInfo.Settings.Notifications { err := p.storeGitHubToUserIDMapping(userInfo.GitHubUsername, userInfo.UserID) @@ -1301,6 +1323,17 @@ func getAutocompleteData(config *Configuration) *model.AutocompleteData { remainderNotifications.AddStaticListArgument("", true, settingValue) settings.AddCommand(remainderNotifications) + settingVacationSyncData := model.NewAutocompleteData("vacation-sync", "", "Set your GitHub status to Busy while your Mattermost status is \"On a vacation\"") + settingValue = []model.AutocompleteListItem{{ + HelpText: "Turn vacation sync on", + Item: "on", + }, { + HelpText: "Turn vacation sync off", + Item: "off", + }} + settingVacationSyncData.AddStaticListArgument("", true, settingValue) + settings.AddCommand(settingVacationSyncData) + github.AddCommand(settings) setup := model.NewAutocompleteData("setup", "[command]", "Available commands: oauth, webhook, announcement") diff --git a/server/plugin/graphql/status.go b/server/plugin/graphql/status.go new file mode 100644 index 000000000..180eec7f8 --- /dev/null +++ b/server/plugin/graphql/status.go @@ -0,0 +1,43 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package graphql + +import ( + "context" + + "github.com/pkg/errors" + "github.com/shurcooL/githubv4" +) + +type changeUserStatusMutation struct { + ChangeUserStatus struct { + Status struct { + Message githubv4.String + } + } `graphql:"changeUserStatus(input: $input)"` +} + +// SetBusyStatus sets the user's GitHub status to Busy, which excludes them from automatic PR assignment. +func (c *Client) SetBusyStatus(ctx context.Context, message, emoji string) error { + return c.changeUserStatus(ctx, githubv4.ChangeUserStatusInput{ + LimitedAvailability: githubv4.NewBoolean(true), + Message: githubv4.NewString(githubv4.String(message)), + Emoji: githubv4.NewString(githubv4.String(emoji)), + }) +} + +// ClearStatus removes the user's GitHub status, including the Busy indicator. +func (c *Client) ClearStatus(ctx context.Context) error { + return c.changeUserStatus(ctx, githubv4.ChangeUserStatusInput{ + LimitedAvailability: githubv4.NewBoolean(false), + }) +} + +func (c *Client) changeUserStatus(ctx context.Context, input githubv4.ChangeUserStatusInput) error { + var m changeUserStatusMutation + if err := c.client.Mutate(ctx, &m, input, nil); err != nil { + return errors.Wrap(err, "error in changing user status") + } + return nil +} diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 079bfd172..0d20980e0 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -51,6 +51,7 @@ const ( settingButtonsTeam = "team" settingNotifications = "notifications" settingReminders = "reminders" + settingVacationSync = "vacation-sync" settingOn = "on" settingOff = "off" settingOnChange = "on-change" @@ -544,7 +545,7 @@ func (p *Plugin) getOAuthConfig(privateAllowed bool) (*oauth2.Config, error) { // means that asks scope for private repositories repo = github.ScopeRepo } - scopes := []string{string(repo), string(github.ScopeNotifications), string(github.ScopeReadOrg), string(github.ScopeAdminOrgHook)} + scopes := []string{string(repo), string(github.ScopeNotifications), string(github.ScopeReadOrg), string(github.ScopeAdminOrgHook), string(github.ScopeUser)} if config.UsePreregisteredApplication { p.client.Log.Debug("Using Chimera Proxy OAuth configuration") @@ -655,6 +656,7 @@ type UserSettings struct { DailyReminder bool `json:"daily_reminder"` DailyReminderOnChange bool `json:"daily_reminder_on_change"` Notifications bool `json:"notifications"` + SyncVacationStatus bool `json:"sync_vacation_status"` } func (p *Plugin) storeGitHubUserInfo(info *GitHubUserInfo, encryptionKey string) error { diff --git a/server/plugin/template.go b/server/plugin/template.go index 407bd527e..4c1b3b199 100644 --- a/server/plugin/template.go +++ b/server/plugin/template.go @@ -513,8 +513,9 @@ Reviewers: {{range $i, $el := .RequestedReviewers -}} {{- if $i}}, {{end}}{{temp "* `/github subscriptions delete owner[/repo]` - Unsubscribe the current channel from a repository\n" + "* `/github me` - Display the connected GitHub account\n" + "* `/github settings [setting] [value]` - Update your user settings\n" + - " * `setting` can be `notifications` or `reminders`\n" + + " * `setting` can be `notifications`, `reminders` or `vacation-sync`\n" + " * `value` can be `on` or `off`\n" + + " * `vacation-sync` sets your GitHub status to Busy (so you are not auto-assigned PRs) while your Mattermost custom status is \"On a vacation\". Off by default.\n" + "* `/github setup` - Setup your Github plugin\n" + "* `/github mute` - Managed muted GitHub users. You'll not receive notifications for comments in your PRs and issues from those users.\n" + " * `/github mute list` - list your muted GitHub users\n" + diff --git a/server/plugin/vacation_sync.go b/server/plugin/vacation_sync.go new file mode 100644 index 000000000..a21504dcb --- /dev/null +++ b/server/plugin/vacation_sync.go @@ -0,0 +1,97 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package plugin + +import ( + "context" + "encoding/json" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin" +) + +const ( + vacationSyncKeyPrefix = "vacation_sync_" + vacationSyncTimeout = 30 * time.Second + vacationCustomStatusIcon = "palm_tree" + vacationGitHubEmoji = ":palm_tree:" + vacationGitHubMessage = "On a vacation" +) + +// vacationSyncState is stored per user so a GitHub status the plugin did not set is never cleared. +type vacationSyncState struct { + Applied bool `json:"applied"` +} + +// PreferencesHaveChanged reacts to custom status changes: the server saves the "recent custom +// statuses" preference whenever a user sets a custom status, and the newest one is first. +func (p *Plugin) PreferencesHaveChanged(_ *plugin.Context, preferences []model.Preference) { + for _, preference := range preferences { + if preference.Category != model.PreferenceCategoryCustomStatus || preference.Name != model.PreferenceNameRecentCustomStatuses { + continue + } + + var recent model.RecentCustomStatuses + if err := json.Unmarshal([]byte(preference.Value), &recent); err != nil { + p.client.Log.Warn("Failed to parse recent custom statuses", "userID", preference.UserId, "error", err.Error()) + continue + } + if len(recent) == 0 { + continue + } + + info, apiErr := p.getGitHubUserInfo(preference.UserId) + if apiErr != nil || info.Settings == nil || !info.Settings.SyncVacationStatus { + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), vacationSyncTimeout) + p.applyVacationStatus(ctx, info, isOnVacation(&recent[0])) + cancel() + } +} + +func isOnVacation(cs *model.CustomStatus) bool { + if cs == nil || cs.Emoji != vacationCustomStatusIcon { + return false + } + return cs.ExpiresAt.IsZero() || cs.ExpiresAt.After(time.Now()) +} + +// applyVacationStatus sets the user's GitHub status to Busy when they go on vacation and clears it +// when they come back, but only if the plugin was the one that set it. +func (p *Plugin) applyVacationStatus(ctx context.Context, info *GitHubUserInfo, onVacation bool) { + key := vacationSyncKeyPrefix + info.UserID + + var state vacationSyncState + if err := p.store.Get(key, &state); err != nil { + p.client.Log.Warn("Failed to get vacation sync state", "userID", info.UserID, "error", err.Error()) + return + } + + switch { + case onVacation && !state.Applied: + if err := p.graphQLConnect(info).SetBusyStatus(ctx, vacationGitHubMessage, vacationGitHubEmoji); err != nil { + p.client.Log.Warn("Failed to set GitHub status to busy", "userID", info.UserID, "error", err.Error()) + p.CreateBotDMPost(info.UserID, + "Failed to set your GitHub status to Busy while you are on vacation. Your GitHub connection may be missing the `user` permission. Please reconnect your account using `/github disconnect` followed by `/github connect`.", + "custom_git_vacation_sync") + return + } + state.Applied = true + case !onVacation && state.Applied: + if err := p.graphQLConnect(info).ClearStatus(ctx); err != nil { + p.client.Log.Warn("Failed to clear GitHub busy status", "userID", info.UserID, "error", err.Error()) + return + } + state.Applied = false + default: + return + } + + if _, err := p.store.Set(key, state); err != nil { + p.client.Log.Warn("Failed to store vacation sync state", "userID", info.UserID, "error", err.Error()) + } +} diff --git a/server/plugin/vacation_sync_test.go b/server/plugin/vacation_sync_test.go new file mode 100644 index 000000000..255b1630a --- /dev/null +++ b/server/plugin/vacation_sync_test.go @@ -0,0 +1,299 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package plugin + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/mattermost/mattermost-plugin-github/server/mocks" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" +) + +func vacationUser(t *testing.T, cs *model.CustomStatus) *model.User { + t.Helper() + user := &model.User{Id: MockUserID, Props: model.StringMap{}} + if cs != nil { + require.NoError(t, user.SetCustomStatus(cs)) + } + return user +} + +func TestIsOnVacation(t *testing.T) { + tests := []struct { + name string + status *model.CustomStatus + expected bool + }{ + {name: "no custom status", status: nil, expected: false}, + {name: "other custom status", status: &model.CustomStatus{Emoji: "calendar", Text: "In a meeting"}, expected: false}, + {name: "vacation without expiry", status: &model.CustomStatus{Emoji: "palm_tree", Text: "On a vacation"}, expected: true}, + {name: "vacation not yet expired", status: &model.CustomStatus{Emoji: "palm_tree", ExpiresAt: time.Now().Add(time.Hour)}, expected: true}, + {name: "vacation expired", status: &model.CustomStatus{Emoji: "palm_tree", ExpiresAt: time.Now().Add(-time.Hour)}, expected: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, isOnVacation(tc.status)) + }) + } +} + +type vacationSyncTestEnv struct { + p *Plugin + store *mocks.MockKvStore + api *plugintest.API + requests chan map[string]any + key string +} + +func setupVacationSyncTest(t *testing.T, graphQLStatus int, syncEnabled bool) *vacationSyncTestEnv { + t.Helper() + + requests := make(chan map[string]any, 10) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + requests <- payload + w.WriteHeader(graphQLStatus) + _, err = w.Write([]byte(`{"data":{"changeUserStatus":{"status":{"message":"ok"}}}}`)) + require.NoError(t, err) + })) + t.Cleanup(server.Close) + + ctrl := gomock.NewController(t) + store := mocks.NewMockKvStore(ctrl) + api := &plugintest.API{} + p := getPluginTest(api, store) + + encryptionKey := "dummyEncryptKey1" + p.setConfiguration(&Configuration{EncryptionKey: encryptionKey, EnterpriseBaseURL: server.URL}) + encryptedToken, err := encrypt([]byte(encryptionKey), MockAccessToken) + require.NoError(t, err) + + stored, err := json.Marshal(&GitHubUserInfo{ + UserID: MockUserID, + GitHubUsername: MockUsername, + Token: &oauth2.Token{AccessToken: encryptedToken}, + Settings: &UserSettings{SyncVacationStatus: syncEnabled}, + }) + require.NoError(t, err) + store.EXPECT().Get(MockUserID+githubTokenKey, gomock.Any()).DoAndReturn(func(_ string, out any) error { + return json.Unmarshal(stored, out) + }).AnyTimes() + + return &vacationSyncTestEnv{ + p: p, + store: store, + api: api, + requests: requests, + key: vacationSyncKeyPrefix + MockUserID, + } +} + +func (e *vacationSyncTestEnv) expectState(t *testing.T, state vacationSyncState) { + t.Helper() + e.store.EXPECT().Get(e.key, gomock.Any()).DoAndReturn(func(_ string, out any) error { + b, err := json.Marshal(state) + require.NoError(t, err) + return json.Unmarshal(b, out) + }) +} + +func (e *vacationSyncTestEnv) expectUser(user *model.User) { + e.api.On("GetUser", MockUserID).Return(user, nil) +} + +func receiveMutationInput(t *testing.T, requests chan map[string]any) map[string]any { + t.Helper() + select { + case payload := <-requests: + variables, ok := payload["variables"].(map[string]any) + require.True(t, ok) + input, ok := variables["input"].(map[string]any) + require.True(t, ok) + return input + case <-time.After(time.Second): + require.FailNow(t, "expected a GraphQL request") + return nil + } +} + +func recentStatusPreference(t *testing.T, statuses ...model.CustomStatus) model.Preference { + t.Helper() + value, err := json.Marshal(statuses) + require.NoError(t, err) + return model.Preference{ + UserId: MockUserID, + Category: model.PreferenceCategoryCustomStatus, + Name: model.PreferenceNameRecentCustomStatuses, + Value: string(value), + } +} + +func TestPreferencesHaveChanged(t *testing.T) { + vacation := model.CustomStatus{Emoji: "palm_tree", Text: "On a vacation"} + meeting := model.CustomStatus{Emoji: "calendar", Text: "In a meeting"} + + t.Run("sets Busy when the newest status is vacation", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, true) + env.expectState(t, vacationSyncState{}) + env.store.EXPECT().Set(env.key, vacationSyncState{Applied: true}).Return(true, nil) + + env.p.PreferencesHaveChanged(nil, []model.Preference{recentStatusPreference(t, vacation, meeting)}) + + input := receiveMutationInput(t, env.requests) + assert.Equal(t, true, input["limitedAvailability"]) + assert.Equal(t, "On a vacation", input["message"]) + assert.Equal(t, ":palm_tree:", input["emoji"]) + }) + + t.Run("clears Busy when a different status is set after vacation", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, true) + env.expectState(t, vacationSyncState{Applied: true}) + env.store.EXPECT().Set(env.key, vacationSyncState{}).Return(true, nil) + + env.p.PreferencesHaveChanged(nil, []model.Preference{recentStatusPreference(t, meeting, vacation)}) + + input := receiveMutationInput(t, env.requests) + assert.Equal(t, false, input["limitedAvailability"]) + assert.NotContains(t, input, "message") + }) + + t.Run("does not touch GitHub when Busy was not applied by the plugin", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, true) + env.expectState(t, vacationSyncState{}) + + env.p.PreferencesHaveChanged(nil, []model.Preference{recentStatusPreference(t, meeting)}) + + assert.Empty(t, env.requests) + }) + + t.Run("does nothing when already applied and vacation is set again", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, true) + env.expectState(t, vacationSyncState{Applied: true}) + + env.p.PreferencesHaveChanged(nil, []model.Preference{recentStatusPreference(t, vacation)}) + + assert.Empty(t, env.requests) + }) + + t.Run("ignores users who have not enabled the setting", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, false) + + env.p.PreferencesHaveChanged(nil, []model.Preference{recentStatusPreference(t, vacation)}) + + assert.Empty(t, env.requests) + }) + + t.Run("ignores unrelated preferences", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, true) + + env.p.PreferencesHaveChanged(nil, []model.Preference{ + {UserId: MockUserID, Category: model.PreferenceCategoryTheme, Name: model.PreferenceNameRecentCustomStatuses, Value: "[]"}, + {UserId: MockUserID, Category: model.PreferenceCategoryCustomStatus, Name: "other", Value: "[]"}, + }) + + assert.Empty(t, env.requests) + }) + + t.Run("notifies the user when GitHub rejects the update", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusForbidden, true) + env.expectState(t, vacationSyncState{}) + env.api.On("LogWarn", "Failed to set GitHub status to busy", "userID", MockUserID, "error", mock.Anything).Once() + env.api.On("GetDirectChannel", MockUserID, MockBotID).Return(&model.Channel{Id: MockChannelID}, nil) + env.api.On("CreatePost", mock.MatchedBy(func(post *model.Post) bool { + return post.ChannelId == MockChannelID && post.UserId == MockBotID + })).Return(&model.Post{}, nil) + + env.p.PreferencesHaveChanged(nil, []model.Preference{recentStatusPreference(t, vacation)}) + + receiveMutationInput(t, env.requests) + env.api.AssertExpectations(t) + }) +} + +func TestHandleSettingsVacationSync(t *testing.T) { + newInfo := func(enabled bool) *GitHubUserInfo { + return &GitHubUserInfo{UserID: MockUserID, GitHubUsername: MockUsername, Token: &oauth2.Token{AccessToken: MockAccessToken}, Settings: &UserSettings{SyncVacationStatus: enabled}} + } + + t.Run("on while already on vacation sets Busy", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, false) + info := newInfo(false) + env.expectUser(vacationUser(t, &model.CustomStatus{Emoji: "palm_tree", Text: "On a vacation"})) + env.expectState(t, vacationSyncState{}) + env.store.EXPECT().Set(env.key, vacationSyncState{Applied: true}).Return(true, nil) + env.store.EXPECT().Set(MockUserID+githubTokenKey, gomock.Any()).Return(true, nil) + + result := env.p.handleSettings(nil, nil, []string{settingVacationSync, settingOn}, info) + + assert.Equal(t, "Settings updated.", result) + assert.True(t, info.Settings.SyncVacationStatus) + input := receiveMutationInput(t, env.requests) + assert.Equal(t, true, input["limitedAvailability"]) + }) + + t.Run("on while not on vacation does not touch GitHub", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, false) + info := newInfo(false) + env.expectUser(vacationUser(t, nil)) + env.expectState(t, vacationSyncState{}) + env.store.EXPECT().Set(MockUserID+githubTokenKey, gomock.Any()).Return(true, nil) + + result := env.p.handleSettings(nil, nil, []string{settingVacationSync, settingOn}, info) + + assert.Equal(t, "Settings updated.", result) + assert.Empty(t, env.requests) + }) + + t.Run("off while Busy is applied clears the GitHub status", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, true) + info := newInfo(true) + env.expectState(t, vacationSyncState{Applied: true}) + env.store.EXPECT().Set(env.key, vacationSyncState{}).Return(true, nil) + env.store.EXPECT().Set(MockUserID+githubTokenKey, gomock.Any()).Return(true, nil) + + result := env.p.handleSettings(nil, nil, []string{settingVacationSync, settingOff}, info) + + assert.Equal(t, "Settings updated.", result) + assert.False(t, info.Settings.SyncVacationStatus) + input := receiveMutationInput(t, env.requests) + assert.Equal(t, false, input["limitedAvailability"]) + }) + + t.Run("off without an applied status does not touch GitHub", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, true) + info := newInfo(true) + env.expectState(t, vacationSyncState{}) + env.store.EXPECT().Set(MockUserID+githubTokenKey, gomock.Any()).Return(true, nil) + + result := env.p.handleSettings(nil, nil, []string{settingVacationSync, settingOff}, info) + + assert.Equal(t, "Settings updated.", result) + assert.Empty(t, env.requests) + }) + + t.Run("invalid value", func(t *testing.T) { + env := setupVacationSyncTest(t, http.StatusOK, false) + + result := env.p.handleSettings(nil, nil, []string{settingVacationSync, "maybe"}, newInfo(false)) + + assert.Equal(t, "Invalid value. Accepted values are: \"on\" or \"off\".", result) + }) +}