From 8394af69a813c0ec9f5e03f3bf451d21aad54141 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 06:43:14 +0000 Subject: [PATCH 1/2] feat: Sync Mattermost OOO status to GitHub Adds a feature to automatically sync a user's Mattermost "Out of Office" status to their GitHub status. When a user sets their status to "Out of Office" in Mattermost, their GitHub status will be set to "Busy" with the message "Out of office". When the user's status is no longer "Out of Office", their original GitHub status will be restored. This is implemented using the `UserStatusHasChanged` hook in the Mattermost plugin API and the `changeUserStatus` GraphQL mutation in the GitHub API. The user's original GitHub status is stored in the plugin's key-value store. --- server/plugin/graphql/client.go | 49 ++++++++++++++++++++++++++ server/plugin/plugin.go | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/server/plugin/graphql/client.go b/server/plugin/graphql/client.go index 53083b03a..a98dda56f 100644 --- a/server/plugin/graphql/client.go +++ b/server/plugin/graphql/client.go @@ -64,3 +64,52 @@ func (c *Client) executeQuery(ctx context.Context, qry interface{}, params map[s return nil } + +type changeUserStatusMutation struct { + ChangeUserStatus struct { + Status struct { + Message githubv4.String + Emoji githubv4.String + } + } `graphql:"changeUserStatus(input: $input)"` +} + +func (c *Client) UpdateUserStatus(ctx context.Context, emoji, message string, busy bool) (string, error) { + var mutation changeUserStatusMutation + input := githubv4.ChangeUserStatusInput{ + Emoji: githubv4.NewString(githubv4.String(emoji)), + Message: githubv4.NewString(githubv4.String(message)), + LimitedAvailability: githubv4.NewBoolean(githubv4.Boolean(busy)), + } + + err := c.client.Mutate(ctx, &mutation, input, nil) + if err != nil { + return "", err + } + + return string(mutation.ChangeUserStatus.Status.Message), nil +} + +type getUserStatusQuery struct { + User struct { + Status struct { + Message githubv4.String + Emoji githubv4.String + LimitedAvailability githubv4.Boolean + } + } `graphql:"user(login: $login)"` +} + +func (c *Client) GetUserStatus(ctx context.Context, login string) (string, string, bool, error) { + var query getUserStatusQuery + variables := map[string]interface{}{ + "login": githubv4.String(login), + } + + err := c.client.Query(ctx, &query, variables) + if err != nil { + return "", "", false, err + } + + return string(query.User.Status.Message), string(query.User.Status.Emoji), bool(query.User.Status.LimitedAvailability), nil +} diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 82ce05b6e..b81eec446 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -627,6 +627,12 @@ type UserSettings struct { Notifications bool `json:"notifications"` } +type GithubStatus struct { + Message string `json:"message"` + Emoji string `json:"emoji"` + Busy bool `json:"busy"` +} + func (p *Plugin) storeGitHubUserInfo(info *GitHubUserInfo) error { config := p.getConfiguration() @@ -1192,3 +1198,59 @@ func (p *Plugin) handleRevokedToken(info *GitHubUserInfo) { p.disconnectGitHubAccount(info.UserID) p.CreateBotDMPost(info.UserID, "Your Github account was disconnected due to an invalid or revoked authorization token. Reconnect your account using the `/github connect` command.", "custom_git_revoked_token") } + +func (p *Plugin) UserStatusHasChanged(c *plugin.Context, userStatus *model.Status) { + userInfo, apiErr := p.getGitHubUserInfo(userStatus.UserId) + if apiErr != nil { + return + } + + if userStatus.Status == "ooo" { + graphQLClient := p.graphQLConnect(userInfo) + message, emoji, busy, err := graphQLClient.GetUserStatus(context.Background(), userInfo.GitHubUsername) + if err != nil { + p.client.Log.Error("failed to get user status", "error", err) + return + } + + githubStatus := &GithubStatus{ + Message: message, + Emoji: emoji, + Busy: busy, + } + + githubStatusJSON, err := json.Marshal(githubStatus) + if err != nil { + p.client.Log.Error("failed to marshal github status", "error", err) + return + } + + p.store.Set(userInfo.UserID+"_github_status", githubStatusJSON) + + _, err = graphQLClient.UpdateUserStatus(context.Background(), ":house_with_garden:", "Out of office", true) + if err != nil { + p.client.Log.Error("failed to update user status", "error", err) + return + } + + p.CreateBotDMPost(userInfo.UserID, "Your GitHub status has been updated to Out of office.", "custom_git_ooo_ephemeral") + } else { + var oldStatus []byte + if err := p.store.Get(userInfo.UserID+"_github_status", &oldStatus); err == nil && len(oldStatus) > 0 { + var githubStatus GithubStatus + if err := json.Unmarshal(oldStatus, &githubStatus); err != nil { + p.client.Log.Error("failed to unmarshal github status", "error", err) + return + } + + graphQLClient := p.graphQLConnect(userInfo) + _, err := graphQLClient.UpdateUserStatus(context.Background(), githubStatus.Emoji, githubStatus.Message, githubStatus.Busy) + if err != nil { + p.client.Log.Error("failed to update user status", "error", err) + return + } + p.store.Delete(userInfo.UserID + "_github_status") + p.CreateBotDMPost(userInfo.UserID, "Your GitHub status has been restored.", "custom_git_ooo_ephemeral") + } + } +} From 921e60d37b120daaa2feac582c2e53886c95811c Mon Sep 17 00:00:00 2001 From: Akshat Khosya Date: Fri, 14 Nov 2025 11:18:10 +0530 Subject: [PATCH 2/2] Address PR feedback: Make status sync opt-in and fix issues - Add EnableStatusSync config option (opt-in, default: false) - Check if GitHub status is already busy before overwriting - Clear stored GitHub status from KV store on account disconnect - Fix misleading post type name (remove "ephemeral") - Add error wrapping for better traceability in GraphQL client - Format code with gofmt Addresses feedback from reviewers: - System-wide opt-in configuration - Respects manually set GitHub OOO statuses - Proper cleanup on disconnect - Improved error messages --- plugin.json | 7 +++++++ server/plugin/configuration.go | 1 + server/plugin/graphql/client.go | 8 ++++---- server/plugin/plugin.go | 20 ++++++++++++++++++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/plugin.json b/plugin.json index ff5289d46..184ce3638 100644 --- a/plugin.json +++ b/plugin.json @@ -134,6 +134,13 @@ "type": "bool", "help_text": "When set to 'true' you will get a notification with less details when a draft pull request is created and a notification with complete details when they are marked as ready for review. When set to 'false' no notifications are delivered for draft pull requests.", "default": false + }, + { + "key": "EnableStatusSync", + "display_name": "Enable Status Synchronization:", + "type": "bool", + "help_text": "(Optional) When enabled, users can opt-in to automatically sync their Mattermost 'Out of Office' status to GitHub. When a user sets their status to OOO in Mattermost, their GitHub status will be updated to 'Out of office' with busy indicator. The status is restored when they return. The feature respects manually set GitHub statuses and will not overwrite them.", + "default": false } ], "footer": "* To report an issue, make a suggestion or a contribution, [check the repository](https://github.com/mattermost/mattermost-plugin-github)." diff --git a/server/plugin/configuration.go b/server/plugin/configuration.go index 38dcc3dcf..902e9c037 100644 --- a/server/plugin/configuration.go +++ b/server/plugin/configuration.go @@ -43,6 +43,7 @@ type Configuration struct { UsePreregisteredApplication bool `json:"usepreregisteredapplication"` ShowAuthorInCommitNotification bool `json:"showauthorincommitnotification"` GetNotificationForDraftPRs bool `json:"getnotificationfordraftprs"` + EnableStatusSync bool `json:"enablestatussync"` } func (c *Configuration) ToMap() (map[string]interface{}, error) { diff --git a/server/plugin/graphql/client.go b/server/plugin/graphql/client.go index a98dda56f..d3f9e2b04 100644 --- a/server/plugin/graphql/client.go +++ b/server/plugin/graphql/client.go @@ -77,14 +77,14 @@ type changeUserStatusMutation struct { func (c *Client) UpdateUserStatus(ctx context.Context, emoji, message string, busy bool) (string, error) { var mutation changeUserStatusMutation input := githubv4.ChangeUserStatusInput{ - Emoji: githubv4.NewString(githubv4.String(emoji)), - Message: githubv4.NewString(githubv4.String(message)), + Emoji: githubv4.NewString(githubv4.String(emoji)), + Message: githubv4.NewString(githubv4.String(message)), LimitedAvailability: githubv4.NewBoolean(githubv4.Boolean(busy)), } err := c.client.Mutate(ctx, &mutation, input, nil) if err != nil { - return "", err + return "", errors.Wrap(err, "UpdateUserStatus mutate failed") } return string(mutation.ChangeUserStatus.Status.Message), nil @@ -108,7 +108,7 @@ func (c *Client) GetUserStatus(ctx context.Context, login string) (string, strin err := c.client.Query(ctx, &query, variables) if err != nil { - return "", "", false, err + return "", "", false, errors.Wrap(err, "GetUserStatus query failed") } return string(query.User.Status.Message), string(query.User.Status.Emoji), bool(query.User.Status.LimitedAvailability), nil diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index b81eec446..6b1e0c8ae 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -717,6 +717,10 @@ func (p *Plugin) disconnectGitHubAccount(userID string) { p.client.Log.Warn("Failed to delete github token from KV store", "userID", userID, "error", err.Error()) } + if err := p.store.Delete(userID + "_github_status"); err != nil { + p.client.Log.Warn("Failed to delete github status from KV store", "userID", userID, "error", err.Error()) + } + user, err := p.client.User.Get(userID) if err != nil { p.client.Log.Warn("Failed to get user props", "userID", userID, "error", err.Error()) @@ -1200,6 +1204,12 @@ func (p *Plugin) handleRevokedToken(info *GitHubUserInfo) { } func (p *Plugin) UserStatusHasChanged(c *plugin.Context, userStatus *model.Status) { + // Check if status sync is enabled in configuration + config := p.getConfiguration() + if !config.EnableStatusSync { + return + } + userInfo, apiErr := p.getGitHubUserInfo(userStatus.UserId) if apiErr != nil { return @@ -1213,6 +1223,12 @@ func (p *Plugin) UserStatusHasChanged(c *plugin.Context, userStatus *model.Statu return } + // Don't overwrite if GitHub status is already set to "out of office" (busy) + if busy { + p.client.Log.Debug("GitHub status is already set to busy/OOO, skipping update") + return + } + githubStatus := &GithubStatus{ Message: message, Emoji: emoji, @@ -1233,7 +1249,7 @@ func (p *Plugin) UserStatusHasChanged(c *plugin.Context, userStatus *model.Statu return } - p.CreateBotDMPost(userInfo.UserID, "Your GitHub status has been updated to Out of office.", "custom_git_ooo_ephemeral") + p.CreateBotDMPost(userInfo.UserID, "Your GitHub status has been updated to Out of office.", "custom_git_status_sync") } else { var oldStatus []byte if err := p.store.Get(userInfo.UserID+"_github_status", &oldStatus); err == nil && len(oldStatus) > 0 { @@ -1250,7 +1266,7 @@ func (p *Plugin) UserStatusHasChanged(c *plugin.Context, userStatus *model.Statu return } p.store.Delete(userInfo.UserID + "_github_status") - p.CreateBotDMPost(userInfo.UserID, "Your GitHub status has been restored.", "custom_git_ooo_ephemeral") + p.CreateBotDMPost(userInfo.UserID, "Your GitHub status has been restored.", "custom_git_status_sync") } } }