diff --git a/server/plugin/api.go b/server/plugin/api.go index 3c41ee65e..ab1a7381c 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -26,29 +26,25 @@ import ( "github.com/mattermost/mattermost-plugin-github/server/plugin/graphql" ) -const ( - apiErrorIDNotConnected = "not_connected" - // TokenTTL is the OAuth token expiry duration in seconds - TokenTTL = 10 * 60 - - requestTimeout = 30 * time.Second - oauthCompleteTimeout = 2 * time.Minute -) +// HTTPHandlerFuncWithUserContext is http.HandleFunc but with a UserContext attached +type HTTPHandlerFuncWithUserContext func(c *UserContext, w http.ResponseWriter, r *http.Request) -type OAuthState struct { - UserID string `json:"user_id"` - Token string `json:"token"` - PrivateAllowed bool `json:"private_allowed"` -} +// HTTPHandlerFuncWithContext is http.HandleFunc but with a .ontext attached +type HTTPHandlerFuncWithContext func(c *Context, w http.ResponseWriter, r *http.Request) -type APIErrorResponse struct { - ID string `json:"id"` - Message string `json:"message"` - StatusCode int `json:"status_code"` -} +// ResponseType indicates type of response returned by api +type ResponseType string -func (e *APIErrorResponse) Error() string { - return e.Message +type UpdateIssueRequest struct { + Title string `json:"title"` + Body string `json:"body"` + Repo string `json:"repo"` + PostID string `json:"post_id"` + ChannelID string `json:"channel_id"` + Labels []string `json:"labels"` + Assignees []string `json:"assignees"` + Milestone int `json:"milestone"` + IssueNumber int `json:"issue_number"` } type PRDetails struct { @@ -78,25 +74,38 @@ type Context struct { Log logger.Logger } -// HTTPHandlerFuncWithContext is http.HandleFunc but with a Context attached -type HTTPHandlerFuncWithContext func(c *Context, w http.ResponseWriter, r *http.Request) - type UserContext struct { Context GHInfo *GitHubUserInfo } -// HTTPHandlerFuncWithUserContext is http.HandleFunc but with a UserContext attached -type HTTPHandlerFuncWithUserContext func(c *UserContext, w http.ResponseWriter, r *http.Request) - -// ResponseType indicates type of response returned by api -type ResponseType string +type APIErrorResponse struct { + ID string `json:"id"` + Message string `json:"message"` + StatusCode int `json:"status_code"` +} const ( // ResponseTypeJSON indicates that response type is json ResponseTypeJSON ResponseType = "JSON_RESPONSE" // ResponseTypePlain indicates that response type is text plain ResponseTypePlain ResponseType = "TEXT_RESPONSE" + + KeyRepoName string = "repo_name" + KeyRepoOwner string = "repo_owner" + KeyIssueNumber string = "issue_number" + KeyIssueID string = "issue_id" + KeyStatus string = "status" + KeyChannelID string = "channel_id" + KeyPostID string = "postId" + + WebsocketEventOpenCommentModal string = "open_comment_modal" + WebsocketEventOpenStatusModal string = "open_status_modal" + WebsocketEventOpenEditModal string = "open_edit_modal" + + PathOpenIssueCommentModal string = "/open-comment-modal" + PathOpenIssueEditModal string = "/open-edit-modal" + PathOpenIssueStatusModal string = "/open-status-modal" ) func (p *Plugin) writeJSON(w http.ResponseWriter, v interface{}) { @@ -106,8 +115,7 @@ func (p *Plugin) writeJSON(w http.ResponseWriter, v interface{}) { w.WriteHeader(http.StatusInternalServerError) return } - _, err = w.Write(b) - if err != nil { + if _, err := w.Write(b); err != nil { p.client.Log.Warn("Failed to write JSON response", "error", err.Error()) w.WriteHeader(http.StatusInternalServerError) return @@ -124,8 +132,7 @@ func (p *Plugin) writeAPIError(w http.ResponseWriter, apiErr *APIErrorResponse) w.WriteHeader(apiErr.StatusCode) - _, err = w.Write(b) - if err != nil { + if _, err := w.Write(b); err != nil { p.client.Log.Warn("Failed to write JSON response", "error", err.Error()) w.WriteHeader(http.StatusInternalServerError) return @@ -149,10 +156,13 @@ 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("/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("/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("/prs_details", p.checkAuth(p.attachUserContext(p.getPrsDetails), ResponseTypePlain)).Methods(http.MethodPost) + apiRouter.HandleFunc("/search_issues", p.checkAuth(p.attachUserContext(p.searchIssues), ResponseTypePlain)).Methods(http.MethodGet) + apiRouter.HandleFunc("/create_issue", p.checkAuth(p.attachUserContext(p.createIssue), ResponseTypePlain)).Methods(http.MethodPost) + apiRouter.HandleFunc("/close_or_reopen_issue", p.checkAuth(p.attachUserContext(p.closeOrReopenIssue), ResponseTypePlain)).Methods(http.MethodPost) + apiRouter.HandleFunc("/update_issue", p.checkAuth(p.attachUserContext(p.updateIssue), ResponseTypePlain)).Methods(http.MethodPost) + apiRouter.HandleFunc("/issue_info", p.checkAuth(p.attachUserContext(p.getIssueInfo), ResponseTypePlain)).Methods(http.MethodGet) + apiRouter.HandleFunc("/create_issue_comment", 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("/labels", p.checkAuth(p.attachUserContext(p.getLabels), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/milestones", p.checkAuth(p.attachUserContext(p.getMilestones), ResponseTypePlain)).Methods(http.MethodGet) @@ -162,6 +172,9 @@ func (p *Plugin) initializeAPI() { 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(PathOpenIssueCommentModal, p.checkAuth(p.attachUserContext(p.handleOpenIssueCommentModal), ResponseTypePlain)).Methods(http.MethodPost) + apiRouter.HandleFunc(PathOpenIssueEditModal, p.checkAuth(p.attachUserContext(p.handleOpenEditIssueModal), ResponseTypePlain)).Methods(http.MethodPost) + apiRouter.HandleFunc(PathOpenIssueStatusModal, p.checkAuth(p.attachUserContext(p.handleOpenIssueStatusModal), ResponseTypePlain)).Methods(http.MethodPost) apiRouter.HandleFunc("/config", checkPluginRequest(p.getConfig)).Methods(http.MethodGet) apiRouter.HandleFunc("/token", checkPluginRequest(p.getToken)).Methods(http.MethodGet) @@ -198,7 +211,7 @@ func (p *Plugin) checkConfigured(next http.Handler) http.Handler { func (p *Plugin) checkAuth(handler http.HandlerFunc, responseType ResponseType) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID := r.Header.Get("Mattermost-User-ID") + userID := r.Header.Get(headerMattermostUserID) if userID == "" { switch responseType { case ResponseTypeJSON: @@ -216,7 +229,7 @@ func (p *Plugin) checkAuth(handler http.HandlerFunc, responseType ResponseType) } func (p *Plugin) createContext(_ http.ResponseWriter, r *http.Request) (*Context, context.CancelFunc) { - userID := r.Header.Get("Mattermost-User-ID") + userID := r.Header.Get(headerMattermostUserID) logger := logger.New(p.API).With(logger.LogContext{ "userid": userID, @@ -305,7 +318,7 @@ func (p *Plugin) connectUserToGitHub(c *Context, w http.ResponseWriter, r *http. PrivateAllowed: privateAllowed, } - _, err = p.store.Set(githubOauthKey+state.Token, state, pluginapi.SetExpiry(TokenTTL)) + _, err = p.store.Set(githubOauthKey+state.Token, state, pluginapi.SetExpiry(tokenTTL)) if err != nil { c.Log.WithError(err).Warnf("error occurred while trying to store oauth state into KV store") p.writeAPIError(w, &APIErrorResponse{Message: "error saving the oauth state", StatusCode: http.StatusInternalServerError}) @@ -447,8 +460,7 @@ func (p *Plugin) completeConnectUserToGitHub(c *Context, w http.ResponseWriter, } if stepName == stepOAuthConnect { - err = flow.Go(stepWebhookQuestion) - if err != nil { + if err = flow.Go(stepWebhookQuestion); err != nil { c.Log.WithError(err).Warnf("Failed go to next step") } } else { @@ -513,8 +525,7 @@ func (p *Plugin) completeConnectUserToGitHub(c *Context, w http.ResponseWriter, ` w.Header().Set("Content-Type", "text/html") - _, err = w.Write([]byte(html)) - if err != nil { + if _, err = w.Write([]byte(html)); err != nil { c.Log.WithError(err).Warnf("Failed to write HTML response") p.writeAPIError(w, &APIErrorResponse{Message: "failed to write HTML response", StatusCode: http.StatusInternalServerError}) return @@ -522,14 +533,10 @@ func (p *Plugin) completeConnectUserToGitHub(c *Context, w http.ResponseWriter, } func (p *Plugin) getGitHubUser(c *Context, w http.ResponseWriter, r *http.Request) { - type GitHubUserRequest struct { - UserID string `json:"user_id"` - } - req := &GitHubUserRequest{} if err := json.NewDecoder(r.Body).Decode(&req); err != nil { c.Log.WithError(err).Warnf("Error decoding GitHubUserRequest from JSON body") - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a JSON object.", StatusCode: http.StatusBadRequest}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid JSON object.", StatusCode: http.StatusBadRequest}) return } @@ -553,27 +560,12 @@ func (p *Plugin) getGitHubUser(c *Context, w http.ResponseWriter, r *http.Reques return } - type GitHubUserResponse struct { - Username string `json:"username"` - } - resp := &GitHubUserResponse{Username: userInfo.GitHubUsername} p.writeJSON(w, resp) } func (p *Plugin) getConnected(c *Context, w http.ResponseWriter, r *http.Request) { config := p.getConfiguration() - - type ConnectedResponse struct { - Connected bool `json:"connected"` - GitHubUsername string `json:"github_username"` - GitHubClientID string `json:"github_client_id"` - EnterpriseBaseURL string `json:"enterprise_base_url,omitempty"` - Organizations []string `json:"organizations"` - UserSettings *UserSettings `json:"user_settings"` - ClientConfiguration map[string]interface{} `json:"configuration"` - } - orgList := p.configuration.getOrganizations() resp := &ConnectedResponse{ Connected: false, @@ -632,7 +624,7 @@ func (p *Plugin) getConnected(c *Context, w http.ResponseWriter, r *http.Request } } - privateRepoStoreKey := info.UserID + githubPrivateRepoKey + privateRepoStoreKey := fmt.Sprintf("%s%s", info.UserID, githubPrivateRepoKey) if config.EnablePrivateRepo && !info.AllowedPrivateRepos { var val []byte err := p.store.Get(privateRepoStoreKey, &val) @@ -733,7 +725,7 @@ func (p *Plugin) getPrsDetails(c *UserContext, w http.ResponseWriter, r *http.Re var prList []*PRDetails if err := json.NewDecoder(r.Body).Decode(&prList); err != nil { c.Log.WithError(err).Warnf("Error decoding PRDetails JSON body") - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a JSON object.", StatusCode: http.StatusBadRequest}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid JSON object.", StatusCode: http.StatusBadRequest}) return } @@ -891,17 +883,18 @@ func getFailReason(code int, repo string, username string) string { func (p *Plugin) createIssueComment(c *UserContext, w http.ResponseWriter, r *http.Request) { type CreateIssueCommentRequest struct { - PostID string `json:"post_id"` - Owner string `json:"owner"` - Repo string `json:"repo"` - Number int `json:"number"` - Comment string `json:"comment"` + PostID string `json:"post_id"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Comment string `json:"comment"` + ShowAttachedMessage bool `json:"show_attached_message"` } req := &CreateIssueCommentRequest{} if err := json.NewDecoder(r.Body).Decode(&req); err != nil { c.Log.WithError(err).Warnf("Error decoding CreateIssueCommentRequest JSON body") - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a JSON object.", StatusCode: http.StatusBadRequest}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid JSON object.", StatusCode: http.StatusBadRequest}) return } @@ -911,12 +904,12 @@ func (p *Plugin) createIssueComment(c *UserContext, w http.ResponseWriter, r *ht } if req.Owner == "" { - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid repo owner.", StatusCode: http.StatusBadRequest}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid repository owner.", StatusCode: http.StatusBadRequest}) return } if req.Repo == "" { - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid repo.", StatusCode: http.StatusBadRequest}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid repository.", StatusCode: http.StatusBadRequest}) return } @@ -934,11 +927,11 @@ func (p *Plugin) createIssueComment(c *UserContext, w http.ResponseWriter, r *ht post, err := p.client.Post.GetPost(req.PostID) if err != nil { - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "failed to load post " + req.PostID, StatusCode: http.StatusInternalServerError}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s", req.PostID), StatusCode: http.StatusInternalServerError}) return } if post == nil { - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "failed to load post " + req.PostID + ": not found", StatusCode: http.StatusNotFound}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s : not found", req.PostID), StatusCode: http.StatusNotFound}) return } @@ -956,7 +949,9 @@ func (p *Plugin) createIssueComment(c *UserContext, w http.ResponseWriter, r *ht } permalinkMessage := fmt.Sprintf("*@%s attached a* [message](%s) *from %s*\n\n", currentUsername, permalink, commentUsername) - req.Comment = permalinkMessage + req.Comment + if req.ShowAttachedMessage { + req.Comment = fmt.Sprintf("%s%s", permalinkMessage, req.Comment) + } comment := &github.IssueComment{ Body: &req.Comment, } @@ -989,7 +984,11 @@ func (p *Plugin) createIssueComment(c *UserContext, w http.ResponseWriter, r *ht rootID = post.RootId } - permalinkReplyMessage := fmt.Sprintf("[Message](%v) attached to GitHub issue [#%v](%v)", permalink, req.Number, result.GetHTMLURL()) + permalinkReplyMessage := fmt.Sprintf("Comment attached to GitHub issue [#%v](%v)", req.Number, result.GetHTMLURL()) + if req.ShowAttachedMessage { + permalinkReplyMessage = fmt.Sprintf("[Message](%v) attached to GitHub issue [#%v](%v)", permalink, req.Number, result.GetHTMLURL()) + } + reply := &model.Post{ Message: permalinkReplyMessage, ChannelId: post.ChannelId, @@ -999,7 +998,7 @@ func (p *Plugin) createIssueComment(c *UserContext, w http.ResponseWriter, r *ht err = p.client.Post.CreatePost(reply) if err != nil { - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "failed to create notification post " + req.PostID, StatusCode: http.StatusInternalServerError}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to create the notification post %s", req.PostID), StatusCode: http.StatusInternalServerError}) return } @@ -1087,11 +1086,97 @@ func (p *Plugin) updateSettings(c *UserContext, w http.ResponseWriter, r *http.R p.writeJSON(w, info.Settings) } +func (p *Plugin) getIssueInfo(c *UserContext, w http.ResponseWriter, r *http.Request) { + owner := r.FormValue(ownerQueryParam) + repo := r.FormValue(repoQueryParam) + number := r.FormValue(numberQueryParam) + postID := r.FormValue(postIDQueryParam) + + issueNumber, err := strconv.Atoi(number) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{Message: "Invalid param 'number'.", StatusCode: http.StatusBadRequest}) + return + } + + githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) + issue, _, err := githubClient.Issues.Get(c.Ctx, owner, repo, issueNumber) + if err != nil { + // If the issue is not found, it probably belongs to a private repo. + // Return an empty response in that case. + var gerr *github.ErrorResponse + if errors.As(err, &gerr) && gerr.Response.StatusCode == http.StatusNotFound { + c.Log.WithError(err).With(logger.LogContext{ + "owner": owner, + "repo": repo, + "number": issueNumber, + }).Debugf("Issue not found") + p.writeJSON(w, nil) + return + } + + c.Log.WithError(err).With(logger.LogContext{ + "owner": owner, + "repo": repo, + "number": issueNumber, + }).Debugf("Could not get the issue") + p.writeAPIError(w, &APIErrorResponse{Message: "Could not get the issue", StatusCode: http.StatusInternalServerError}) + return + } + + description := "" + if issue.Body != nil { + description = mdCommentRegex.ReplaceAllString(issue.GetBody(), "") + } + + assignees := make([]string, len(issue.Assignees)) + for index, user := range issue.Assignees { + assignees[index] = user.GetLogin() + } + + labels := make([]string, len(issue.Labels)) + for index, label := range issue.Labels { + labels[index] = label.GetName() + } + + milestoneTitle := "" + var milestoneNumber int + if issue.Milestone != nil && issue.Milestone.Title != nil { + milestoneTitle = *issue.Milestone.Title + milestoneNumber = *issue.Milestone.Number + } + + post, appErr := p.API.GetPost(postID) + if appErr != nil { + p.client.Log.Error("Unable to get the post", "PostID", postID, "Error", appErr.Error()) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s", postID), StatusCode: http.StatusInternalServerError}) + return + } + if post == nil { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s : not found", postID), StatusCode: http.StatusNotFound}) + return + } + + issueInfo := map[string]interface{}{ + "title": *issue.Title, + "channel_id": post.ChannelId, + "postId": postID, + "milestone_title": milestoneTitle, + "milestone_number": milestoneNumber, + "assignees": assignees, + "labels": labels, + "description": description, + "repo_full_name": fmt.Sprintf("%s/%s", owner, repo), + "issue_number": *issue.Number, + } + + p.writeJSON(w, issueInfo) +} + func (p *Plugin) getIssueByNumber(c *UserContext, w http.ResponseWriter, r *http.Request) { - owner := r.FormValue("owner") - repo := r.FormValue("repo") - number := r.FormValue("number") - numberInt, err := strconv.Atoi(number) + owner := r.FormValue(ownerQueryParam) + repo := r.FormValue(repoQueryParam) + number := r.FormValue(numberQueryParam) + issueNumber, err := strconv.Atoi(number) if err != nil { p.writeAPIError(w, &APIErrorResponse{Message: "Invalid param 'number'.", StatusCode: http.StatusBadRequest}) return @@ -1101,7 +1186,7 @@ func (p *Plugin) getIssueByNumber(c *UserContext, w http.ResponseWriter, r *http var result *github.Issue if cErr := p.useGitHubClient(c.GHInfo, func(info *GitHubUserInfo, token *oauth2.Token) error { - result, _, err = githubClient.Issues.Get(c.Ctx, owner, repo, numberInt) + result, _, err = githubClient.Issues.Get(c.Ctx, owner, repo, issueNumber) if err != nil { return err } @@ -1114,8 +1199,8 @@ func (p *Plugin) getIssueByNumber(c *UserContext, w http.ResponseWriter, r *http c.Log.WithError(err).With(logger.LogContext{ "owner": owner, "repo": repo, - "number": numberInt, - }).Debugf("Issue not found") + "number": issueNumber, + }).Debugf("Issue not found") p.writeJSON(w, nil) return } @@ -1123,11 +1208,12 @@ func (p *Plugin) getIssueByNumber(c *UserContext, w http.ResponseWriter, r *http c.Log.WithError(cErr).With(logger.LogContext{ "owner": owner, "repo": repo, - "number": numberInt, - }).Debugf("Could not get issue") - p.writeAPIError(w, &APIErrorResponse{Message: "Could not get issue", StatusCode: http.StatusInternalServerError}) + "number": issueNumber, + }).Debugf("Could not get the issue") + p.writeAPIError(w, &APIErrorResponse{Message: "Could not get the issue", StatusCode: http.StatusInternalServerError}) return } + if result.Body != nil { *result.Body = mdCommentRegex.ReplaceAllString(result.GetBody(), "") } @@ -1135,11 +1221,11 @@ func (p *Plugin) getIssueByNumber(c *UserContext, w http.ResponseWriter, r *http } func (p *Plugin) getPrByNumber(c *UserContext, w http.ResponseWriter, r *http.Request) { - owner := r.FormValue("owner") - repo := r.FormValue("repo") - number := r.FormValue("number") + owner := r.FormValue(ownerQueryParam) + repo := r.FormValue(repoQueryParam) + number := r.FormValue(numberQueryParam) - numberInt, err := strconv.Atoi(number) + prNumber, err := strconv.Atoi(number) if err != nil { p.writeAPIError(w, &APIErrorResponse{Message: "Invalid param 'number'.", StatusCode: http.StatusBadRequest}) return @@ -1148,20 +1234,20 @@ func (p *Plugin) getPrByNumber(c *UserContext, w http.ResponseWriter, r *http.Re githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) var result *github.PullRequest if cErr := p.useGitHubClient(c.GHInfo, func(userInfo *GitHubUserInfo, token *oauth2.Token) error { - result, _, err = githubClient.PullRequests.Get(c.Ctx, owner, repo, numberInt) + result, _, err = githubClient.PullRequests.Get(c.Ctx, owner, repo, prNumber) if err != nil { return err } return nil }); cErr != nil { // If the pull request is not found, it's probably behind a private repo. - // Return an empty repose in this case. + // Return an empty response in that case. var gerr *github.ErrorResponse if errors.As(cErr, &gerr) && gerr.Response.StatusCode == http.StatusNotFound { c.Log.With(logger.LogContext{ "owner": owner, "repo": repo, - "number": numberInt, + "number": prNumber, }).Debugf("Pull request not found") p.writeJSON(w, nil) @@ -1171,7 +1257,7 @@ func (p *Plugin) getPrByNumber(c *UserContext, w http.ResponseWriter, r *http.Re c.Log.WithError(cErr).With(logger.LogContext{ "owner": owner, "repo": repo, - "number": numberInt, + "number": prNumber, }).Debugf("Could not get pull request") p.writeAPIError(w, &APIErrorResponse{Message: "Could not get pull request", StatusCode: http.StatusInternalServerError}) return @@ -1392,7 +1478,6 @@ func (p *Plugin) getRepositories(c *UserContext, w http.ResponseWriter, r *http. } } - // Only send down fields to client that are needed type RepositoryResponse struct { Name string `json:"name,omitempty"` FullName string `json:"full_name,omitempty"` @@ -1409,8 +1494,161 @@ func (p *Plugin) getRepositories(c *UserContext, w http.ResponseWriter, r *http. p.writeJSON(w, resp) } +func (p *Plugin) updateIssue(c *UserContext, w http.ResponseWriter, r *http.Request) { + // get data for the issue from the request body and fill UpdateIssueRequest to update the issue + issue := &UpdateIssueRequest{} + if err := json.NewDecoder(r.Body).Decode(&issue); err != nil { + c.Log.WithError(err).Warnf("Error decoding the JSON body") + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid JSON object.", StatusCode: http.StatusBadRequest}) + return + } + + if !p.validateIssueRequestForUpdation(issue, w) { + return + } + + var post *model.Post + if issue.PostID != "" { + var appErr *model.AppError + post, appErr = p.API.GetPost(issue.PostID) + if appErr != nil { + p.client.Log.Error("Unable to get the post", "PostID", issue.PostID, "Error", appErr.Error()) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s", issue.PostID), StatusCode: http.StatusInternalServerError}) + return + } + if post == nil { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s : not found", issue.PostID), StatusCode: http.StatusNotFound}) + return + } + } + + githubIssue := &github.IssueRequest{ + Title: &issue.Title, + Body: &issue.Body, + Labels: &issue.Labels, + Assignees: &issue.Assignees, + } + + // submitting the request with an invalid milestone ID results in a 422 error + // we should make sure it's not zero here because the webapp client might have left this field empty + if issue.Milestone > 0 { + githubIssue.Milestone = &issue.Milestone + } + + currentUser, appErr := p.API.GetUser(c.UserID) + if appErr != nil { + p.client.Log.Error("Unable to get the user", "UserID", c.UserID, "Error", appErr.Error()) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "failed to load current user", StatusCode: http.StatusInternalServerError}) + return + } + + splittedRepo := strings.Split(issue.Repo, "/") + if len(splittedRepo) < 2 { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid repository", StatusCode: http.StatusBadRequest}) + } + + owner, repoName := splittedRepo[0], splittedRepo[1] + githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) + + result, resp, err := githubClient.Issues.Edit(c.Ctx, owner, repoName, issue.IssueNumber, githubIssue) + if err != nil { + if resp != nil && resp.Response.StatusCode == http.StatusGone { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Issues are disabled on this repository.", StatusCode: http.StatusMethodNotAllowed}) + return + } + + c.Log.WithError(err).Warnf("Failed to update the issue") + p.writeAPIError(w, &APIErrorResponse{ + ID: "", + Message: fmt.Sprintf("failed to update the issue: %s", getFailReason(resp.StatusCode, + issue.Repo, + currentUser.Username, + )), + StatusCode: resp.StatusCode, + }) + return + } + + rootID := issue.PostID + channelID := issue.ChannelID + message := fmt.Sprintf("Updated GitHub issue [#%v](%v)", result.GetNumber(), result.GetHTMLURL()) + if post != nil { + if post.RootId != "" { + rootID = post.RootId + } + channelID = post.ChannelId + } + + reply := &model.Post{ + Message: message, + ChannelId: channelID, + RootId: rootID, + UserId: c.UserID, + } + + if post != nil { + _, appErr = p.API.CreatePost(reply) + } else { + _ = p.API.SendEphemeralPost(c.UserID, reply) + } + if appErr != nil { + c.Log.WithError(appErr).Warnf("failed to create the notification post") + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to create the notification post, postID: %s, channelID: %s", issue.PostID, channelID), StatusCode: http.StatusInternalServerError}) + return + } + + p.updatePost(issue, w) + p.writeJSON(w, result) +} + +func (p *Plugin) closeOrReopenIssue(c *UserContext, w http.ResponseWriter, r *http.Request) { + type CommentAndCloseRequest struct { + ChannelID string `json:"channel_id"` + IssueComment string `json:"issue_comment"` + StatusReason string `json:"status_reason"` + Number int `json:"number"` + Owner string `json:"owner"` + Repository string `json:"repo"` + Status string `json:"status"` + PostID string `json:"postId"` + } + + req := &CommentAndCloseRequest{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.Log.WithError(err).Warnf("Error decoding the JSON body") + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid JSON object.", StatusCode: http.StatusBadRequest}) + return + } + + post, appErr := p.API.GetPost(req.PostID) + if appErr != nil { + p.client.Log.Error("Unable to get the post", "PostID", req.PostID, "Error", appErr.Error()) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s", req.PostID), StatusCode: http.StatusInternalServerError}) + return + } + if post == nil { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s : not found", req.PostID), StatusCode: http.StatusNotFound}) + return + } + + if _, err := p.getUsername(post.UserId); err != nil { + p.client.Log.Error("Unable to get the username", "UserID", post.UserId, "Error", err.Error()) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "failed to get username", StatusCode: http.StatusInternalServerError}) + return + } + if req.IssueComment != "" { + p.CreateCommentToIssue(c, w, req.IssueComment, req.Owner, req.Repository, post, req.Number) + } + + if req.Status == statusClose { + p.CloseOrReopenIssue(c, w, issueClose, req.StatusReason, req.Owner, req.Repository, post, req.Number) + } else { + p.CloseOrReopenIssue(c, w, issueOpen, req.StatusReason, req.Owner, req.Repository, post, req.Number) + } +} + func (p *Plugin) createIssue(c *UserContext, w http.ResponseWriter, r *http.Request) { - type IssueRequest struct { + type CreateIssueRequest struct { Title string `json:"title"` Body string `json:"body"` Repo string `json:"repo"` @@ -1421,12 +1659,11 @@ func (p *Plugin) createIssue(c *UserContext, w http.ResponseWriter, r *http.Requ Milestone int `json:"milestone"` } - // get data for the issue from the request body and fill IssueRequest object - issue := &IssueRequest{} - + // get data for the issue from the request body and fill CreateIssueRequest object to create the issue + issue := &CreateIssueRequest{} if err := json.NewDecoder(r.Body).Decode(&issue); err != nil { - c.Log.WithError(err).Warnf("Error decoding JSON body") - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a JSON object.", StatusCode: http.StatusBadRequest}) + c.Log.WithError(err).Warnf("Error decoding the JSON body") + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid JSON object.", StatusCode: http.StatusBadRequest}) return } @@ -1436,7 +1673,7 @@ func (p *Plugin) createIssue(c *UserContext, w http.ResponseWriter, r *http.Requ } if issue.Repo == "" { - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid repo name.", StatusCode: http.StatusBadRequest}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid repository name.", StatusCode: http.StatusBadRequest}) return } @@ -1452,11 +1689,11 @@ func (p *Plugin) createIssue(c *UserContext, w http.ResponseWriter, r *http.Requ var err error post, err = p.client.Post.GetPost(issue.PostID) if err != nil { - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "failed to load post " + issue.PostID, StatusCode: http.StatusInternalServerError}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s", issue.PostID), StatusCode: http.StatusInternalServerError}) return } if post == nil { - p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "failed to load post " + issue.PostID + ": not found", StatusCode: http.StatusNotFound}) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s : not found", issue.PostID), StatusCode: http.StatusNotFound}) return } @@ -1475,7 +1712,7 @@ func (p *Plugin) createIssue(c *UserContext, w http.ResponseWriter, r *http.Requ mmMessage = fmt.Sprintf("_Issue created from a [Mattermost message](%v) *by %s*._", permalink, username) } - ghIssue := &github.IssueRequest{ + githubIssue := &github.IssueRequest{ Title: &issue.Title, Body: &issue.Body, Labels: &issue.Labels, @@ -1483,15 +1720,15 @@ func (p *Plugin) createIssue(c *UserContext, w http.ResponseWriter, r *http.Requ } // submitting the request with an invalid milestone ID results in a 422 error - // we make sure it's not zero here, because the webapp client might have left this field empty + // we should make sure it's not zero here because the webapp client might have left this field empty if issue.Milestone > 0 { - ghIssue.Milestone = &issue.Milestone + githubIssue.Milestone = &issue.Milestone } - if ghIssue.GetBody() != "" && mmMessage != "" { + if githubIssue.GetBody() != "" && mmMessage != "" { mmMessage = "\n\n" + mmMessage } - *ghIssue.Body = ghIssue.GetBody() + mmMessage + *githubIssue.Body = fmt.Sprintf("%s%s", githubIssue.GetBody(), mmMessage) currentUser, err := p.client.User.Get(c.UserID) if err != nil { @@ -1500,14 +1737,13 @@ func (p *Plugin) createIssue(c *UserContext, w http.ResponseWriter, r *http.Requ } splittedRepo := strings.Split(issue.Repo, "/") - owner := splittedRepo[0] - repoName := splittedRepo[1] + owner, repoName := splittedRepo[0], splittedRepo[1] githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) var resp *github.Response var result *github.Issue if cErr := p.useGitHubClient(c.GHInfo, func(info *GitHubUserInfo, token *oauth2.Token) error { - result, resp, err = githubClient.Issues.Create(c.Ctx, owner, repoName, ghIssue) + result, resp, err = githubClient.Issues.Create(c.Ctx, owner, repoName, githubIssue) if err != nil { return err } @@ -1587,6 +1823,92 @@ func (p *Plugin) getToken(w http.ResponseWriter, r *http.Request) { p.writeJSON(w, info.Token) } +func (p *Plugin) handleOpenEditIssueModal(c *UserContext, w http.ResponseWriter, r *http.Request) { + response := &model.PostActionIntegrationResponse{} + decoder := json.NewDecoder(r.Body) + postActionIntegrationRequest := &model.PostActionIntegrationRequest{} + if err := decoder.Decode(&postActionIntegrationRequest); err != nil { + p.API.LogError("Error decoding PostActionIntegrationRequest params", "Error", err.Error()) + p.returnPostActionIntegrationResponse(w, response) + return + } + + p.client.Frontend.PublishWebSocketEvent( + WebsocketEventOpenEditModal, + map[string]interface{}{ + KeyRepoName: postActionIntegrationRequest.Context[KeyRepoName], + KeyRepoOwner: postActionIntegrationRequest.Context[KeyRepoOwner], + KeyIssueNumber: postActionIntegrationRequest.Context[KeyIssueNumber], + KeyPostID: postActionIntegrationRequest.PostId, + KeyStatus: postActionIntegrationRequest.Context[KeyStatus], + KeyChannelID: postActionIntegrationRequest.ChannelId, + }, + &model.WebsocketBroadcast{UserId: postActionIntegrationRequest.UserId}, + ) + + p.returnPostActionIntegrationResponse(w, response) +} + +func (p *Plugin) returnPostActionIntegrationResponse(w http.ResponseWriter, res *model.PostActionIntegrationResponse) { + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(res); err != nil { + p.API.LogWarn("Failed to write PostActionIntegrationResponse", "Error", err.Error()) + } +} + +func (p *Plugin) handleOpenIssueStatusModal(c *UserContext, w http.ResponseWriter, r *http.Request) { + response := &model.PostActionIntegrationResponse{} + decoder := json.NewDecoder(r.Body) + postActionIntegrationRequest := &model.PostActionIntegrationRequest{} + if err := decoder.Decode(&postActionIntegrationRequest); err != nil { + p.API.LogError("Error decoding PostActionIntegrationRequest params", "Error", err.Error()) + p.returnPostActionIntegrationResponse(w, response) + return + } + + p.client.Frontend.PublishWebSocketEvent( + WebsocketEventOpenStatusModal, + map[string]interface{}{ + KeyRepoName: postActionIntegrationRequest.Context[KeyRepoName], + KeyRepoOwner: postActionIntegrationRequest.Context[KeyRepoOwner], + KeyIssueNumber: postActionIntegrationRequest.Context[KeyIssueNumber], + KeyPostID: postActionIntegrationRequest.PostId, + KeyStatus: postActionIntegrationRequest.Context[KeyStatus], + KeyChannelID: postActionIntegrationRequest.ChannelId, + }, + &model.WebsocketBroadcast{UserId: postActionIntegrationRequest.UserId}, + ) + + p.returnPostActionIntegrationResponse(w, response) +} + +func (p *Plugin) handleOpenIssueCommentModal(c *UserContext, w http.ResponseWriter, r *http.Request) { + response := &model.PostActionIntegrationResponse{} + decoder := json.NewDecoder(r.Body) + postActionIntegrationRequest := &model.PostActionIntegrationRequest{} + if err := decoder.Decode(&postActionIntegrationRequest); err != nil { + p.API.LogError("Error decoding PostActionIntegrationRequest params", "Error", err.Error()) + p.returnPostActionIntegrationResponse(w, response) + return + } + + p.client.Frontend.PublishWebSocketEvent( + WebsocketEventOpenCommentModal, + map[string]interface{}{ + KeyRepoName: postActionIntegrationRequest.Context[KeyRepoName], + KeyRepoOwner: postActionIntegrationRequest.Context[KeyRepoOwner], + KeyIssueNumber: postActionIntegrationRequest.Context[KeyIssueNumber], + KeyPostID: postActionIntegrationRequest.PostId, + KeyStatus: postActionIntegrationRequest.Context[KeyStatus], + KeyChannelID: postActionIntegrationRequest.ChannelId, + }, + &model.WebsocketBroadcast{UserId: postActionIntegrationRequest.UserId}, + ) + + p.returnPostActionIntegrationResponse(w, response) +} + // parseRepo parses the owner & repository name from the repo query parameter func parseRepo(repoParam string) (owner, repo string, err error) { if repoParam == "" { diff --git a/server/plugin/api_test.go b/server/plugin/api_test.go index 782a1487d..7a3a560de 100644 --- a/server/plugin/api_test.go +++ b/server/plugin/api_test.go @@ -118,7 +118,7 @@ func TestPlugin_ServeHTTP(t *testing.T) { p.SetAPI(&plugintest.API{}) req := test.httpTest.CreateHTTPRequest(test.request) - req.Header.Add("Mattermost-User-ID", test.userID) + req.Header.Add(headerMattermostUserID, test.userID) rr := httptest.NewRecorder() p.ServeHTTP(&plugin.Context{}, rr, req) test.httpTest.CompareHTTPResponse(rr, test.expectedResponse) diff --git a/server/plugin/mm_34646_token_refresh.go b/server/plugin/mm_34646_token_refresh.go index 9abd56751..60eb6f4fe 100644 --- a/server/plugin/mm_34646_token_refresh.go +++ b/server/plugin/mm_34646_token_refresh.go @@ -112,7 +112,7 @@ func (p *Plugin) forceResetUserTokenMM34646(ctx context.Context, config *Configu info.MM34646ResetTokenDone = true err = p.storeGitHubUserInfo(info) if err != nil { - return "", errors.Wrap(err, "failed to store updated GitHubUserInfo") + return "", errors.Wrap(err, "failed to store updated serializer.GitHubUserInfo") } p.client.Log.Debug("Updated user access token for MM-34646", "user_id", info.UserID) diff --git a/server/plugin/oauth.go b/server/plugin/oauth.go index 294727fce..7ccd8f9c6 100644 --- a/server/plugin/oauth.go +++ b/server/plugin/oauth.go @@ -2,13 +2,58 @@ package plugin import ( "sync" + + "golang.org/x/oauth2" ) +type GitHubUserRequest struct { + UserID string `json:"user_id"` +} + +type GitHubUserResponse struct { + Username string `json:"username"` +} + +type ConnectedResponse struct { + Connected bool `json:"connected"` + GitHubUsername string `json:"github_username"` + GitHubClientID string `json:"github_client_id"` + EnterpriseBaseURL string `json:"enterprise_base_url,omitempty"` + Organizations []string `json:"organizations"` + UserSettings *UserSettings `json:"user_settings"` + ClientConfiguration map[string]interface{} `json:"configuration"` +} + +type UserSettings struct { + SidebarButtons string `json:"sidebar_buttons"` + DailyReminder bool `json:"daily_reminder"` + DailyReminderOnChange bool `json:"daily_reminder_on_change"` + Notifications bool `json:"notifications"` +} + +type GitHubUserInfo struct { + UserID string + Token *oauth2.Token + GitHubUsername string + LastToDoPostAt int64 + Settings *UserSettings + AllowedPrivateRepos bool + + // MM34646ResetTokenDone is set for a user whose token has been reset for MM-34646. + MM34646ResetTokenDone bool +} + type OAuthCompleteEvent struct { UserID string Err error } +type OAuthState struct { + UserID string `json:"user_id"` + Token string `json:"token"` + PrivateAllowed bool `json:"private_allowed"` +} + type OAuthBroker struct { sendOAuthCompleteEvent func(event OAuthCompleteEvent) diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index f627bb4bc..b1367bbc7 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -10,6 +10,7 @@ import ( "regexp" "strings" "sync" + "time" "github.com/google/go-github/v54/github" "github.com/gorilla/mux" @@ -27,10 +28,14 @@ import ( ) const ( - githubTokenKey = "_githubtoken" - githubOauthKey = "githuboauthkey_" - githubUsernameKey = "_githubusername" - githubPrivateRepoKey = "_githubprivate" + githubTokenKey = "_githubtoken" + githubOauthKey = "githuboauthkey_" + githubUsernameKey = "_githubusername" + githubPrivateRepoKey = "_githubprivate" + githubObjectTypeIssue = "issue" + githubObjectTypeIssueComment = "issue_comment" + githubObjectTypePRReviewComment = "pr_review_comment" + githubObjectTypeDiscussionComment = "discussion_comment" mm34646MutexKey = "mm34646_token_reset_mutex" mm34646DoneKey = "mm34646_token_reset_done" @@ -56,6 +61,38 @@ const ( chimeraGitHubAppIdentifier = "plugin-github" + apiErrorIDNotConnected = "not_connected" + + // TokenTTL is the OAuth token expiry duration in seconds + tokenTTL = 600 + + requestTimeout = 30 * time.Second + oauthCompleteTimeout = 2 * time.Minute + headerMattermostUserID = "Mattermost-User-ID" + ownerQueryParam = "owner" + repoQueryParam = "repo" + numberQueryParam = "number" + postIDQueryParam = "postId" + + issueStatus = "status" + assigneesForProps = "assignees" + labelsForProps = "labels" + descriptionForProps = "description" + titleForProps = "title" + attachmentsForProps = "attachments" + issueNumberForProps = "issue_number" + issueURLForProps = "issue_url" + repoOwnerForProps = "repo_owner" + repoNameForProps = "repo_name" + + statusClose = "Close" + statusReopen = "Reopen" + + issueCompleted = "completed" + issueNotPlanned = "not_planned" + issueClose = "closed" + issueOpen = "open" + invalidTokenError = "401 Bad credentials" //#nosec G101 -- False positive ) @@ -600,25 +637,6 @@ func (p *Plugin) getOAuthConfigForChimeraApp(scopes []string) (*oauth2.Config, e }, nil } -type GitHubUserInfo struct { - UserID string - Token *oauth2.Token - GitHubUsername string - LastToDoPostAt int64 - Settings *UserSettings - AllowedPrivateRepos bool - - // MM34646ResetTokenDone is set for a user whose token has been reset for MM-34646. - MM34646ResetTokenDone bool -} - -type UserSettings struct { - SidebarButtons string `json:"sidebar_buttons"` - DailyReminder bool `json:"daily_reminder"` - DailyReminderOnChange bool `json:"daily_reminder_on_change"` - Notifications bool `json:"notifications"` -} - func (p *Plugin) storeGitHubUserInfo(info *GitHubUserInfo) error { config := p.getConfiguration() @@ -1119,7 +1137,7 @@ func (p *Plugin) sendRefreshEvent(userID string) { return } - contentMap, err := sidebarContent.toMap() + contentMap, err := sidebarContent.ToMap() if err != nil { p.client.Log.Warn("Failed to convert sidebar content to map", "error", err.Error()) return @@ -1132,7 +1150,7 @@ func (p *Plugin) sendRefreshEvent(userID string) { ) } -func (s *SidebarContent) toMap() (map[string]interface{}, error) { +func (s *SidebarContent) ToMap() (map[string]interface{}, error) { var m map[string]interface{} bytes, err := json.Marshal(&s) if err != nil { @@ -1146,6 +1164,10 @@ func (s *SidebarContent) toMap() (map[string]interface{}, error) { return m, nil } +func (e *APIErrorResponse) Error() string { + return e.Message +} + // 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. @@ -1167,6 +1189,10 @@ func (p *Plugin) getUsername(mmUserID string) (string, error) { return "@" + info.GitHubUsername, nil } +func (p *Plugin) GetPluginAPIPath() string { + return fmt.Sprintf("%s/plugins/%s/api/v1", *p.client.Configuration.GetConfig().ServiceSettings.SiteURL, Manifest.Id) +} + func (p *Plugin) useGitHubClient(info *GitHubUserInfo, toRun func(info *GitHubUserInfo, token *oauth2.Token) error) error { err := toRun(info, info.Token) if err != nil { diff --git a/server/plugin/utils.go b/server/plugin/utils.go index b5ee9c793..aa6497f2d 100644 --- a/server/plugin/utils.go +++ b/server/plugin/utils.go @@ -7,8 +7,10 @@ import ( "crypto/cipher" "crypto/rand" "encoding/base64" + "encoding/json" "fmt" "io" + "net/http" "net/url" "path" "strconv" @@ -19,6 +21,8 @@ import ( "github.com/google/go-github/v54/github" "github.com/pkg/errors" + + "github.com/mattermost/mattermost/server/public/model" ) func getMentionSearchQuery(username string, orgs []string) string { @@ -372,6 +376,195 @@ func isValidURL(rawURL string) error { return nil } +func (p *Plugin) validateIssueRequestForUpdation(issue *UpdateIssueRequest, w http.ResponseWriter) bool { + if issue.Title == "" { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide a valid issue title.", StatusCode: http.StatusBadRequest}) + return false + } + if issue.PostID == "" && issue.ChannelID == "" { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Please provide either a postID or a channelID", StatusCode: http.StatusBadRequest}) + return false + } + + return true +} + +func (p *Plugin) updatePost(issue *UpdateIssueRequest, w http.ResponseWriter) { + post, appErr := p.API.GetPost(issue.PostID) + if appErr != nil { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s", issue.PostID), StatusCode: http.StatusInternalServerError}) + return + } + if post == nil { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to load the post %s : not found", issue.PostID), StatusCode: http.StatusNotFound}) + return + } + + attachments, err := getAttachmentsFromProps(post.Props) + if err != nil { + p.client.Log.Warn("Error occurred while getting attachments from props", "PostID", post.Id, "error", err.Error()) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("existing attachments format error: %v", err), StatusCode: http.StatusInternalServerError}) + return + } + + attachments[0].Fields = p.CreateFieldsForIssuePost(issue.Assignees, issue.Labels) + attachments[0].Title = fmt.Sprintf("%s #%d", issue.Title, issue.IssueNumber) + attachments[0].Text = issue.Body + + post.Props[attachmentsForProps] = attachments + + if _, appErr = p.API.UpdatePost(post); appErr != nil { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to update the post %s", issue.PostID), StatusCode: http.StatusInternalServerError}) + } +} + +func getAttachmentsFromProps(props map[string]interface{}) ([]*model.SlackAttachment, error) { + attachments, ok := props["attachments"] + if !ok { + return nil, fmt.Errorf("no attachments found in props") + } + + attachmentsData, err := json.Marshal(attachments) + if err != nil { + return nil, fmt.Errorf("failed to marshal attachments: %v", err) + } + + var slackAttachments []*model.SlackAttachment + err = json.Unmarshal(attachmentsData, &slackAttachments) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal attachments: %v", err) + } + + return slackAttachments, nil +} + +func (p *Plugin) CreateCommentToIssue(c *UserContext, w http.ResponseWriter, comment, owner, repo string, post *model.Post, issueNumber int) { + currentUsername := c.GHInfo.GitHubUsername + issueComment := &github.IssueComment{ + Body: &comment, + } + githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) + + result, rawResponse, err := githubClient.Issues.CreateComment(c.Ctx, owner, repo, issueNumber, issueComment) + if err != nil { + statusCode := http.StatusInternalServerError + if rawResponse != nil { + statusCode = rawResponse.StatusCode + } + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to create an issue comment: %s", getFailReason(statusCode, repo, currentUsername)), StatusCode: statusCode}) + return + } + + rootID := post.Id + if post.RootId != "" { + // the original post was a reply + rootID = post.RootId + } + + permalinkReplyMessage := fmt.Sprintf("Comment attached to GitHub issue [#%v](%v)", issueNumber, result.GetHTMLURL()) + reply := &model.Post{ + Message: permalinkReplyMessage, + ChannelId: post.ChannelId, + RootId: rootID, + UserId: c.UserID, + } + + if _, appErr := p.API.CreatePost(reply); appErr != nil { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to create the notification post %s", post.Id), StatusCode: http.StatusInternalServerError}) + return + } +} + +func (p *Plugin) CloseOrReopenIssue(c *UserContext, w http.ResponseWriter, status, statusReason, owner, repo string, post *model.Post, issueNumber int) { + currentUsername := c.GHInfo.GitHubUsername + githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) + githubIssue := &github.IssueRequest{ + State: &(status), + StateReason: &(statusReason), + } + + issue, resp, err := githubClient.Issues.Edit(c.Ctx, owner, repo, issueNumber, githubIssue) + if err != nil { + if resp != nil && resp.Response.StatusCode == http.StatusGone { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Issues are disabled on this repository.", StatusCode: http.StatusMethodNotAllowed}) + return + } + + c.Log.WithError(err).Warnf("Failed to update the issue") + p.writeAPIError(w, &APIErrorResponse{ + ID: "", + Message: fmt.Sprintf("failed to update the issue: %s", getFailReason(resp.StatusCode, + repo, + currentUsername, + )), + StatusCode: resp.StatusCode, + }) + return + } + + var permalinkReplyMessage string + switch statusReason { + case issueCompleted: + permalinkReplyMessage = fmt.Sprintf("Issue closed as completed [#%v](%v)", issueNumber, issue.GetHTMLURL()) + case issueNotPlanned: + permalinkReplyMessage = fmt.Sprintf("Issue closed as not planned [#%v](%v)", issueNumber, issue.GetHTMLURL()) + default: + permalinkReplyMessage = fmt.Sprintf("Issue reopend [#%v](%v)", issueNumber, issue.GetHTMLURL()) + } + + rootID := post.Id + if post.RootId != "" { + // the original post was a reply + rootID = post.RootId + } + + reply := &model.Post{ + Message: permalinkReplyMessage, + ChannelId: post.ChannelId, + RootId: rootID, + UserId: c.UserID, + } + + if _, appErr := p.API.CreatePost(reply); appErr != nil { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to create the notification post %s", post.Id), StatusCode: http.StatusInternalServerError}) + return + } + + var actionButtonTitle string + if status == issueClose { + post.Props[issueStatus] = statusReopen + actionButtonTitle = statusReopen + } else { + post.Props[issueStatus] = statusClose + actionButtonTitle = statusClose + } + + attachment, err := getAttachmentsFromProps(post.Props) + if err != nil { + p.client.Log.Error("Error occurred while getting attachments from props", "PostID", post.Id, "Error", err.Error()) + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("existing attachments format error: %v", err), StatusCode: http.StatusInternalServerError}) + return + } + actions := attachment[0].Actions + for _, action := range actions { + if action.Name == statusClose || action.Name == statusReopen { + action.Name = actionButtonTitle + if status == issueClose { + action.Integration.Context["status"] = "close" + } else { + action.Integration.Context["status"] = "open" + } + } + } + attachment[0].Actions = actions + post.Props[attachmentsForProps] = attachment + + if _, appErr := p.API.UpdatePost(post); appErr != nil { + p.writeAPIError(w, &APIErrorResponse{ID: "", Message: fmt.Sprintf("failed to update the post %s", post.Id), StatusCode: http.StatusInternalServerError}) + } + p.writeJSON(w, issue) +} + func buildPluginURL(client *pluginapi.Client, elem ...string) (string, error) { siteURL, err := getSiteURL(client) if err != nil { @@ -414,3 +607,24 @@ func lastN(s string, n int) string { return string(out) } + +func (p *Plugin) CreateFieldsForIssuePost(assignees []string, labels []string) []*model.SlackAttachmentField { + fields := []*model.SlackAttachmentField{} + if len(assignees) > 0 { + fields = append(fields, &model.SlackAttachmentField{ + Title: "Assignees", + Value: strings.Join(assignees, ", "), + Short: true, + }) + } + + if len(labels) > 0 { + fields = append(fields, &model.SlackAttachmentField{ + Title: "Labels", + Value: strings.Join(labels, ", "), + Short: true, + }) + } + + return fields +} diff --git a/server/plugin/webhook.go b/server/plugin/webhook.go index 37c327b6d..97ba5a880 100644 --- a/server/plugin/webhook.go +++ b/server/plugin/webhook.go @@ -6,6 +6,7 @@ import ( "crypto/sha1" //nolint:gosec // GitHub webhooks are signed using sha1 https://developer.github.com/webhooks/. "encoding/hex" "encoding/json" + "fmt" "html" "io" "net/http" @@ -40,11 +41,6 @@ const ( postPropGithubRepo = "gh_repo" postPropGithubObjectID = "gh_object_id" postPropGithubObjectType = "gh_object_type" - - githubObjectTypeIssue = "issue" - githubObjectTypeIssueComment = "issue_comment" - githubObjectTypePRReviewComment = "pr_review_comment" - githubObjectTypeDiscussionComment = "discussion_comment" ) // RenderConfig holds various configuration options to be used in a template @@ -402,8 +398,8 @@ func (p *Plugin) postPullRequestEvent(event *github.PullRequestEvent) { isPRInDraftState := pr.GetDraft() eventLabel := event.GetLabel().GetName() labels := make([]string, len(pr.Labels)) - for i, v := range pr.Labels { - labels[i] = v.GetName() + for index, label := range pr.Labels { + labels[index] = label.GetName() } closedPRMessage, err := renderTemplate("closedPR", event) @@ -613,8 +609,8 @@ func (p *Plugin) postIssueEvent(event *github.IssuesEvent) { eventLabel := event.GetLabel().GetName() labels := make([]string, len(issue.Labels)) - for i, v := range issue.Labels { - labels[i] = v.GetName() + for index, label := range issue.Labels { + labels[index] = label.GetName() } for _, sub := range subscribedChannels { @@ -637,8 +633,75 @@ func (p *Plugin) postIssueEvent(event *github.IssuesEvent) { } renderedMessage = p.sanitizeDescription(renderedMessage) - post := p.makeBotPost(renderedMessage, "custom_git_issue") + assignees := make([]string, len(issue.Assignees)) + for index, user := range issue.Assignees { + assignees[index] = user.GetLogin() + } + description := "" + if issue.Body != nil { + description = *issue.Body + } + + post := &model.Post{ + UserId: p.BotUserID, + Type: "custom_git_release", + } + if action == actionOpened { + post.Props = model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Pretext: renderedMessage, + Title: fmt.Sprintf("%s #%d", *issue.Title, *issue.Number), + TitleLink: *issue.HTMLURL, + Text: description, + Actions: []*model.PostAction{ + { + Name: "Comment", + Integration: &model.PostActionIntegration{ + Context: map[string]interface{}{ + KeyRepoOwner: repo.GetOwner().GetLogin(), + KeyRepoName: repo.GetName(), + KeyIssueNumber: issue.GetNumber(), + KeyIssueID: issue.GetID(), + KeyStatus: *issue.State, + }, + URL: fmt.Sprintf("%s%s", p.GetPluginAPIPath(), PathOpenIssueCommentModal), + }, + Style: "primary", + }, + { + Name: "Edit", + Integration: &model.PostActionIntegration{ + Context: map[string]interface{}{ + KeyRepoOwner: repo.GetOwner().GetLogin(), + KeyRepoName: repo.GetName(), + KeyIssueNumber: issue.GetNumber(), + KeyIssueID: issue.GetID(), + KeyStatus: *issue.State, + }, + URL: fmt.Sprintf("%s%s", p.GetPluginAPIPath(), PathOpenIssueEditModal), + }, + }, + { + Name: "Close", + Integration: &model.PostActionIntegration{ + Context: map[string]interface{}{ + KeyRepoOwner: repo.GetOwner().GetLogin(), + KeyRepoName: repo.GetName(), + KeyIssueNumber: issue.GetNumber(), + KeyIssueID: issue.GetID(), + KeyStatus: *issue.State, + }, + URL: fmt.Sprintf("%s%s", p.GetPluginAPIPath(), PathOpenIssueStatusModal), + }, + }, + }, + Fields: p.CreateFieldsForIssuePost(assignees, labels), + }, + }, + } + } repoName := strings.ToLower(repo.GetFullName()) issueNumber := issue.Number @@ -806,8 +869,8 @@ func (p *Plugin) postIssueCommentEvent(event *github.IssueCommentEvent) { } labels := make([]string, len(event.GetIssue().Labels)) - for i, v := range event.GetIssue().Labels { - labels[i] = v.GetName() + for index, label := range event.GetIssue().Labels { + labels[index] = label.GetName() } for _, sub := range subs { @@ -855,8 +918,7 @@ func (p *Plugin) postIssueCommentEvent(event *github.IssueCommentEvent) { func (p *Plugin) senderMutedByReceiver(userID string, sender string) bool { var mutedUsernameBytes []byte - err := p.store.Get(userID+"-muted-users", &mutedUsernameBytes) - if err != nil { + if err := p.store.Get(fmt.Sprintf("%s-muted-users", userID), &mutedUsernameBytes); err != nil { p.client.Log.Warn("Failed to get muted users", "userID", userID) return false } @@ -894,8 +956,8 @@ func (p *Plugin) postPullRequestReviewEvent(event *github.PullRequestReviewEvent } labels := make([]string, len(event.GetPullRequest().Labels)) - for i, v := range event.GetPullRequest().Labels { - labels[i] = v.GetName() + for index, label := range event.GetPullRequest().Labels { + labels[index] = label.GetName() } for _, sub := range subs { @@ -944,8 +1006,8 @@ func (p *Plugin) postPullRequestReviewCommentEvent(event *github.PullRequestRevi } labels := make([]string, len(event.GetPullRequest().Labels)) - for i, v := range event.GetPullRequest().Labels { - labels[i] = v.GetName() + for index, label := range event.GetPullRequest().Labels { + labels[index] = label.GetName() } for _, sub := range subs { diff --git a/webapp/src/action_types/index.ts b/webapp/src/action_types/index.ts index 6ea61edf9..eb47b2d14 100644 --- a/webapp/src/action_types/index.ts +++ b/webapp/src/action_types/index.ts @@ -16,9 +16,11 @@ export default { RECEIVED_GITHUB_USER: pluginId + '_received_github_user', RECEIVED_SHOW_RHS_ACTION: pluginId + '_received_rhs_action', UPDATE_RHS_STATE: pluginId + '_update_rhs_state', - CLOSE_CREATE_ISSUE_MODAL: pluginId + '_close_create_modal', - OPEN_CREATE_ISSUE_MODAL: pluginId + '_open_create_modal', - OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST: pluginId + '_open_create_modal_without_post', + CLOSE_CREATE_OR_UPDATE_ISSUE_MODAL: pluginId + '_close_create_or_update_issue_modal', + CLOSE_CLOSE_OR_REOPEN_ISSUE_MODAL: pluginId + '_close_close_or_reopen_issue_modal', + OPEN_CREATE_ISSUE_MODAL_WITH_POST: pluginId + '_open_create_issue_modal_with_post', + OPEN_CLOSE_OR_REOPEN_ISSUE_MODAL: pluginId + '_open_close_or_reopen_issue_modal', + OPEN_CREATE_OR_UPDATE_ISSUE_MODAL: pluginId + '_open_create_or_update_issue_modal', CLOSE_ATTACH_COMMENT_TO_ISSUE_MODAL: pluginId + '_close_attach_modal', OPEN_ATTACH_COMMENT_TO_ISSUE_MODAL: pluginId + '_open_attach_modal', RECEIVED_ATTACH_COMMENT_RESULT: pluginId + '_received_attach_comment', diff --git a/webapp/src/actions/index.ts b/webapp/src/actions/index.ts index af1f7ee22..a67cc515e 100644 --- a/webapp/src/actions/index.ts +++ b/webapp/src/actions/index.ts @@ -9,7 +9,7 @@ import {GetStateFunc} from '../types/store'; import Client from '../client'; import ActionTypes from '../action_types'; -import {APIError, PrsDetailsData, ShowRhsPluginActionData} from '../types/github_types'; +import {APIError, MessageData, PrsDetailsData, ShowRhsPluginActionData} from '../types/github_types'; export function getConnected(reminder = false) { return async (dispatch: DispatchFunc) => { @@ -197,6 +197,24 @@ export function getMilestoneOptions(repo: string) { }; } +export function getIssueInfo(owner: string, repo: string, number: number, post_id: string) { + return async (dispatch: DispatchFunc) => { + let data; + try { + data = await Client.getIssueInfo(owner, repo, number, post_id); + } catch (error) { + return {error}; + } + + const connected = await checkAndHandleNotConnected(data)(dispatch); + if (!connected) { + return {error: data}; + } + + return {data}; + }; +} + export function getMentions() { return async (dispatch: DispatchFunc) => { let data; @@ -279,28 +297,51 @@ export function updateRhsState(rhsState: string) { }; } -export function openCreateIssueModal(postId: string) { +export function openCreateIssueModalWithPost(postId: string) { return { - type: ActionTypes.OPEN_CREATE_ISSUE_MODAL, + type: ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITH_POST, data: { postId, }, }; } -export function openCreateIssueModalWithoutPost(title: string, channelId: string) { +export function openCreateOrUpdateIssueModal(messageData: MessageData) { return { - type: ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST, + type: ActionTypes.OPEN_CREATE_OR_UPDATE_ISSUE_MODAL, data: { - title, - channelId, + messageData, }, }; } -export function closeCreateIssueModal() { +export function openCloseOrReopenIssueModal(messageData: MessageData) { return { - type: ActionTypes.CLOSE_CREATE_ISSUE_MODAL, + type: ActionTypes.OPEN_CLOSE_OR_REOPEN_ISSUE_MODAL, + data: { + messageData, + }, + }; +} + +export function openCreateCommentOnIssueModal(messageData: MessageData) { + return { + type: ActionTypes.OPEN_ATTACH_COMMENT_TO_ISSUE_MODAL, + data: { + messageData, + }, + }; +} + +export function closeCreateOrUpdateIssueModal() { + return { + type: ActionTypes.CLOSE_CREATE_OR_UPDATE_ISSUE_MODAL, + }; +} + +export function closeCloseOrReOpenIssueModal() { + return { + type: ActionTypes.CLOSE_CLOSE_OR_REOPEN_ISSUE_MODAL, }; } @@ -313,7 +354,43 @@ export function createIssue(payload: CreateIssuePayload) { return {error}; } - const connected = await checkAndHandleNotConnected(data); + const connected = checkAndHandleNotConnected(data); + if (!connected) { + return {error: data}; + } + + return {data}; + }; +} + +export function closeOrReopenIssue(payload: CloseOrReopenIssuePayload) { + return async (dispatch: DispatchFunc) => { + let data; + try { + data = await Client.closeOrReopenIssue(payload); + } catch (error) { + return {error}; + } + + const connected = checkAndHandleNotConnected(data); + if (!connected) { + return {error: data}; + } + + return {data}; + }; +} + +export function updateIssue(payload: UpdateIssuePayload) { + return async (dispatch: DispatchFunc) => { + let data; + try { + data = await Client.updateIssue(payload); + } catch (error) { + return {error}; + } + + const connected = checkAndHandleNotConnected(data); if (!connected) { return {error: data}; } @@ -346,7 +423,7 @@ export function attachCommentToIssue(payload: AttachCommentToIssuePayload) { return {error}; } - const connected = await checkAndHandleNotConnected(data); + const connected = checkAndHandleNotConnected(data); if (!connected) { return {error: data}; } diff --git a/webapp/src/client/client.js b/webapp/src/client/client.js index e381dbf5d..bc74d7d43 100644 --- a/webapp/src/client/client.js +++ b/webapp/src/client/client.js @@ -7,6 +7,10 @@ import {ClientError} from 'mattermost-redux/client/client4'; import manifest from '../manifest'; export default class Client { + getIssueInfo = async (owner, repo, issueNumber, postID) => { + return this.doGet(`${this.url}/issue_info?owner=${owner}&repo=${repo}&number=${issueNumber}&postId=${postID}`); + } + setServerRoute(url) { this.url = url + `/plugins/${manifest.id}/api/v1`; } @@ -20,7 +24,7 @@ export default class Client { } getPrsDetails = async (prList) => { - return this.doPost(`${this.url}/prsdetails`, prList); + return this.doPost(`${this.url}/prs_details`, prList); } getMentions = async () => { @@ -48,15 +52,23 @@ export default class Client { } createIssue = async (payload) => { - return this.doPost(`${this.url}/createissue`, payload); + return this.doPost(`${this.url}/create_issue`, payload); + } + + closeOrReopenIssue = async (payload) => { + return this.doPost(`${this.url}/close_or_reopen_issue`, payload); + } + + updateIssue = async (payload) => { + return this.doPost(`${this.url}/update_issue`, payload); } searchIssues = async (searchTerm) => { - return this.doGet(`${this.url}/searchissues?term=${searchTerm}`); + return this.doGet(`${this.url}/search_issues?term=${searchTerm}`); } attachCommentToIssue = async (payload) => { - return this.doPost(`${this.url}/createissuecomment`, payload); + return this.doPost(`${this.url}/create_issue_comment`, payload); } getIssue = async (owner, repo, issueNumber) => { diff --git a/webapp/src/components/input.jsx b/webapp/src/components/input.jsx index 324cbdcd3..c9392c3f3 100644 --- a/webapp/src/components/input.jsx +++ b/webapp/src/components/input.jsx @@ -16,6 +16,7 @@ export default class Input extends PureComponent { PropTypes.string, PropTypes.number, ]), + error: PropTypes.string, addValidate: PropTypes.func, removeValidate: PropTypes.func, maxLength: PropTypes.number, @@ -84,10 +85,10 @@ export default class Input extends PureComponent { const value = this.props.value || ''; let validationError = null; - if (this.props.required && this.state.invalid) { + if ((this.props.required && this.state.invalid) || this.props.error) { validationError = (

- {requiredMsg} + {requiredMsg || this.props.error}

); } diff --git a/webapp/src/components/modals/attach_comment_to_issue/attach_comment_to_issue.jsx b/webapp/src/components/modals/attach_comment_to_issue/attach_comment_to_issue.jsx index 2e996df0c..325aed1ab 100644 --- a/webapp/src/components/modals/attach_comment_to_issue/attach_comment_to_issue.jsx +++ b/webapp/src/components/modals/attach_comment_to_issue/attach_comment_to_issue.jsx @@ -12,6 +12,7 @@ import GithubIssueSelector from '@/components/github_issue_selector'; import {getErrorMessage} from '@/utils/user_utils'; const initialState = { + comment: '', submitting: false, issueValue: null, textSearchTerms: '', @@ -25,6 +26,7 @@ export default class AttachIssueModal extends PureComponent { post: PropTypes.object, theme: PropTypes.object.isRequired, visible: PropTypes.bool.isRequired, + messageData: PropTypes.object, }; constructor(props) { @@ -38,6 +40,30 @@ export default class AttachIssueModal extends PureComponent { } if (!this.state.issueValue) { + if (!this.state.comment.trim()) { + this.setState({error: 'This field is required.', submitting: false}); + return; + } + + const {repo_owner, repo_name, issue_number, postId} = this.props.messageData ?? {}; + const issue = { + owner: repo_owner, + repo: repo_name, + number: issue_number, + comment: this.state.comment, + post_id: postId, + show_attached_message: false, + }; + this.setState({submitting: true}); + + this.props.create(issue).then((created) => { + if (created.error) { + const errMessage = getErrorMessage(created.error.message); + this.setState({error: errMessage, submitting: false}); + return; + } + this.handleClose(e); + }); return; } @@ -51,8 +77,9 @@ export default class AttachIssueModal extends PureComponent { owner, repo, number, - comment: this.props.post.message, - post_id: this.props.post.id, + comment: this.state.comment, + post_id: this.props.messageData?.postId, + show_attached_message: true, }; this.setState({submitting: true}); @@ -68,6 +95,8 @@ export default class AttachIssueModal extends PureComponent { }); }; + handleIssueCommentChange = (comment) => this.setState({comment, error: ''}); + handleClose = (e) => { if (e && e.preventDefault) { e.preventDefault(); @@ -82,16 +111,34 @@ export default class AttachIssueModal extends PureComponent { }); }; + componentDidUpdate(prevProps) { + if (this.props.post && !this.props.messageData && !prevProps.post) { + this.setState({comment: this.props.post.message}); // eslint-disable-line react/no-did-update-set-state + } + } + render() { - const {visible, theme} = this.props; - const {error, submitting} = this.state; + const {error, submitting, comment, issueValue} = this.state; + const {visible, theme, messageData} = this.props; const style = getStyle(theme); - if (!visible) { return null; } - const component = ( + const {issue_number} = messageData ?? {}; + const modalTitle = issue_number ? 'Create a comment to GitHub Issue' : 'Attach Message to GitHub Issue'; + const component = issue_number ? ( +
+ +
+ ) : (
); @@ -123,7 +169,7 @@ export default class AttachIssueModal extends PureComponent { > - {'Attach Message to GitHub Issue'} + {modalTitle}
{ const {id: pluginId} = manifest; - const postId = state[`plugins-${pluginId}`].attachCommentToIssueModalForPostId; - const post = getPost(state, postId); + const {postId, messageData} = state[`plugins-${pluginId}`].attachCommentToIssueModalForPostId; + const currentPostId = postId || messageData?.postId; + const post = currentPostId ? getPost(state, currentPostId) : null; return { visible: state[`plugins-${pluginId}`].attachCommentToIssueModalVisible, post, + messageData, }; }; diff --git a/webapp/src/components/modals/close_reopen_issue/index.tsx b/webapp/src/components/modals/close_reopen_issue/index.tsx new file mode 100644 index 000000000..f40833453 --- /dev/null +++ b/webapp/src/components/modals/close_reopen_issue/index.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {Modal} from 'react-bootstrap'; + +import {Theme} from 'mattermost-redux/types/preferences'; + +import {useDispatch, useSelector} from 'react-redux'; + +import {closeCloseOrReOpenIssueModal, closeOrReopenIssue} from '../../../actions'; + +import {getCloseOrReopenIssueModalData} from '../../../selectors'; + +import FormButton from '../../form_button'; +import Input from '../../input'; + +const CloseOrReopenIssueModal = ({theme}: {theme: Theme}) => { + const dispatch = useDispatch(); + const {messageData, visible} = useSelector(getCloseOrReopenIssueModalData); + const [statusReason, setStatusReason] = useState('completed'); + const [submitting, setSubmitting] = useState(false); + const [comment, setComment] = useState(''); + if (!visible) { + return null; + } + + const handleCloseOrReopenIssue = async (e: React.SyntheticEvent) => { + if (e && e.preventDefault) { + e.preventDefault(); + } + + const issue = { + channel_id: messageData.channel_id, + issue_comment: comment, + status_reason: messageData?.status === 'open' ? statusReason : 'reopened', // Sending the reason for the issue edit API call + repo: messageData.repo_name, + number: messageData.issue_number, + owner: messageData.repo_owner, + status: messageData.status === 'open' ? 'Close' : 'Reopen', // Sending the state of the issue which we want it to be after the edit API call + postId: messageData.postId, + }; + setSubmitting(true); + await dispatch(closeOrReopenIssue(issue)); + setSubmitting(false); + handleClose(e); + }; + + const handleClose = (e: React.SyntheticEvent) => { + if (e && e.preventDefault) { + e.preventDefault(); + } + dispatch(closeCloseOrReOpenIssueModal()); + }; + + const handleStatusChange = (e: React.ChangeEvent) => setStatusReason(e.target.value); + + const handleIssueCommentChange = (updatedComment: string) => setComment(updatedComment); + + const style = getStyle(theme); + const issueAction = messageData.status === 'open' ? 'Close Issue' : 'Open Issue'; + const modalTitle = issueAction; + const status = issueAction; + const savingMessage = messageData.status === 'open' ? 'Closing' : 'Reopening'; + const submitError = null; + + const component = (messageData.status === 'open') ? ( +
+ +
+ + +
+ + +
+
+ ) : ( +
+ +
+ ); + + return ( + + + + {modalTitle} + + + + + {component} + + + {submitError} + + + {status} + + + + + ); +}; + +const getStyle = (theme: Theme) => ({ + modal: { + padding: '2em 2em 3em', + color: theme.centerChannelColor, + backgroundColor: theme.centerChannelBg, + }, + descriptionArea: { + height: 'auto', + width: '100%', + color: '#000', + }, + radioButtons: { + margin: '0.4em 0.6em', + }, +}); + +export default CloseOrReopenIssueModal; diff --git a/webapp/src/components/modals/create_issue/index.js b/webapp/src/components/modals/create_issue/index.js deleted file mode 100644 index 2b5b37017..000000000 --- a/webapp/src/components/modals/create_issue/index.js +++ /dev/null @@ -1,31 +0,0 @@ -// 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 {getPost} from 'mattermost-redux/selectors/entities/posts'; - -import manifest from '@/manifest'; -import {closeCreateIssueModal, createIssue} from '@/actions'; - -import CreateIssueModal from './create_issue'; - -const mapStateToProps = (state) => { - const {id: pluginId} = manifest; - const {postId, title, channelId} = state[`plugins-${pluginId}`].createIssueModal; - const post = (postId) ? getPost(state, postId) : null; - - return { - visible: state[`plugins-${pluginId}`].isCreateIssueModalVisible, - post, - title, - channelId, - }; -}; - -const mapDispatchToProps = (dispatch) => bindActionCreators({ - close: closeCreateIssueModal, - create: createIssue, -}, dispatch); - -export default connect(mapStateToProps, mapDispatchToProps)(CreateIssueModal); diff --git a/webapp/src/components/modals/create_issue/create_issue.jsx b/webapp/src/components/modals/create_update_issue/create_update_issue.jsx similarity index 61% rename from webapp/src/components/modals/create_issue/create_issue.jsx rename to webapp/src/components/modals/create_update_issue/create_update_issue.jsx index 180ccfd33..e5a67773f 100644 --- a/webapp/src/components/modals/create_issue/create_issue.jsx +++ b/webapp/src/components/modals/create_update_issue/create_update_issue.jsx @@ -22,6 +22,7 @@ const initialState = { repo: null, issueTitle: '', issueDescription: '', + channelId: '', labels: [], assignees: [], milestone: null, @@ -29,15 +30,16 @@ const initialState = { issueTitleValid: true, }; -export default class CreateIssueModal extends PureComponent { +export default class CreateOrUpdateIssueModal extends PureComponent { static propTypes = { + update: PropTypes.func.isRequired, close: PropTypes.func.isRequired, create: PropTypes.func.isRequired, + getIssueInfo: PropTypes.func.isRequired, post: PropTypes.object, - title: PropTypes.string, - channelId: PropTypes.string, theme: PropTypes.object.isRequired, visible: PropTypes.bool.isRequired, + messageData: PropTypes.object, }; constructor(props) { @@ -46,17 +48,50 @@ export default class CreateIssueModal extends PureComponent { this.validator = new Validator(); } + getIssueInfo = async () => { + const {repo_owner, repo_name, issue_number, postId} = this.props.messageData; + const issueInfo = await this.props.getIssueInfo(repo_owner, repo_name, issue_number, postId); + return issueInfo; + } + + updateState(issueInfo) { + const {channel_id, title, description, milestone_title, milestone_number, repo_full_name} = issueInfo ?? {}; + const assignees = issueInfo?.assignees ?? []; + const labels = issueInfo?.labels ?? []; + + this.setState({milestone: { + value: milestone_number, + label: milestone_title, + }, + repo: { + name: repo_full_name, + }, + assignees, + labels, + channelId: channel_id, + issueDescription: description, + issueTitle: title.substring(0, MAX_TITLE_LENGTH)}); + } + + /* eslint-disable react/no-did-update-set-state*/ componentDidUpdate(prevProps) { - if (this.props.post && !prevProps.post) { - this.setState({issueDescription: this.props.post.message}); //eslint-disable-line react/no-did-update-set-state - } else if (this.props.channelId && (this.props.channelId !== prevProps.channelId || this.props.title !== prevProps.title)) { - const title = this.props.title.substring(0, MAX_TITLE_LENGTH); - this.setState({issueTitle: title}); // eslint-disable-line react/no-did-update-set-state + if (this.props.post && !this.props.messageData && !prevProps.post) { + this.setState({issueDescription: this.props.post.message}); + } + + if (this.props.messageData?.repo_owner && !prevProps.visible && this.props.visible) { + this.getIssueInfo().then((issueInfo) => { + this.updateState(issueInfo.data); + }); + } else if (this.props.messageData?.channel_id && (this.props.messageData?.channel_id !== prevProps.messageData?.channel_id || this.props.messageData?.title !== prevProps.messageData?.title)) { + this.updateState(this.props.messageData); } } + /* eslint-enable */ - // handle issue creation after form is populated - handleCreate = async (e) => { + // handle issue creation or updation after form is populated + handleCreateOrUpdate = async (e) => { + const {issue_number, postId} = this.props.messageData ?? {}; if (e && e.preventDefault) { e.preventDefault(); } @@ -70,9 +105,6 @@ export default class CreateIssueModal extends PureComponent { return; } - const {post} = this.props; - const postId = post ? post.id : ''; - const issue = { title: this.state.issueTitle, body: this.state.issueDescription, @@ -81,20 +113,36 @@ export default class CreateIssueModal extends PureComponent { assignees: this.state.assignees, milestone: this.state.milestone && this.state.milestone.value, post_id: postId, - channel_id: this.props.channelId, + channel_id: this.state.channelId, + issue_number, }; + if (!issue.repo) { + issue.repo = this.props.messageData.repo_owner + this.props.messageData.repo_name; + } this.setState({submitting: true}); - - const created = await this.props.create(issue); - if (created.error) { - const errMessage = getErrorMessage(created.error.message); - this.setState({ - error: errMessage, - showErrors: true, - submitting: false, - }); - return; + if (issue_number) { + const updated = await this.props.update(issue); + if (updated?.error) { + const errMessage = getErrorMessage(updated.error.message); + this.setState({ + error: errMessage, + showErrors: true, + submitting: false, + }); + return; + } + } else { + const created = await this.props.create(issue); + if (created.error) { + const errMessage = getErrorMessage(created.error.message); + this.setState({ + error: errMessage, + showErrors: true, + submitting: false, + }); + return; + } } this.handleClose(e); }; @@ -120,7 +168,7 @@ export default class CreateIssueModal extends PureComponent { this.setState({issueDescription}); renderIssueAttributeSelectors = () => { - if (!this.state.repo || (this.state.repo.permissions && !this.state.repo.permissions.push)) { + if (!this.state.repo || !this.state.repo.name || (this.state.repo.permissions && !this.state.repo.permissions.push)) { return null; } @@ -156,12 +204,14 @@ export default class CreateIssueModal extends PureComponent { } const theme = this.props.theme; - const {error, submitting} = this.state; + const {error, submitting, showErrors, issueTitle, issueDescription, repo} = this.state; const style = getStyle(theme); + const {repo_name, repo_owner} = this.props.messageData ?? {}; + const modalTitle = repo_name ? 'Update GitHub Issue' : 'Create GitHub Issue'; const requiredMsg = 'This field is required.'; let issueTitleValidationError = null; - if (this.state.showErrors && !this.state.issueTitleValid) { + if (showErrors && !issueTitle) { issueTitleValidationError = (

+ + + + {issueTitleValidationError} + + {this.renderIssueAttributeSelectors()} + + + + ) : (

{issueTitleValidationError} @@ -209,7 +289,7 @@ export default class CreateIssueModal extends PureComponent {
@@ -225,11 +305,13 @@ export default class CreateIssueModal extends PureComponent { backdrop='static' > - {'Create GitHub Issue'} + + {modalTitle} +
{ + const {id: pluginId} = manifest; + const {postId, messageData} = state[`plugins-${pluginId}`].createOrUpdateIssueModal; + const currentPostId = postId || messageData?.postId; + const post = currentPostId ? getPost(state, currentPostId) : null; + + return { + visible: state[`plugins-${pluginId}`].isCreateOrUpdateIssueModalVisible, + post, + messageData, + }; +}; + +const mapDispatchToProps = (dispatch) => bindActionCreators({ + close: closeCreateOrUpdateIssueModal, + create: createIssue, + update: updateIssue, + getIssueInfo, +}, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(CreateOrUpdateIssueModal); diff --git a/webapp/src/components/post_menu_action/create_issue/index.js b/webapp/src/components/post_menu_action/create_issue/index.js index e39c6d322..3561f2a59 100644 --- a/webapp/src/components/post_menu_action/create_issue/index.js +++ b/webapp/src/components/post_menu_action/create_issue/index.js @@ -7,7 +7,7 @@ import {getPost} from 'mattermost-redux/selectors/entities/posts'; import {isSystemMessage} from 'mattermost-redux/utils/post_utils'; import manifest from '@/manifest'; -import {openCreateIssueModal} from '@/actions'; +import {openCreateIssueModalWithPost} from '@/actions'; import CreateIssuePostMenuAction from './create_issue'; @@ -21,7 +21,7 @@ const mapStateToProps = (state, ownProps) => { }; const mapDispatchToProps = (dispatch) => bindActionCreators({ - open: openCreateIssueModal, + open: openCreateIssueModalWithPost, }, dispatch); export default connect(mapStateToProps, mapDispatchToProps)(CreateIssuePostMenuAction); diff --git a/webapp/src/components/sidebar_buttons/sidebar_buttons.jsx b/webapp/src/components/sidebar_buttons/sidebar_buttons.jsx index 63d1d5c8b..651a66eec 100644 --- a/webapp/src/components/sidebar_buttons/sidebar_buttons.jsx +++ b/webapp/src/components/sidebar_buttons/sidebar_buttons.jsx @@ -209,28 +209,26 @@ export default class SidebarButtons extends React.PureComponent { } } -const getStyle = makeStyleFromTheme((theme) => { - return { - buttonTeam: { - color: changeOpacity(theme.sidebarText, 0.6), - display: 'block', - marginBottom: '10px', - width: '100%', - }, - buttonHeader: { - color: changeOpacity(theme.sidebarText, 0.6), - textAlign: 'center', - cursor: 'pointer', - }, - containerHeader: { - marginTop: '10px', - marginBottom: '5px', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-around', - padding: '0 10px', - }, - containerTeam: { - }, - }; -}); +const getStyle = makeStyleFromTheme((theme) => ({ + buttonTeam: { + color: changeOpacity(theme.sidebarText, 0.6), + display: 'block', + marginBottom: '10px', + width: '100%', + }, + buttonHeader: { + color: changeOpacity(theme.sidebarText, 0.6), + textAlign: 'center', + cursor: 'pointer', + }, + containerHeader: { + marginTop: '10px', + marginBottom: '5px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-around', + padding: '0 10px', + }, + containerTeam: { + }, +})); diff --git a/webapp/src/index.js b/webapp/src/index.js index cf839b2bc..0cb85a7a5 100644 --- a/webapp/src/index.js +++ b/webapp/src/index.js @@ -3,7 +3,8 @@ import AttachCommentToIssuePostMenuAction from '@/components/post_menu_actions/attach_comment_to_issue'; import AttachCommentToIssueModal from '@/components/modals/attach_comment_to_issue'; -import CreateIssueModal from './components/modals/create_issue'; +import CreateOrUpdateIssueModal from './components/modals/create_update_issue'; +import CloseOrReopenIssueModal from './components/modals/close_reopen_issue'; import CreateIssuePostMenuAction from './components/post_menu_action/create_issue'; import SidebarHeader from './components/sidebar_header'; import TeamSidebar from './components/team_sidebar'; @@ -13,7 +14,7 @@ import LinkTooltip from './components/link_tooltip'; import Reducer from './reducers'; import Client from './client'; import {getConnected, setShowRHSAction} from './actions'; -import {handleConnect, handleDisconnect, handleConfigurationUpdate, handleOpenCreateIssueModal, handleReconnect, handleRefresh} from './websocket'; +import {handleConnect, handleDisconnect, handleConfigurationUpdate, handleOpenCreateOrUpdateIssueModal, handleOpenCreateCommentOnIssueModal, handleOpenCloseOrReopenIssueModal, handleReconnect, handleRefresh, handleOpenEditIssueModal} from './websocket'; import {getServerRoute} from './selectors'; import manifest from './manifest'; @@ -32,7 +33,8 @@ class PluginClass { registry.registerLeftSidebarHeaderComponent(SidebarHeader); registry.registerBottomTeamSidebarComponent(TeamSidebar); registry.registerPopoverUserAttributesComponent(UserAttribute); - registry.registerRootComponent(CreateIssueModal); + registry.registerRootComponent(CreateOrUpdateIssueModal); + registry.registerRootComponent(CloseOrReopenIssueModal); registry.registerPostDropdownMenuComponent(CreateIssuePostMenuAction); registry.registerRootComponent(AttachCommentToIssueModal); registry.registerPostDropdownMenuComponent(AttachCommentToIssuePostMenuAction); @@ -45,7 +47,11 @@ class PluginClass { registry.registerWebSocketEventHandler(`custom_${pluginId}_disconnect`, handleDisconnect(store)); registry.registerWebSocketEventHandler(`custom_${pluginId}_config_update`, handleConfigurationUpdate(store)); registry.registerWebSocketEventHandler(`custom_${pluginId}_refresh`, handleRefresh(store)); - registry.registerWebSocketEventHandler(`custom_${pluginId}_createIssue`, handleOpenCreateIssueModal(store)); + registry.registerWebSocketEventHandler(`custom_${pluginId}_createIssue`, handleOpenCreateOrUpdateIssueModal(store)); + registry.registerWebSocketEventHandler(`custom_${pluginId}_open_comment_modal`, handleOpenCreateCommentOnIssueModal(store)); + registry.registerWebSocketEventHandler(`custom_${pluginId}_open_edit_modal`, handleOpenEditIssueModal(store)); + registry.registerWebSocketEventHandler(`custom_${pluginId}_open_status_modal`, handleOpenCloseOrReopenIssueModal(store)); + registry.registerReconnectHandler(handleReconnect(store)); activityFunc = () => { diff --git a/webapp/src/reducers/index.ts b/webapp/src/reducers/index.ts index 7c2b49d24..9a6caa354 100644 --- a/webapp/src/reducers/index.ts +++ b/webapp/src/reducers/index.ts @@ -3,7 +3,7 @@ import {combineReducers} from 'redux'; -import {AttachCommentToIssueModalForPostIdData, ConfigurationData, ConnectedData, CreateIssueModalData, GithubUsersData, MentionsData, PrsDetailsData, ShowRhsPluginActionData, SidebarContentData, UserSettingsData, YourReposData} from '../types/github_types'; +import {AttachCommentToIssueModalForPostIdData, CloseOrReopenIssueModalData, ConfigurationData, ConnectedData, CreateIssueModalData, GithubUsersData, MentionsData, MessageData, PrsDetailsData, ShowRhsPluginActionData, SidebarContentData, UserSettingsData, YourReposData} from '../types/github_types'; import ActionTypes from '../action_types'; import Constants from '../constants'; @@ -163,12 +163,23 @@ function rhsState(state = null, action: {type: string, state: string}) { } } -const isCreateIssueModalVisible = (state = false, action: {type: string}) => { +const isCreateOrUpdateIssueModalVisible = (state = false, action: {type: string}) => { switch (action.type) { - case ActionTypes.OPEN_CREATE_ISSUE_MODAL: - case ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST: + case ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITH_POST: + case ActionTypes.OPEN_CREATE_OR_UPDATE_ISSUE_MODAL: return true; - case ActionTypes.CLOSE_CREATE_ISSUE_MODAL: + case ActionTypes.CLOSE_CREATE_OR_UPDATE_ISSUE_MODAL: + return false; + default: + return state; + } +}; + +const isCloseOrReopenIssueModalVisible = (state = false, action: {type: string}) => { + switch (action.type) { + case ActionTypes.OPEN_CLOSE_OR_REOPEN_ISSUE_MODAL: + return true; + case ActionTypes.CLOSE_CLOSE_OR_REOPEN_ISSUE_MODAL: return false; default: return state; @@ -186,27 +197,44 @@ const attachCommentToIssueModalVisible = (state = false, action: {type: string}) } }; -const createIssueModal = (state = {} as CreateIssueModalData, action: {type: string, data: CreateIssueModalData}) => { +const createOrUpdateIssueModal = (state = {} as CreateIssueModalData, action: {type: string, data: CreateIssueModalData}) => { switch (action.type) { - case ActionTypes.OPEN_CREATE_ISSUE_MODAL: - case ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST: + case ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITH_POST: + case ActionTypes.OPEN_CREATE_OR_UPDATE_ISSUE_MODAL: return { ...state, postId: action.data.postId, - title: action.data.title, - channelId: action.data.channelId, + messageData: action.data.messageData, + }; + case ActionTypes.CLOSE_CREATE_OR_UPDATE_ISSUE_MODAL: + return {}; + default: + return state; + } +}; + +const closeOrReopenIssueModal = (state = {}, action: {type: string, data: CloseOrReopenIssueModalData}) => { + switch (action.type) { + case ActionTypes.OPEN_CLOSE_OR_REOPEN_ISSUE_MODAL: + return { + ...state, + messageData: action.data.messageData, }; - case ActionTypes.CLOSE_CREATE_ISSUE_MODAL: + case ActionTypes.CLOSE_CLOSE_OR_REOPEN_ISSUE_MODAL: return {}; default: return state; } }; -const attachCommentToIssueModalForPostId = (state = '', action: {type: string, data: AttachCommentToIssueModalForPostIdData}) => { +const attachCommentToIssueModalForPostId = (state = {}, action: {type: string, data: AttachCommentToIssueModalForPostIdData}) => { switch (action.type) { case ActionTypes.OPEN_ATTACH_COMMENT_TO_ISSUE_MODAL: - return action.data.postId; + return { + ...state, + postId: action.data.postId, + messageData: action.data.messageData, + }; case ActionTypes.CLOSE_ATTACH_COMMENT_TO_ISSUE_MODAL: return ''; default: @@ -229,8 +257,10 @@ export default combineReducers({ githubUsers, rhsPluginAction, rhsState, - isCreateIssueModalVisible, - createIssueModal, + isCreateOrUpdateIssueModalVisible, + isCloseOrReopenIssueModalVisible, + createOrUpdateIssueModal, + closeOrReopenIssueModal, attachCommentToIssueModalVisible, attachCommentToIssueModalForPostId, sidebarContent, diff --git a/webapp/src/selectors.ts b/webapp/src/selectors.ts index 47b710182..04390a6c7 100644 --- a/webapp/src/selectors.ts +++ b/webapp/src/selectors.ts @@ -1,9 +1,9 @@ -import {getConfig} from 'mattermost-redux/selectors/entities/general'; - import {createSelector} from 'reselect'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; + import {GlobalState, PluginState} from './types/store'; -import {GithubIssueData, SidebarData, PrsDetailsData, UnreadsData} from './types/github_types'; +import {GithubIssueData, SidebarData, PrsDetailsData, UnreadsData, CloseOrReopenIssueModalData} from './types/github_types'; const emptyArray: GithubIssueData[] | UnreadsData[] = []; @@ -22,6 +22,17 @@ export const getServerRoute = (state: GlobalState) => { return basePath; }; +export const getCloseOrReopenIssueModalData = createSelector( + getPluginState, + (pluginState) => { + const {messageData} = pluginState.closeOrReopenIssueModal as CloseOrReopenIssueModalData; + return { + visible: pluginState.isCloseOrReopenIssueModalVisible, + messageData, + }; + }, +); + function mapPrsToDetails(prs: GithubIssueData[], details: PrsDetailsData[]) { if (!prs) { return []; diff --git a/webapp/src/types/common/index.d.ts b/webapp/src/types/common/index.d.ts new file mode 100644 index 000000000..3b6ea1f0f --- /dev/null +++ b/webapp/src/types/common/index.d.ts @@ -0,0 +1,4 @@ +type WebsocketEventParams = { + event: string, + data: Record, +} diff --git a/webapp/src/types/github_types.ts b/webapp/src/types/github_types.ts index e872acad0..e2bc4b627 100644 --- a/webapp/src/types/github_types.ts +++ b/webapp/src/types/github_types.ts @@ -121,10 +121,16 @@ export type CreateIssueModalData = { title: string; channelId: string; postId: string; + messageData: MessageData +} + +export type CloseOrReopenIssueModalData = { + messageData: MessageData } export type AttachCommentToIssueModalForPostIdData = { postId: string; + messageData: MessageData; } export type APIError = { @@ -142,3 +148,12 @@ export type SidebarData = { orgs: string[], rhsState?: string | null } + +export type MessageData = { + repo_owner: string, + repo_name: string, + issue_number: number, + postId: string, + status: string, + channel_id: string, +} diff --git a/webapp/src/types/payload.d.ts b/webapp/src/types/payload.d.ts index e834d5907..4eb13cb7f 100644 --- a/webapp/src/types/payload.d.ts +++ b/webapp/src/types/payload.d.ts @@ -16,3 +16,18 @@ type CreateIssuePayload = { assignees: string[]; milestone: number; } + +type UpdateIssuePayload = CreateIssuePayload & { + issue_number: number; +} + +type CloseOrReopenIssuePayload = { + channel_id: string; + issue_comment: string; + status_reason: string; + number: number; + owner: string; + repo: string; + status: string; + postId: string; +} diff --git a/webapp/src/websocket/index.js b/webapp/src/websocket/index.js index c7e1e23fb..24fbba271 100644 --- a/webapp/src/websocket/index.js +++ b/webapp/src/websocket/index.js @@ -5,8 +5,10 @@ import ActionTypes from '../action_types'; import Constants from '../constants'; import { getConnected, + openCreateOrUpdateIssueModal, + openCreateCommentOnIssueModal, + openCloseOrReopenIssueModal, getSidebarContent, - openCreateIssueModalWithoutPost, } from '../actions'; import manifest from '../manifest'; @@ -91,11 +93,62 @@ export function handleRefresh(store) { }; } -export function handleOpenCreateIssueModal(store) { +export function handleOpenCreateOrUpdateIssueModal(store) { return (msg) => { if (!msg.data) { return; } - store.dispatch(openCreateIssueModalWithoutPost(msg.data.title, msg.data.channel_id)); + store.dispatch(openCreateOrUpdateIssueModal(msg.data)); + }; +} + +export function handleOpenEditIssueModal(store) { + return (msg) => { + if (!msg.data) { + return; + } + const editIssueModalData = { + repo_owner: msg.data.repo_owner, + repo_name: msg.data.repo_name, + issue_number: msg.data.issue_number, + postId: msg.data.postId, + status: msg.data.status, + channel_id: msg.data.channel_id, + }; + store.dispatch(openCreateOrUpdateIssueModal(editIssueModalData)); + }; +} + +export function handleOpenCreateCommentOnIssueModal(store) { + return (msg) => { + if (!msg.data) { + return; + } + const commmentModalData = { + repo_owner: msg.data.repo_owner, + repo_name: msg.data.repo_name, + issue_number: msg.data.issue_number, + postId: msg.data.postId, + status: msg.data.status, + channel_id: msg.data.channel_id, + }; + store.dispatch(openCreateCommentOnIssueModal(commmentModalData)); + }; +} + +export function handleOpenCloseOrReopenIssueModal(store) { + return (msg) => { + if (!msg.data) { + return; + } + const statusModalData = { + repo_owner: msg.data.repo_owner, + repo_name: msg.data.repo_name, + issue_number: msg.data.issue_number, + postId: msg.data.postId, + status: msg.data.status, + channel_id: msg.data.channel_id, + }; + store.dispatch(openCloseOrReopenIssueModal(statusModalData)); }; }