diff --git a/go.mod b/go.mod index 02bbd74bf..eb6cd5b66 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,8 @@ require ( github.com/rudderlabs/analytics-go v3.3.2+incompatible // indirect github.com/segmentio/backo-go v1.0.1 // indirect github.com/shopspring/decimal v1.2.0 // indirect + github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9 + github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect github.com/sirupsen/logrus v1.9.0 // indirect github.com/spf13/cast v1.3.1 // indirect github.com/stretchr/objx v0.4.0 // indirect diff --git a/go.sum b/go.sum index 4c5461d06..ae29971af 100644 --- a/go.sum +++ b/go.sum @@ -206,10 +206,14 @@ github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= +github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9 h1:nCBaIs5/R0HFP5+aPW/SzFUF8z0oKuCXmuDmHWaxzjY= +github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= +github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 h1:B1PEwpArrNp4dkQrfxh/abbBAOZBVp0ds+fBEOUOqOc= +github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= diff --git a/server/plugin/api.go b/server/plugin/api.go index 97103c152..8dd636d59 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -57,6 +57,18 @@ type PRDetails struct { Reviews []*github.PullRequestReview `json:"reviews"` } +type FilteredNotification struct { + github.Notification + HTMLURL string `json:"html_url"` +} + +type SidebarContent struct { + PRs []*github.Issue `json:"prs"` + Reviews []*github.Issue `json:"reviews"` + Assignments []*github.Issue `json:"assignments"` + Unreads []*FilteredNotification `json:"unreads"` +} + type Context struct { Ctx context.Context UserID string @@ -134,15 +146,11 @@ func (p *Plugin) initializeAPI() { apiRouter.HandleFunc("/user", p.checkAuth(p.attachContext(p.getGitHubUser), ResponseTypeJSON)).Methods(http.MethodPost) apiRouter.HandleFunc("/todo", p.checkAuth(p.attachUserContext(p.postToDo), ResponseTypeJSON)).Methods(http.MethodPost) - apiRouter.HandleFunc("/reviews", p.checkAuth(p.attachUserContext(p.getReviews), ResponseTypePlain)).Methods(http.MethodGet) - apiRouter.HandleFunc("/yourprs", p.checkAuth(p.attachUserContext(p.getYourPrs), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/prsdetails", p.checkAuth(p.attachUserContext(p.getPrsDetails), ResponseTypePlain)).Methods(http.MethodPost) apiRouter.HandleFunc("/searchissues", p.checkAuth(p.attachUserContext(p.searchIssues), ResponseTypePlain)).Methods(http.MethodGet) - apiRouter.HandleFunc("/yourassignments", p.checkAuth(p.attachUserContext(p.getYourAssignments), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/createissue", p.checkAuth(p.attachUserContext(p.createIssue), ResponseTypePlain)).Methods(http.MethodPost) apiRouter.HandleFunc("/createissuecomment", p.checkAuth(p.attachUserContext(p.createIssueComment), ResponseTypePlain)).Methods(http.MethodPost) apiRouter.HandleFunc("/mentions", p.checkAuth(p.attachUserContext(p.getMentions), ResponseTypePlain)).Methods(http.MethodGet) - apiRouter.HandleFunc("/unreads", p.checkAuth(p.attachUserContext(p.getUnreads), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/labels", p.checkAuth(p.attachUserContext(p.getLabels), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/milestones", p.checkAuth(p.attachUserContext(p.getMilestones), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/assignees", p.checkAuth(p.attachUserContext(p.getAssignees), ResponseTypePlain)).Methods(http.MethodGet) @@ -150,6 +158,7 @@ func (p *Plugin) initializeAPI() { apiRouter.HandleFunc("/settings", p.checkAuth(p.attachUserContext(p.updateSettings), ResponseTypePlain)).Methods(http.MethodPost) apiRouter.HandleFunc("/issue", p.checkAuth(p.attachUserContext(p.getIssueByNumber), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/pr", p.checkAuth(p.attachUserContext(p.getPrByNumber), ResponseTypePlain)).Methods(http.MethodGet) + apiRouter.HandleFunc("/lhs-content", p.checkAuth(p.attachUserContext(p.getSidebarContent), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/config", checkPluginRequest(p.getConfig)).Methods(http.MethodGet) apiRouter.HandleFunc("/token", checkPluginRequest(p.getToken)).Methods(http.MethodGet) @@ -651,22 +660,16 @@ func (p *Plugin) getMentions(c *UserContext, w http.ResponseWriter, r *http.Requ p.writeJSON(w, result.Issues) } -func (p *Plugin) getUnreads(c *UserContext, w http.ResponseWriter, r *http.Request) { +func (p *Plugin) getUnreadsData(c *UserContext) []*FilteredNotification { githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) notifications, _, err := githubClient.Activity.ListNotifications(c.Ctx, &github.NotificationListOptions{}) if err != nil { c.Log.WithError(err).Warnf("Failed to list notifications") - return - } - - type filteredNotification struct { - github.Notification - - HTMLUrl string `json:"html_url"` + return nil } - filteredNotifications := []*filteredNotification{} + filteredNotifications := []*FilteredNotification{} for _, n := range notifications { if n.GetReason() == notificationReasonSubscribed { continue @@ -684,45 +687,13 @@ func (p *Plugin) getUnreads(c *UserContext, w http.ResponseWriter, r *http.Reque subjectURL = n.GetSubject().GetLatestCommentURL() } - filteredNotifications = append(filteredNotifications, &filteredNotification{ + filteredNotifications = append(filteredNotifications, &FilteredNotification{ Notification: *n, - HTMLUrl: fixGithubNotificationSubjectURL(subjectURL, issueNum), + HTMLURL: fixGithubNotificationSubjectURL(subjectURL, issueNum), }) } - p.writeJSON(w, filteredNotifications) -} - -func (p *Plugin) getReviews(c *UserContext, w http.ResponseWriter, r *http.Request) { - config := p.getConfiguration() - - githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) - username := c.GHInfo.GitHubUsername - - query := getReviewSearchQuery(username, config.GitHubOrg) - result, _, err := githubClient.Search.Issues(c.Ctx, query, &github.SearchOptions{}) - if err != nil { - c.Log.WithError(err).With(logger.LogContext{"query": query}).Warnf("Failed to search for review") - return - } - - p.writeJSON(w, result.Issues) -} - -func (p *Plugin) getYourPrs(c *UserContext, w http.ResponseWriter, r *http.Request) { - config := p.getConfiguration() - - githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) - username := c.GHInfo.GitHubUsername - - query := getYourPrsSearchQuery(username, config.GitHubOrg) - result, _, err := githubClient.Search.Issues(c.Ctx, query, &github.SearchOptions{}) - if err != nil { - c.Log.WithError(err).With(logger.LogContext{"query": query}).Warnf("Failed to search for PRs") - return - } - - p.writeJSON(w, result.Issues) + return filteredNotifications } func (p *Plugin) getPrsDetails(c *UserContext, w http.ResponseWriter, r *http.Request) { @@ -967,20 +938,39 @@ func (p *Plugin) createIssueComment(c *UserContext, w http.ResponseWriter, r *ht p.writeJSON(w, result) } -func (p *Plugin) getYourAssignments(c *UserContext, w http.ResponseWriter, r *http.Request) { - config := p.getConfiguration() +func (p *Plugin) getLHSData(c *UserContext) (reviewResp []*github.Issue, assignmentResp []*github.Issue, openPRResp []*github.Issue, err error) { + graphQLClient := p.graphQLConnect(c.GHInfo) - githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) + reviewResp, assignmentResp, openPRResp, err = graphQLClient.GetLHSData(c.Context.Ctx) + if err != nil { + return []*github.Issue{}, []*github.Issue{}, []*github.Issue{}, err + } - username := c.GHInfo.GitHubUsername - query := getYourAssigneeSearchQuery(username, config.GitHubOrg) - result, _, err := githubClient.Search.Issues(c.Ctx, query, &github.SearchOptions{}) + return reviewResp, assignmentResp, openPRResp, nil +} + +func (p *Plugin) getSidebarData(c *UserContext) (*SidebarContent, error) { + reviewResp, assignmentResp, openPRResp, err := p.getLHSData(c) if err != nil { - c.Log.WithError(err).With(logger.LogContext{"query": query}).Warnf("Failed to search for assignments") + return nil, err + } + + return &SidebarContent{ + PRs: openPRResp, + Assignments: assignmentResp, + Reviews: reviewResp, + Unreads: p.getUnreadsData(c), + }, nil +} + +func (p *Plugin) getSidebarContent(c *UserContext, w http.ResponseWriter, r *http.Request) { + sidebarContent, err := p.getSidebarData(c) + if err != nil { + c.Log.WithError(err).Warnf("Failed to search for the sidebar data") return } - p.writeJSON(w, result.Issues) + p.writeJSON(w, sidebarContent) } func (p *Plugin) postToDo(c *UserContext, w http.ResponseWriter, r *http.Request) { diff --git a/server/plugin/api_test.go b/server/plugin/api_test.go index c1e9ad81c..e2f7d7f74 100644 --- a/server/plugin/api_test.go +++ b/server/plugin/api_test.go @@ -88,7 +88,7 @@ func TestPlugin_ServeHTTP(t *testing.T) { httpTest: httpTestString, request: testutils.Request{ Method: http.MethodGet, - URL: "/api/v1/reviews", + URL: "/api/v1/lhs-content", Body: nil, }, expectedResponse: testutils.ExpectedResponse{ diff --git a/server/plugin/graphql/client.go b/server/plugin/graphql/client.go new file mode 100644 index 000000000..6563cd721 --- /dev/null +++ b/server/plugin/graphql/client.go @@ -0,0 +1,62 @@ +package graphql + +import ( + "context" + "net/url" + "path" + + pluginapi "github.com/mattermost/mattermost-plugin-api" + "github.com/pkg/errors" + "github.com/shurcooL/githubv4" + "golang.org/x/oauth2" +) + +// Client encapsulates the third party package that communicates with Github GraphQL API +type Client struct { + client *githubv4.Client + org string + username string + logger pluginapi.LogService +} + +// NewClient creates and returns Client. The third party package that queries GraphQL is initialized here. +func NewClient(logger pluginapi.LogService, token oauth2.Token, username, orgName, enterpriseBaseURL string) *Client { + ts := oauth2.StaticTokenSource(&token) + httpClient := oauth2.NewClient(context.Background(), ts) + var client Client + + if enterpriseBaseURL == "" { + client = Client{ + username: username, + client: githubv4.NewClient(httpClient), + logger: logger, + org: orgName, + } + } else { + baseURL, err := url.Parse(enterpriseBaseURL) + if err != nil { + logger.Debug("Not able to parse the URL", "Error", err.Error()) + return nil + } + + baseURL.Path = path.Join(baseURL.Path, "api", "graphql") + + client = Client{ + client: githubv4.NewEnterpriseClient(baseURL.String(), httpClient), + username: username, + org: orgName, + logger: logger, + } + } + + return &client +} + +// executeQuery takes a query struct and sends it to Github GraphQL API via helper package. +func (c *Client) executeQuery(ctx context.Context, qry interface{}, params map[string]interface{}) error { + if err := c.client.Query(ctx, qry, params); err != nil { + return errors.Wrap(err, "error in executing query") + } + + return nil +} diff --git a/server/plugin/graphql/lhs_query.go b/server/plugin/graphql/lhs_query.go new file mode 100644 index 000000000..29c17a76c --- /dev/null +++ b/server/plugin/graphql/lhs_query.go @@ -0,0 +1,91 @@ +package graphql + +import ( + "github.com/shurcooL/githubv4" +) + +type ( + repositoryQuery struct { + Name githubv4.String + NameWithOwner githubv4.String + URL githubv4.URI + } + + authorQuery struct { + Login githubv4.String + } + + prSearchNodes struct { + PullRequest struct { + Body githubv4.String + Number githubv4.Int + AuthorAssociation githubv4.String + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + Repository repositoryQuery + State githubv4.String + Title githubv4.String + Author authorQuery + URL githubv4.URI + } `graphql:"... on PullRequest"` + } +) + +type ( + assignmentSearchNodes struct { + Issue struct { + Body githubv4.String + Number githubv4.Int + AuthorAssociation githubv4.String + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + Repository repositoryQuery + State githubv4.String + Title githubv4.String + Author authorQuery + URL githubv4.URI + } `graphql:"... on Issue"` + + PullRequest struct { + Body githubv4.String + Number githubv4.Int + AuthorAssociation githubv4.String + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + Repository repositoryQuery + State githubv4.String + Title githubv4.String + Author authorQuery + URL githubv4.URI + } `graphql:"... on PullRequest"` + } +) + +var mainQuery struct { + ReviewRequests struct { + IssueCount int + Nodes []prSearchNodes + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + } `graphql:"pullRequest: search(first:100, after:$reviewsCursor, query: $prReviewQueryArg, type: ISSUE)"` + + Assignments struct { + IssueCount int + Nodes []assignmentSearchNodes + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + } `graphql:"assignee: search(first:100, after:$assignmentsCursor, query: $assigneeQueryArg, type: ISSUE)"` + + OpenPullRequests struct { + IssueCount int + Nodes []prSearchNodes + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + } `graphql:"graphql: search(first:100, after:$openPrsCursor, query: $prOpenQueryArg, type: ISSUE)"` +} diff --git a/server/plugin/graphql/lhs_request.go b/server/plugin/graphql/lhs_request.go new file mode 100644 index 000000000..8c7d2db0c --- /dev/null +++ b/server/plugin/graphql/lhs_request.go @@ -0,0 +1,128 @@ +package graphql + +import ( + "context" + "fmt" + + "github.com/google/go-github/v41/github" + "github.com/pkg/errors" + "github.com/shurcooL/githubv4" +) + +const ( + queryParamReviewsCursor = "reviewsCursor" + queryParamAssignmentsCursor = "assignmentsCursor" + queryParamOpenPRsCursor = "openPrsCursor" + + queryParamOpenPRQueryArg = "prOpenQueryArg" + queryParamReviewPRQueryArg = "prReviewQueryArg" + queryParamAssigneeQueryArg = "assigneeQueryArg" +) + +func (c *Client) GetLHSData(ctx context.Context) ([]*github.Issue, []*github.Issue, []*github.Issue, error) { + params := map[string]interface{}{ + queryParamOpenPRQueryArg: githubv4.String(fmt.Sprintf("author:%s is:pr is:%s archived:false", c.username, githubv4.PullRequestStateOpen)), + queryParamReviewPRQueryArg: githubv4.String(fmt.Sprintf("review-requested:%s is:pr is:%s archived:false", c.username, githubv4.PullRequestStateOpen)), + queryParamAssigneeQueryArg: githubv4.String(fmt.Sprintf("assignee:%s is:%s archived:false", c.username, githubv4.PullRequestStateOpen)), + queryParamReviewsCursor: (*githubv4.String)(nil), + queryParamAssignmentsCursor: (*githubv4.String)(nil), + queryParamOpenPRsCursor: (*githubv4.String)(nil), + } + + if c.org != "" { + params[queryParamOpenPRQueryArg] = githubv4.String(fmt.Sprintf("org:%s %s", c.org, params[queryParamOpenPRQueryArg])) + params[queryParamReviewPRQueryArg] = githubv4.String(fmt.Sprintf("org:%s %s", c.org, params[queryParamReviewPRQueryArg])) + params[queryParamAssigneeQueryArg] = githubv4.String(fmt.Sprintf("org:%s %s", c.org, params[queryParamAssigneeQueryArg])) + } + + var resultReview, resultAssignee, resultOpenPR []*github.Issue + allReviewRequestsFetched, allAssignmentsFetched, allOpenPRsFetched := false, false, false + + for { + if allReviewRequestsFetched && allAssignmentsFetched && allOpenPRsFetched { + break + } + + if err := c.executeQuery(ctx, &mainQuery, params); err != nil { + return nil, nil, nil, errors.Wrap(err, "Not able to excute the query") + } + + if !allReviewRequestsFetched { + for i := range mainQuery.ReviewRequests.Nodes { + resp := mainQuery.ReviewRequests.Nodes[i] + pr := getPR(&resp) + resultReview = append(resultReview, pr) + } + + if !mainQuery.ReviewRequests.PageInfo.HasNextPage { + allReviewRequestsFetched = true + } + + params[queryParamReviewsCursor] = githubv4.NewString(mainQuery.ReviewRequests.PageInfo.EndCursor) + } + + if !allAssignmentsFetched { + for i := range mainQuery.Assignments.Nodes { + resp := mainQuery.Assignments.Nodes[i] + issue := getIssue(&resp) + resultAssignee = append(resultAssignee, issue) + } + + if !mainQuery.Assignments.PageInfo.HasNextPage { + allAssignmentsFetched = true + } + + params[queryParamAssignmentsCursor] = githubv4.NewString(mainQuery.Assignments.PageInfo.EndCursor) + } + + if !allOpenPRsFetched { + for i := range mainQuery.OpenPullRequests.Nodes { + resp := mainQuery.OpenPullRequests.Nodes[i] + pr := getPR(&resp) + resultOpenPR = append(resultOpenPR, pr) + } + + if !mainQuery.OpenPullRequests.PageInfo.HasNextPage { + allOpenPRsFetched = true + } + + params[queryParamOpenPRsCursor] = githubv4.NewString(mainQuery.OpenPullRequests.PageInfo.EndCursor) + } + } + + return resultReview, resultAssignee, resultOpenPR, nil +} + +func getPR(prResp *prSearchNodes) *github.Issue { + resp := prResp.PullRequest + + return getGithubIssue(resp.Number, resp.Title, resp.Author.Login, resp.Repository.URL, resp.URL, resp.CreatedAt, resp.UpdatedAt) +} + +func getIssue(assignmentResp *assignmentSearchNodes) *github.Issue { + resp := assignmentResp.PullRequest + + return getGithubIssue(resp.Number, resp.Title, resp.Author.Login, resp.Repository.URL, resp.URL, resp.CreatedAt, resp.UpdatedAt) +} + +func getGithubIssue(prNumber githubv4.Int, title, login githubv4.String, repositoryURL, htmlURL githubv4.URI, createdAt, updatedAt githubv4.DateTime) *github.Issue { + number := int(prNumber) + repoURL := repositoryURL.String() + issuetitle := string(title) + userLogin := (string)(login) + url := htmlURL.String() + createdAtTime := createdAt.Time + updatedAtTime := updatedAt.Time + + return &github.Issue{ + Number: &number, + RepositoryURL: &repoURL, + Title: &issuetitle, + CreatedAt: &createdAtTime, + UpdatedAt: &updatedAtTime, + User: &github.User{ + Login: &userLogin, + }, + HTMLURL: &url, + } +} diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 74748ff88..891880682 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -2,6 +2,7 @@ package plugin import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -14,6 +15,7 @@ import ( "github.com/google/go-github/v41/github" "github.com/gorilla/mux" pluginapi "github.com/mattermost/mattermost-plugin-api" + "github.com/mattermost/mattermost-plugin-api/experimental/bot/logger" "github.com/mattermost/mattermost-plugin-api/experimental/bot/poster" "github.com/mattermost/mattermost-plugin-api/experimental/telemetry" "github.com/mattermost/mattermost-server/v6/model" @@ -22,6 +24,7 @@ import ( "golang.org/x/oauth2" root "github.com/mattermost/mattermost-plugin-github" + "github.com/mattermost/mattermost-plugin-github/server/plugin/graphql" ) const ( @@ -158,6 +161,11 @@ func (p *Plugin) githubConnectUser(ctx context.Context, info *GitHubUserInfo) *g return p.githubConnectToken(tok) } +func (p *Plugin) graphQLConnect(info *GitHubUserInfo) *graphql.Client { + conf := p.getConfiguration() + return graphql.NewClient(p.client.Log, *info.Token, info.GitHubUsername, conf.GitHubOrg, conf.EnterpriseBaseURL) +} + func (p *Plugin) githubConnectToken(token oauth2.Token) *github.Client { config := p.getConfiguration() @@ -975,13 +983,64 @@ func (p *Plugin) isOrganizationLocked() bool { } func (p *Plugin) sendRefreshEvent(userID string) { + eventLogger := logger.New(p.API).With(logger.LogContext{ + "userid": userID, + }) + + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + + context := &Context{ + Ctx: ctx, + UserID: userID, + Log: eventLogger, + } + + defer cancel() + + info, apiErr := p.getGitHubUserInfo(context.UserID) + if apiErr != nil { + p.API.LogWarn("Failed to get github user info", "error", apiErr.Error()) + return + } + + userContext := &UserContext{ + Context: *context, + GHInfo: info, + } + + sidebarContent, err := p.getSidebarData(userContext) + if err != nil { + p.API.LogWarn("Failed to get the sidebar data", "error", err.Error()) + return + } + + contentMap, err := sidebarContent.toMap() + if err != nil { + p.API.LogWarn("Failed to convert sidebar content to map", "error", err.Error()) + return + } + p.client.Frontend.PublishWebSocketEvent( wsEventRefresh, - nil, + contentMap, &model.WebsocketBroadcast{UserId: userID}, ) } +func (s *SidebarContent) toMap() (map[string]interface{}, error) { + var m map[string]interface{} + bytes, err := json.Marshal(&s) + if err != nil { + return nil, err + } + + if err = json.Unmarshal(bytes, &m); err != nil { + return nil, err + } + + return m, nil +} + // getUsername returns the GitHub username for a given Mattermost user, // if the user is connected to GitHub via this plugin. // Otherwise it return the Mattermost username. It will be escaped via backticks. diff --git a/webapp/src/action_types/index.js b/webapp/src/action_types/index.js index 88da0997b..24480e95d 100644 --- a/webapp/src/action_types/index.js +++ b/webapp/src/action_types/index.js @@ -5,13 +5,10 @@ import {id as pluginId} from '../manifest'; export default { RECEIVED_REPOSITORIES: pluginId + '_received_repositories', - RECEIVED_REVIEWS: pluginId + '_received_reviews', RECEIVED_REVIEWS_DETAILS: pluginId + '_received_reviews_details', - RECEIVED_YOUR_PRS: pluginId + '_received_your_prs', RECEIVED_YOUR_PRS_DETAILS: pluginId + '_received_your_prs_details', - RECEIVED_YOUR_ASSIGNMENTS: pluginId + '_received_your_assignments', + RECEIVED_SIDEBAR_CONTENT: pluginId + '_received_sidebar_content', RECEIVED_MENTIONS: pluginId + '_received_mentions', - RECEIVED_UNREADS: pluginId + '_received_unreads', RECEIVED_CONNECTED: pluginId + '_received_connected', RECEIVED_CONFIGURATION: pluginId + '_received_configuration', RECEIVED_GITHUB_USER: pluginId + '_received_github_user', diff --git a/webapp/src/actions/index.js b/webapp/src/actions/index.js index 1788ae959..16998eccb 100644 --- a/webapp/src/actions/index.js +++ b/webapp/src/actions/index.js @@ -42,29 +42,6 @@ function checkAndHandleNotConnected(data) { }; } -export function getReviews() { - return async (dispatch, getState) => { - let data; - try { - data = await Client.getReviews(); - } catch (error) { - return {error}; - } - - const connected = await checkAndHandleNotConnected(data)(dispatch, getState); - if (!connected) { - return {error: data}; - } - - dispatch({ - type: ActionTypes.RECEIVED_REVIEWS, - data, - }); - - return {data}; - }; -} - export function getReviewsDetails(prList) { return async (dispatch, getState) => { let data; @@ -111,11 +88,11 @@ export function getRepos() { }; } -export function getYourPrs() { +export function getSidebarContent() { return async (dispatch, getState) => { let data; try { - data = await Client.getYourPrs(); + data = await Client.getSidebarContent(); } catch (error) { return {error}; } @@ -126,7 +103,7 @@ export function getYourPrs() { } dispatch({ - type: ActionTypes.RECEIVED_YOUR_PRS, + type: ActionTypes.RECEIVED_SIDEBAR_CONTENT, data, }); @@ -211,29 +188,6 @@ export function getMilestoneOptions(repo) { }; } -export function getYourAssignments() { - return async (dispatch, getState) => { - let data; - try { - data = await Client.getYourAssignments(); - } catch (error) { - return {error}; - } - - const connected = await checkAndHandleNotConnected(data)(dispatch, getState); - if (!connected) { - return {error: data}; - } - - dispatch({ - type: ActionTypes.RECEIVED_YOUR_ASSIGNMENTS, - data, - }); - - return {data}; - }; -} - export function getMentions() { return async (dispatch, getState) => { let data; @@ -257,29 +211,6 @@ export function getMentions() { }; } -export function getUnreads() { - return async (dispatch, getState) => { - let data; - try { - data = await Client.getUnreads(); - } catch (error) { - return {error}; - } - - const connected = await checkAndHandleNotConnected(data)(dispatch, getState); - if (!connected) { - return {error: data}; - } - - dispatch({ - type: ActionTypes.RECEIVED_UNREADS, - data, - }); - - return {data}; - }; -} - const GITHUB_USER_GET_TIMEOUT_MILLISECONDS = 1000 * 60 * 60; // 1 hour export function getGitHubUser(userID) { diff --git a/webapp/src/client/client.js b/webapp/src/client/client.js index 25d6f3f9c..7358dbfba 100644 --- a/webapp/src/client/client.js +++ b/webapp/src/client/client.js @@ -15,30 +15,18 @@ export default class Client { return this.doGet(`${this.url}/connected?reminder=${reminder}`); } - getReviews = async () => { - return this.doGet(`${this.url}/reviews`); - } - - getYourPrs = async () => { - return this.doGet(`${this.url}/yourprs`); + getSidebarContent = async () => { + return this.doGet(`${this.url}/lhs-content`); } getPrsDetails = async (prList) => { return this.doPost(`${this.url}/prsdetails`, prList); } - getYourAssignments = async () => { - return this.doGet(`${this.url}/yourassignments`); - } - getMentions = async () => { return this.doGet(`${this.url}/mentions`); } - getUnreads = async () => { - return this.doGet(`${this.url}/unreads`); - } - getGitHubUser = async (userID) => { return this.doPost(`${this.url}/user`, {user_id: userID}); } diff --git a/webapp/src/components/sidebar_buttons/index.js b/webapp/src/components/sidebar_buttons/index.js index 0ebdff14f..862a1d02c 100644 --- a/webapp/src/components/sidebar_buttons/index.js +++ b/webapp/src/components/sidebar_buttons/index.js @@ -4,7 +4,7 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {getConnected, getReviews, getUnreads, getYourAssignments, getYourPrs, updateRhsState} from '../../actions'; +import {getConnected, updateRhsState, getSidebarContent} from '../../actions'; import {id as pluginId} from '../../manifest'; @@ -14,10 +14,10 @@ function mapStateToProps(state) { return { connected: state[`plugins-${pluginId}`].connected, clientId: state[`plugins-${pluginId}`].clientId, - reviews: state[`plugins-${pluginId}`].reviews, - yourPrs: state[`plugins-${pluginId}`].yourPrs, - yourAssignments: state[`plugins-${pluginId}`].yourAssignments, - unreads: state[`plugins-${pluginId}`].unreads, + reviews: state[`plugins-${pluginId}`].sidebarContent.reviews, + yourPrs: state[`plugins-${pluginId}`].sidebarContent.prs, + yourAssignments: state[`plugins-${pluginId}`].sidebarContent.assignments, + unreads: state[`plugins-${pluginId}`].sidebarContent.unreads, enterpriseURL: state[`plugins-${pluginId}`].enterpriseURL, showRHSPlugin: state[`plugins-${pluginId}`].rhsPluginAction, }; @@ -27,11 +27,8 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ getConnected, - getReviews, - getUnreads, - getYourPrs, - getYourAssignments, updateRhsState, + getSidebarContent, }, dispatch), }; } diff --git a/webapp/src/components/sidebar_buttons/sidebar_buttons.jsx b/webapp/src/components/sidebar_buttons/sidebar_buttons.jsx index 079f9cda1..63d1d5c8b 100644 --- a/webapp/src/components/sidebar_buttons/sidebar_buttons.jsx +++ b/webapp/src/components/sidebar_buttons/sidebar_buttons.jsx @@ -22,10 +22,7 @@ export default class SidebarButtons extends React.PureComponent { showRHSPlugin: PropTypes.func.isRequired, actions: PropTypes.shape({ getConnected: PropTypes.func.isRequired, - getReviews: PropTypes.func.isRequired, - getUnreads: PropTypes.func.isRequired, - getYourPrs: PropTypes.func.isRequired, - getYourAssignments: PropTypes.func.isRequired, + getSidebarContent: PropTypes.func.isRequired, updateRhsState: PropTypes.func.isRequired, }).isRequired, }; @@ -72,12 +69,7 @@ export default class SidebarButtons extends React.PureComponent { } this.setState({refreshing: true}); - await Promise.all([ - this.props.actions.getReviews(), - this.props.actions.getUnreads(), - this.props.actions.getYourPrs(), - this.props.actions.getYourAssignments(), - ]); + await this.props.actions.getSidebarContent(); this.setState({refreshing: false}); } diff --git a/webapp/src/components/sidebar_right/index.jsx b/webapp/src/components/sidebar_right/index.jsx index 746b27bed..f96a08a02 100644 --- a/webapp/src/components/sidebar_right/index.jsx +++ b/webapp/src/components/sidebar_right/index.jsx @@ -5,46 +5,22 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import {getReviewsDetails, getYourPrsDetails} from '../../actions'; -import {id as pluginId} from '../../manifest'; -import SidebarRight from './sidebar_right.jsx'; - -function mapPrsToDetails(prs, details) { - if (!prs) { - return []; - } - - return prs.map((pr) => { - let foundDetails; - if (details) { - foundDetails = details.find((prDetails) => { - return (pr.repository_url === prDetails.url) && (pr.number === prDetails.number); - }); - } - if (!foundDetails) { - return pr; - } +import {getSidebarData} from 'src/selectors'; - return { - ...pr, - status: foundDetails.status, - mergeable: foundDetails.mergeable, - requestedReviewers: foundDetails.requestedReviewers, - reviews: foundDetails.reviews, - }; - }); -} +import SidebarRight from './sidebar_right.jsx'; function mapStateToProps(state) { + const {username, reviews, yourPrs, yourAssignments, unreads, enterpriseURL, org, rhsState} = getSidebarData(state); return { - username: state[`plugins-${pluginId}`].username, - reviews: mapPrsToDetails(state[`plugins-${pluginId}`].reviews, state[`plugins-${pluginId}`].reviewsDetails), - yourPrs: mapPrsToDetails(state[`plugins-${pluginId}`].yourPrs, state[`plugins-${pluginId}`].yourPrsDetails), - yourAssignments: state[`plugins-${pluginId}`].yourAssignments, - unreads: state[`plugins-${pluginId}`].unreads, - enterpriseURL: state[`plugins-${pluginId}`].enterpriseURL, - org: state[`plugins-${pluginId}`].organization, - rhsState: state[`plugins-${pluginId}`].rhsState, + username, + reviews, + yourPrs, + yourAssignments, + unreads, + enterpriseURL, + org, + rhsState, }; } diff --git a/webapp/src/components/sidebar_right/sidebar_right.jsx b/webapp/src/components/sidebar_right/sidebar_right.jsx index 162efdf00..71bb12191 100644 --- a/webapp/src/components/sidebar_right/sidebar_right.jsx +++ b/webapp/src/components/sidebar_right/sidebar_right.jsx @@ -103,37 +103,38 @@ export default class SidebarRight extends React.PureComponent { render() { const baseURL = this.props.enterpriseURL ? this.props.enterpriseURL : 'https://github.com'; const orgQuery = this.props.org ? '+org%3A' + this.props.org : ''; + const {yourPrs, reviews, unreads, yourAssignments, username, rhsState} = this.props; let title = ''; let githubItems = []; let listUrl = ''; - switch (this.props.rhsState) { + switch (rhsState) { case RHSStates.PRS: - githubItems = this.props.yourPrs; + githubItems = yourPrs; title = 'Your Open Pull Requests'; - listUrl = baseURL + '/pulls?q=is%3Aopen+is%3Apr+author%3A' + this.props.username + '+archived%3Afalse' + orgQuery; + listUrl = baseURL + '/pulls?q=is%3Aopen+is%3Apr+author%3A' + username + '+archived%3Afalse' + orgQuery; break; case RHSStates.REVIEWS: - githubItems = this.props.reviews; - listUrl = baseURL + '/pulls?q=is%3Aopen+is%3Apr+review-requested%3A' + this.props.username + '+archived%3Afalse' + orgQuery; + githubItems = reviews; + listUrl = baseURL + '/pulls?q=is%3Aopen+is%3Apr+review-requested%3A' + username + '+archived%3Afalse' + orgQuery; title = 'Pull Requests Needing Review'; break; case RHSStates.UNREADS: - githubItems = this.props.unreads; + githubItems = unreads; title = 'Unread Messages'; listUrl = baseURL + '/notifications'; break; case RHSStates.ASSIGNMENTS: - githubItems = this.props.yourAssignments; + githubItems = yourAssignments; title = 'Your Assignments'; - listUrl = baseURL + '/pulls?q=is%3Aopen+archived%3Afalse+assignee%3A' + this.props.username + orgQuery; + listUrl = baseURL + '/pulls?q=is%3Aopen+archived%3Afalse+assignee%3A' + username + orgQuery; break; default: break; diff --git a/webapp/src/reducers/index.js b/webapp/src/reducers/index.js index 0ebcf5192..39f8f29fa 100644 --- a/webapp/src/reducers/index.js +++ b/webapp/src/reducers/index.js @@ -77,16 +77,7 @@ function clientId(state = '', action) { } } -function reviews(state = [], action) { - switch (action.type) { - case ActionTypes.RECEIVED_REVIEWS: - return action.data; - default: - return state; - } -} - -function reviewsDetails(state = [], action) { +function reviewDetails(state = [], action) { switch (action.type) { case ActionTypes.RECEIVED_REVIEWS_DETAILS: return action.data; @@ -95,9 +86,16 @@ function reviewsDetails(state = [], action) { } } -function yourPrs(state = [], action) { +const defaultSidebarContent = { + reviews: [], + prs: [], + assignments: [], + unreads: [], +}; + +function sidebarContent(state = defaultSidebarContent, action) { switch (action.type) { - case ActionTypes.RECEIVED_YOUR_PRS: + case ActionTypes.RECEIVED_SIDEBAR_CONTENT: return action.data; default: return state; @@ -113,7 +111,7 @@ function yourRepos(state = [], action) { } } -function yourPrsDetails(state = [], action) { +function yourPrDetails(state = [], action) { switch (action.type) { case ActionTypes.RECEIVED_YOUR_PRS_DETAILS: return action.data; @@ -122,15 +120,6 @@ function yourPrsDetails(state = [], action) { } } -function yourAssignments(state = [], action) { - switch (action.type) { - case ActionTypes.RECEIVED_YOUR_ASSIGNMENTS: - return action.data; - default: - return state; - } -} - function mentions(state = [], action) { switch (action.type) { case ActionTypes.RECEIVED_MENTIONS: @@ -140,15 +129,6 @@ function mentions(state = [], action) { } } -function unreads(state = [], action) { - switch (action.type) { - case ActionTypes.RECEIVED_UNREADS: - return action.data; - default: - return state; - } -} - function githubUsers(state = {}, action) { switch (action.type) { case ActionTypes.RECEIVED_GITHUB_USER: { @@ -238,14 +218,10 @@ export default combineReducers({ userSettings, configuration, clientId, - reviews, - reviewsDetails, - yourPrs, + reviewDetails, yourRepos, - yourPrsDetails, - yourAssignments, + yourPrDetails, mentions, - unreads, githubUsers, rhsPluginAction, rhsState, @@ -253,4 +229,5 @@ export default combineReducers({ createIssueModal, attachCommentToIssueModalVisible, attachCommentToIssueModalForPostId, + sidebarContent, }); diff --git a/webapp/src/selectors.js b/webapp/src/selectors.js index a8d3f26de..d6de6526f 100644 --- a/webapp/src/selectors.js +++ b/webapp/src/selectors.js @@ -1,7 +1,11 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {createSelector} from 'reselect'; + import {id as pluginId} from './manifest'; +const emptyArray = []; + const getPluginState = (state) => state['plugins-' + pluginId] || {}; export const isEnabled = (state) => getPluginState(state).enabled; @@ -19,4 +23,46 @@ export const getServerRoute = (state) => { return basePath; }; +function mapPrsToDetails(prs, details) { + if (!prs) { + return []; + } + + return prs.map((pr) => { + let foundDetails; + if (details) { + foundDetails = details.find((prDetails) => { + return (pr.repository_url === prDetails.url) && (pr.number === prDetails.number); + }); + } + if (!foundDetails) { + return pr; + } + + return { + ...pr, + status: foundDetails.status, + mergeable: foundDetails.mergeable, + requestedReviewers: foundDetails.requestedReviewers, + reviews: foundDetails.reviews, + }; + }); +} + +export const getSidebarData = createSelector( + getPluginState, + (pluginState) => { + const {username, sidebarContent, reviewDetails, yourPrDetails, organization, rhsState} = pluginState; + return { + username, + reviews: mapPrsToDetails(sidebarContent.reviews || emptyArray, reviewDetails), + yourPrs: mapPrsToDetails(sidebarContent.prs || emptyArray, yourPrDetails), + yourAssignments: sidebarContent.assignments || emptyArray, + unreads: sidebarContent.unreads || emptyArray, + org: organization, + rhsState, + }; + }, +); + export const configuration = (state) => getPluginState(state).configuration; diff --git a/webapp/src/websocket/index.js b/webapp/src/websocket/index.js index 15021af57..7e871b693 100644 --- a/webapp/src/websocket/index.js +++ b/webapp/src/websocket/index.js @@ -5,15 +5,14 @@ import ActionTypes from '../action_types'; import Constants from '../constants'; import { getConnected, - getReviews, - getUnreads, - getYourAssignments, - getYourPrs, + getSidebarContent, openCreateIssueModalWithoutPost, } from '../actions'; import {id as pluginId} from '../manifest'; +let timeoutId; +const RECONNECT_JITTER_MAX_TIME_IN_SEC = 10; export function handleConnect(store) { return (msg) => { if (!msg.data) { @@ -66,21 +65,28 @@ export function handleReconnect(store, reminder = false) { return async () => { const {data} = await getConnected(reminder)(store.dispatch, store.getState); if (data && data.connected) { - getReviews()(store.dispatch, store.getState); - getUnreads()(store.dispatch, store.getState); - getYourPrs()(store.dispatch, store.getState); - getYourAssignments()(store.dispatch, store.getState); + if (typeof timeoutId === 'number') { + clearTimeout(timeoutId); + } + + const rand = Math.floor(Math.random() * RECONNECT_JITTER_MAX_TIME_IN_SEC) + 1; + timeoutId = setTimeout(() => { + getSidebarContent()(store.dispatch, store.getState); + timeoutId = undefined; //eslint-disable-line no-undefined + }, rand * 1000); } }; } export function handleRefresh(store) { - return () => { + return (msg) => { if (store.getState()[`plugins-${pluginId}`].connected) { - getReviews()(store.dispatch, store.getState); - getUnreads()(store.dispatch, store.getState); - getYourPrs()(store.dispatch, store.getState); - getYourAssignments()(store.dispatch, store.getState); + const {data} = msg; + + store.dispatch({ + type: ActionTypes.RECEIVED_SIDEBAR_CONTENT, + data, + }); } }; }