diff --git a/plugin.json b/plugin.json index f0a2c923d..000249808 100644 --- a/plugin.json +++ b/plugin.json @@ -114,6 +114,13 @@ } ] }, + { + "key": "AIAgents", + "display_name": "AI Agents:", + "type": "text", + "help_text": "Comma-separated list of AI agents in Name:@mention format (e.g., 'MyBot:@mybot'). Claude (@claude) and Cursor (@cursor) are included by default.", + "default": "" + }, { "key": "EnableWebhookEventLogging", "display_name": "Enable Webhook Event Logging:", diff --git a/server/plugin/api.go b/server/plugin/api.go index 05e32da5f..0109271a2 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -183,6 +183,13 @@ func (p *Plugin) initializeAPI() { 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("/pr-review-threads", p.checkAuth(p.attachUserContext(p.getPRReviewThreads), ResponseTypePlain)).Methods(http.MethodGet) + apiRouter.HandleFunc("/pr-review-comment-reply", p.checkAuth(p.attachUserContext(p.replyToReviewComment), ResponseTypePlain)).Methods(http.MethodPost) + apiRouter.HandleFunc("/pr-review-comment-reaction", p.checkAuth(p.attachUserContext(p.toggleReviewCommentReaction), ResponseTypePlain)).Methods(http.MethodPost) + apiRouter.HandleFunc("/pr-resolve-thread", p.checkAuth(p.attachUserContext(p.resolveReviewThread), ResponseTypePlain)).Methods(http.MethodPost) + apiRouter.HandleFunc("/pr-create-comment", p.checkAuth(p.attachUserContext(p.createPRComment), ResponseTypePlain)).Methods(http.MethodPost) + apiRouter.HandleFunc("/plugin-settings/ai-agents", p.checkAuth(p.attachUserContext(p.getAIAgents), ResponseTypePlain)).Methods(http.MethodGet) + apiRouter.HandleFunc("/config", checkPluginRequest(p.getConfig)).Methods(http.MethodGet) apiRouter.HandleFunc("/token", checkPluginRequest(p.getToken)).Methods(http.MethodGet) } @@ -1814,3 +1821,402 @@ func parseRepo(repoParam string) (owner, repo string, err error) { return splitted[0], splitted[1], nil } + +func (p *Plugin) getPRReviewThreads(c *UserContext, w http.ResponseWriter, r *http.Request) { + owner := r.FormValue("owner") + repo := r.FormValue("repo") + numberStr := r.FormValue("number") + + if owner == "" { + p.writeAPIError(w, &APIErrorResponse{Message: "Invalid or missing param 'owner'.", StatusCode: http.StatusBadRequest}) + return + } + + if repo == "" { + p.writeAPIError(w, &APIErrorResponse{Message: "Invalid or missing param 'repo'.", StatusCode: http.StatusBadRequest}) + return + } + + number, err := strconv.Atoi(numberStr) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{Message: "Invalid param 'number'.", StatusCode: http.StatusBadRequest}) + return + } + + graphQLClient := p.graphQLConnect(c.GHInfo) + result, err := graphQLClient.GetReviewThreads(c.Ctx, owner, repo, number) + if err != nil { + c.Log.WithError(err).Errorf("Failed to get review threads") + p.writeAPIError(w, &APIErrorResponse{Message: "failed to get review threads", StatusCode: http.StatusInternalServerError}) + return + } + + config := p.getConfiguration() + baseURL := config.getBaseURL() + prURL := fmt.Sprintf("%s%s/%s/pull/%d", baseURL, owner, repo, number) + + approved := 0 + changesRequested := 0 + for _, rs := range result.ReviewSummaries { + switch rs.State { + case "APPROVED": + approved++ + case "CHANGES_REQUESTED": + changesRequested++ + } + } + + type ReviewCommentResponse struct { + ID string `json:"id"` + DatabaseID int `json:"database_id"` + Body string `json:"body"` + AuthorLogin string `json:"author_login"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + URL string `json:"url"` + DiffHunk string `json:"diff_hunk"` + Path string `json:"path"` + Line int `json:"line"` + StartLine int `json:"start_line"` + ReactionGroups []graphql.ReactionInfo `json:"reaction_groups,omitempty"` + } + + type ReviewThreadResponse struct { + ID string `json:"id"` + IsResolved bool `json:"is_resolved"` + ResolvedBy string `json:"resolved_by,omitempty"` + Path string `json:"path"` + Line int `json:"line"` + StartLine int `json:"start_line"` + DiffHunk string `json:"diff_hunk"` + Comments []ReviewCommentResponse `json:"comments"` + } + + type SummaryResponse struct { + Approved int `json:"approved"` + ChangesRequested int `json:"changes_requested"` + UnresolvedThreads int `json:"unresolved_threads"` + TotalThreads int `json:"total_threads"` + } + + type PRReviewThreadsResponse struct { + PRTitle string `json:"pr_title"` + PRNumber int `json:"pr_number"` + PRURL string `json:"pr_url"` + Summary SummaryResponse `json:"summary"` + Threads []ReviewThreadResponse `json:"threads"` + } + + threads := make([]ReviewThreadResponse, 0, len(result.Threads)) + for _, t := range result.Threads { + threadPath := "" + threadLine := 0 + threadStartLine := 0 + threadDiffHunk := "" + + comments := make([]ReviewCommentResponse, 0, len(t.Comments)) + for i, cm := range t.Comments { + if i == 0 { + threadPath = cm.Path + threadLine = cm.Line + threadStartLine = cm.StartLine + threadDiffHunk = cm.DiffHunk + } + comments = append(comments, ReviewCommentResponse{ + ID: cm.ID, + DatabaseID: cm.DatabaseID, + Body: cm.Body, + AuthorLogin: cm.AuthorLogin, + CreatedAt: cm.CreatedAt.Format(time.RFC3339), + UpdatedAt: cm.UpdatedAt.Format(time.RFC3339), + URL: cm.URL, + DiffHunk: cm.DiffHunk, + Path: cm.Path, + Line: cm.Line, + StartLine: cm.StartLine, + ReactionGroups: cm.ReactionGroups, + }) + } + + threads = append(threads, ReviewThreadResponse{ + ID: t.ID, + IsResolved: t.IsResolved, + ResolvedBy: t.ResolvedByLogin, + Path: threadPath, + Line: threadLine, + StartLine: threadStartLine, + DiffHunk: threadDiffHunk, + Comments: comments, + }) + } + + resp := PRReviewThreadsResponse{ + PRTitle: fmt.Sprintf("%s/%s#%d", owner, repo, number), + PRNumber: number, + PRURL: prURL, + Summary: SummaryResponse{ + Approved: approved, + ChangesRequested: changesRequested, + UnresolvedThreads: result.UnresolvedCount, + TotalThreads: result.TotalThreadCount, + }, + Threads: threads, + } + + p.writeJSON(w, resp) +} + +func (p *Plugin) replyToReviewComment(c *UserContext, w http.ResponseWriter, r *http.Request) { + type ReplyToReviewCommentRequest struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + CommentID int64 `json:"comment_id"` + Body string `json:"body"` + } + + req := &ReplyToReviewCommentRequest{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.Log.WithError(err).Warnf("Error decoding ReplyToReviewCommentRequest JSON body") + p.writeAPIError(w, &APIErrorResponse{Message: "Please provide a JSON object.", StatusCode: http.StatusBadRequest}) + return + } + + if req.Owner == "" || req.Repo == "" || req.Number == 0 || req.CommentID == 0 || req.Body == "" { + p.writeAPIError(w, &APIErrorResponse{Message: "Please provide valid owner, repo, number, comment_id, and body.", StatusCode: http.StatusBadRequest}) + return + } + + githubClient := p.githubConnectUser(c.Ctx, c.GHInfo) + + var result *github.PullRequestComment + var err error + if cErr := p.useGitHubClient(c.GHInfo, func(info *GitHubUserInfo, token *oauth2.Token) error { + result, _, err = githubClient.PullRequests.CreateCommentInReplyTo(c.Ctx, req.Owner, req.Repo, req.Number, req.Body, req.CommentID) + if err != nil { + return err + } + return nil + }); cErr != nil { + c.Log.WithError(cErr).Errorf("Failed to reply to review comment") + p.writeAPIError(w, &APIErrorResponse{Message: "failed to reply to review comment", StatusCode: http.StatusInternalServerError}) + return + } + + p.writeJSON(w, result) +} + +func (p *Plugin) toggleReviewCommentReaction(c *UserContext, w http.ResponseWriter, r *http.Request) { + type ToggleReactionRequest struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + CommentID int64 `json:"comment_id"` + Reaction string `json:"reaction"` + } + + req := &ToggleReactionRequest{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.Log.WithError(err).Warnf("Error decoding ToggleReactionRequest JSON body") + p.writeAPIError(w, &APIErrorResponse{Message: "Please provide a JSON object.", StatusCode: http.StatusBadRequest}) + return + } + + if req.Owner == "" || req.Repo == "" || req.CommentID == 0 || req.Reaction == "" { + p.writeAPIError(w, &APIErrorResponse{Message: "Please provide valid owner, repo, comment_id, and reaction.", StatusCode: http.StatusBadRequest}) + return + } + + githubClient := p.githubConnectUser(c.Ctx, c.GHInfo) + currentUserLogin := c.GHInfo.GitHubUsername + + // List existing reactions to check if current user already reacted + var allReactions []*github.Reaction + opt := &github.ListOptions{PerPage: 100} + for { + var reactions []*github.Reaction + var resp *github.Response + var err error + if cErr := p.useGitHubClient(c.GHInfo, func(info *GitHubUserInfo, token *oauth2.Token) error { + reactions, resp, err = githubClient.Reactions.ListPullRequestCommentReactions(c.Ctx, req.Owner, req.Repo, req.CommentID, opt) + if err != nil { + return err + } + return nil + }); cErr != nil { + c.Log.WithError(cErr).Errorf("Failed to list reactions") + p.writeAPIError(w, &APIErrorResponse{Message: "failed to list reactions", StatusCode: http.StatusInternalServerError}) + return + } + allReactions = append(allReactions, reactions...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + // Check if current user already reacted with this content + var existingReactionID int64 + for _, reaction := range allReactions { + if reaction.GetUser().GetLogin() == currentUserLogin && reaction.GetContent() == req.Reaction { + existingReactionID = reaction.GetID() + break + } + } + + type ToggleReactionResponse struct { + ID int64 `json:"id"` + Content string `json:"content"` + Toggled bool `json:"toggled"` + } + + if existingReactionID != 0 { + // Remove the reaction (toggle off) + var err error + if cErr := p.useGitHubClient(c.GHInfo, func(info *GitHubUserInfo, token *oauth2.Token) error { + _, err = githubClient.Reactions.DeletePullRequestCommentReaction(c.Ctx, req.Owner, req.Repo, req.CommentID, existingReactionID) + if err != nil { + return err + } + return nil + }); cErr != nil { + c.Log.WithError(cErr).Errorf("Failed to delete reaction") + p.writeAPIError(w, &APIErrorResponse{Message: "failed to delete reaction", StatusCode: http.StatusInternalServerError}) + return + } + + p.writeJSON(w, ToggleReactionResponse{ + ID: existingReactionID, + Content: req.Reaction, + Toggled: false, + }) + return + } + + // Add the reaction (toggle on) + var newReaction *github.Reaction + var err error + if cErr := p.useGitHubClient(c.GHInfo, func(info *GitHubUserInfo, token *oauth2.Token) error { + newReaction, _, err = githubClient.Reactions.CreatePullRequestCommentReaction(c.Ctx, req.Owner, req.Repo, req.CommentID, req.Reaction) + if err != nil { + return err + } + return nil + }); cErr != nil { + c.Log.WithError(cErr).Errorf("Failed to create reaction") + p.writeAPIError(w, &APIErrorResponse{Message: "failed to create reaction", StatusCode: http.StatusInternalServerError}) + return + } + + p.writeJSON(w, ToggleReactionResponse{ + ID: newReaction.GetID(), + Content: newReaction.GetContent(), + Toggled: true, + }) +} + +func (p *Plugin) resolveReviewThread(c *UserContext, w http.ResponseWriter, r *http.Request) { + type ResolveThreadRequest struct { + ThreadID string `json:"thread_id"` + Action string `json:"action"` + } + + req := &ResolveThreadRequest{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.Log.WithError(err).Warnf("Error decoding ResolveThreadRequest JSON body") + p.writeAPIError(w, &APIErrorResponse{Message: "Please provide a JSON object.", StatusCode: http.StatusBadRequest}) + return + } + + if req.ThreadID == "" { + p.writeAPIError(w, &APIErrorResponse{Message: "Please provide a valid thread_id.", StatusCode: http.StatusBadRequest}) + return + } + + if req.Action != "resolve" && req.Action != "unresolve" { + p.writeAPIError(w, &APIErrorResponse{Message: "Action must be 'resolve' or 'unresolve'.", StatusCode: http.StatusBadRequest}) + return + } + + graphQLClient := p.graphQLConnect(c.GHInfo) + isResolved, err := graphQLClient.ResolveReviewThread(c.Ctx, req.ThreadID, req.Action == "resolve") + if err != nil { + c.Log.WithError(err).Errorf("Failed to resolve/unresolve review thread") + p.writeAPIError(w, &APIErrorResponse{Message: "failed to resolve/unresolve review thread", StatusCode: http.StatusInternalServerError}) + return + } + + type ResolveThreadResponse struct { + Status string `json:"status"` + IsResolved bool `json:"is_resolved"` + } + + p.writeJSON(w, ResolveThreadResponse{ + Status: "ok", + IsResolved: isResolved, + }) +} + +func (p *Plugin) createPRComment(c *UserContext, w http.ResponseWriter, r *http.Request) { + type CreatePRCommentRequest struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Body string `json:"body"` + } + + req := &CreatePRCommentRequest{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.Log.WithError(err).Warnf("Error decoding CreatePRCommentRequest JSON body") + p.writeAPIError(w, &APIErrorResponse{Message: "Please provide a JSON object.", StatusCode: http.StatusBadRequest}) + return + } + + if req.Owner == "" || req.Repo == "" || req.Number == 0 || req.Body == "" { + p.writeAPIError(w, &APIErrorResponse{Message: "Please provide valid owner, repo, number, and body.", StatusCode: http.StatusBadRequest}) + return + } + + githubClient := p.githubConnectUser(c.Ctx, c.GHInfo) + comment := &github.IssueComment{ + Body: &req.Body, + } + + var result *github.IssueComment + var err error + if cErr := p.useGitHubClient(c.GHInfo, func(info *GitHubUserInfo, token *oauth2.Token) error { + result, _, err = githubClient.Issues.CreateComment(c.Ctx, req.Owner, req.Repo, req.Number, comment) + if err != nil { + return err + } + return nil + }); cErr != nil { + c.Log.WithError(cErr).Errorf("Failed to create PR comment") + p.writeAPIError(w, &APIErrorResponse{Message: "failed to create PR comment", StatusCode: http.StatusInternalServerError}) + return + } + + p.writeJSON(w, result) +} + +func (p *Plugin) getAIAgents(c *UserContext, w http.ResponseWriter, r *http.Request) { + agents := p.getConfiguration().ParseAIAgents() + + type AIAgentResponse struct { + Name string `json:"name"` + Mention string `json:"mention"` + IsDefault bool `json:"is_default"` + } + + type AIAgentsResponse struct { + Agents []AIAgentResponse `json:"agents"` + } + + agentList := make([]AIAgentResponse, 0, len(agents)) + for _, a := range agents { + agentList = append(agentList, AIAgentResponse(a)) + } + + p.writeJSON(w, AIAgentsResponse{ + Agents: agentList, + }) +} diff --git a/server/plugin/configuration.go b/server/plugin/configuration.go index 9886560d1..c8ef553b5 100644 --- a/server/plugin/configuration.go +++ b/server/plugin/configuration.go @@ -26,6 +26,13 @@ import ( // // If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep // copy appropriate for your types. +// AIAgent represents a configured AI agent with its name and mention handle. +type AIAgent struct { + Name string + Mention string + IsDefault bool +} + type Configuration struct { GitHubOrg string `json:"githuborg"` GitHubOAuthClientID string `json:"githuboauthclientid"` @@ -42,6 +49,7 @@ type Configuration struct { UsePreregisteredApplication bool `json:"usepreregisteredapplication"` ShowAuthorInCommitNotification bool `json:"showauthorincommitnotification"` GetNotificationForDraftPRs bool `json:"getnotificationfordraftprs"` + AIAgents string `json:"aiagents"` } func (c *Configuration) ToMap() (map[string]any, error) { @@ -266,3 +274,46 @@ func (c *Configuration) getOrganizations() []string { return allOrgs } + +// ParseAIAgents parses the AIAgents configuration string and returns a list of +// AI agents including hardcoded defaults (Claude and Cursor). The AIAgents field +// is expected to be a comma-separated list of Name:@mention pairs. +func (c *Configuration) ParseAIAgents() []AIAgent { + defaults := []AIAgent{ + {Name: "Claude", Mention: "@claude", IsDefault: true}, + {Name: "Cursor", Mention: "@cursor", IsDefault: true}, + } + + if strings.TrimSpace(c.AIAgents) == "" { + return defaults + } + + agents := make([]AIAgent, 0, len(defaults)) + agents = append(agents, defaults...) + + for pair := range strings.SplitSeq(c.AIAgents, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + + parts := strings.SplitN(pair, ":", 2) + if len(parts) != 2 { + continue + } + + name := strings.TrimSpace(parts[0]) + mention := strings.TrimSpace(parts[1]) + if name == "" || mention == "" { + continue + } + + agents = append(agents, AIAgent{ + Name: name, + Mention: mention, + IsDefault: false, + }) + } + + return agents +} diff --git a/server/plugin/graphql/client.go b/server/plugin/graphql/client.go index a606dcb8b..7f2d31446 100644 --- a/server/plugin/graphql/client.go +++ b/server/plugin/graphql/client.go @@ -64,3 +64,12 @@ func (c *Client) executeQuery(ctx context.Context, qry any, params map[string]an return nil } + +// executeMutation takes a mutation struct and sends it to Github GraphQL API via helper package. +func (c *Client) executeMutation(ctx context.Context, m any, input githubv4.Input, params map[string]any) error { + if err := c.client.Mutate(ctx, m, input, params); err != nil { + return errors.Wrap(err, "error in executing mutation") + } + + return nil +} diff --git a/server/plugin/graphql/review_threads.go b/server/plugin/graphql/review_threads.go new file mode 100644 index 000000000..da2c5c2f2 --- /dev/null +++ b/server/plugin/graphql/review_threads.go @@ -0,0 +1,180 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package graphql + +import ( + "context" + "math" + "time" + + "github.com/pkg/errors" + "github.com/shurcooL/githubv4" +) + +// ReactionInfo holds reaction data for a review comment. +type ReactionInfo struct { + Content string `json:"content"` + Count int `json:"count"` +} + +// ReviewComment represents a single comment within a review thread. +type ReviewComment struct { + ID string `json:"id"` + DatabaseID int `json:"database_id"` + Body string `json:"body"` + AuthorLogin string `json:"author_login"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + URL string `json:"url"` + DiffHunk string `json:"diff_hunk"` + Path string `json:"path"` + Line int `json:"line"` + StartLine int `json:"start_line"` + ReactionGroups []ReactionInfo `json:"reaction_groups,omitempty"` +} + +// ReviewThread represents a review thread on a pull request. +type ReviewThread struct { + ID string `json:"id"` + IsResolved bool `json:"is_resolved"` + ResolvedByLogin string `json:"resolved_by_login,omitempty"` + Comments []ReviewComment `json:"comments"` +} + +// PRReviewSummary represents a review summary (approval, changes requested, etc.) +type PRReviewSummary struct { + State string `json:"state"` + AuthorLogin string `json:"author_login"` +} + +// ReviewThreadsResult holds the complete result from fetching review threads. +type ReviewThreadsResult struct { + Threads []ReviewThread `json:"threads"` + ReviewSummaries []PRReviewSummary `json:"review_summaries"` + UnresolvedCount int `json:"unresolved_count"` + TotalThreadCount int `json:"total_thread_count"` +} + +// GetReviewThreads fetches all review threads and review summaries for a pull request. +func (c *Client) GetReviewThreads(ctx context.Context, owner, name string, prNumber int) (*ReviewThreadsResult, error) { + var allThreads []ReviewThread + var reviewSummaries []PRReviewSummary + + if prNumber < 0 || prNumber > math.MaxInt32 { + return nil, errors.Errorf("PR number %d overflows int32", prNumber) + } + + params := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(name), + "prNumber": githubv4.Int(int32(prNumber)), //nolint:gosec // overflow checked above + "threadsCursor": (*githubv4.String)(nil), + } + + reviewSummariesFetched := false + + for { + if err := c.executeQuery(ctx, &reviewThreadsQuery, params); err != nil { + return nil, errors.Wrap(err, "failed to fetch review threads") + } + + pr := reviewThreadsQuery.Repository.PullRequest + + // Only collect review summaries on the first page (they are not paginated by threadsCursor). + if !reviewSummariesFetched { + for i := range pr.Reviews.Nodes { + node := pr.Reviews.Nodes[i] + reviewSummaries = append(reviewSummaries, PRReviewSummary{ + State: string(node.State), + AuthorLogin: string(node.Author.Login), + }) + } + reviewSummariesFetched = true + } + + for i := range pr.ReviewThreads.Nodes { + threadNode := pr.ReviewThreads.Nodes[i] + thread := ReviewThread{ + ID: string(threadNode.ID), + IsResolved: bool(threadNode.IsResolved), + ResolvedByLogin: string(threadNode.ResolvedBy.Login), + } + + for j := range threadNode.Comments.Nodes { + commentNode := threadNode.Comments.Nodes[j] + comment := ReviewComment{ + ID: string(commentNode.ID), + DatabaseID: int(commentNode.DatabaseID), + Body: string(commentNode.Body), + AuthorLogin: string(commentNode.Author.Login), + CreatedAt: commentNode.CreatedAt.Time, + UpdatedAt: commentNode.UpdatedAt.Time, + URL: commentNode.URL.String(), + DiffHunk: string(commentNode.DiffHunk), + Path: string(commentNode.Path), + Line: int(commentNode.Line), + StartLine: int(commentNode.StartLine), + } + + for k := range commentNode.ReactionGroups { + rg := commentNode.ReactionGroups[k] + comment.ReactionGroups = append(comment.ReactionGroups, ReactionInfo{ + Content: string(rg.Content), + Count: int(rg.Users.TotalCount), + }) + } + + thread.Comments = append(thread.Comments, comment) + } + + allThreads = append(allThreads, thread) + } + + if !pr.ReviewThreads.PageInfo.HasNextPage { + break + } + + params["threadsCursor"] = githubv4.NewString(pr.ReviewThreads.PageInfo.EndCursor) + } + + unresolvedCount := 0 + for i := range allThreads { + if !allThreads[i].IsResolved { + unresolvedCount++ + } + } + + return &ReviewThreadsResult{ + Threads: allThreads, + ReviewSummaries: reviewSummaries, + UnresolvedCount: unresolvedCount, + TotalThreadCount: len(allThreads), + }, nil +} + +// ResolveReviewThread resolves or unresolves a review thread by its GraphQL node ID. +// It returns the new isResolved state. +func (c *Client) ResolveReviewThread(ctx context.Context, threadID string, resolve bool) (bool, error) { + if resolve { + input := githubv4.ResolveReviewThreadInput{ + ThreadID: githubv4.ID(threadID), + } + + if err := c.executeMutation(ctx, &resolveThreadMutation, input, nil); err != nil { + return false, errors.Wrap(err, "failed to resolve review thread") + } + + return bool(resolveThreadMutation.ResolveReviewThread.Thread.IsResolved), nil + } + + input := githubv4.UnresolveReviewThreadInput{ + ThreadID: githubv4.ID(threadID), + } + + if err := c.executeMutation(ctx, &unresolveThreadMutation, input, nil); err != nil { + return false, errors.Wrap(err, "failed to unresolve review thread") + } + + return bool(unresolveThreadMutation.UnresolveReviewThread.Thread.IsResolved), nil +} diff --git a/server/plugin/graphql/review_threads_query.go b/server/plugin/graphql/review_threads_query.go new file mode 100644 index 000000000..1e3b56d4c --- /dev/null +++ b/server/plugin/graphql/review_threads_query.go @@ -0,0 +1,85 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package graphql + +import ( + "github.com/shurcooL/githubv4" +) + +type ( + reactionGroupNode struct { + Content githubv4.String + Users struct { + TotalCount githubv4.Int + } + } + + reviewThreadCommentNode struct { + ID githubv4.String + DatabaseID githubv4.Int + Body githubv4.String + Author authorQuery + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + URL githubv4.URI + DiffHunk githubv4.String + Path githubv4.String + Line githubv4.Int + StartLine githubv4.Int + ReactionGroups []reactionGroupNode + } + + reviewThreadNode struct { + ID githubv4.String + IsResolved githubv4.Boolean + ResolvedBy authorQuery + Comments struct { + Nodes []reviewThreadCommentNode + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + } `graphql:"comments(first:100)"` + } + + reviewSummaryNode struct { + State githubv4.String + Author authorQuery + } +) + +var reviewThreadsQuery struct { + Repository struct { + PullRequest struct { + Reviews struct { + Nodes []reviewSummaryNode + } `graphql:"reviews(first:100)"` + ReviewThreads struct { + Nodes []reviewThreadNode + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + } `graphql:"reviewThreads(first:50, after:$threadsCursor)"` + } `graphql:"pullRequest(number:$prNumber)"` + } `graphql:"repository(owner:$owner, name:$name)"` +} + +var resolveThreadMutation struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.String + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input:$input)"` +} + +var unresolveThreadMutation struct { + UnresolveReviewThread struct { + Thread struct { + ID githubv4.String + IsResolved githubv4.Boolean + } + } `graphql:"unresolveReviewThread(input:$input)"` +} diff --git a/webapp/src/action_types/index.ts b/webapp/src/action_types/index.ts index 4b980394f..a4fa29fdf 100644 --- a/webapp/src/action_types/index.ts +++ b/webapp/src/action_types/index.ts @@ -24,4 +24,10 @@ export default { 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', + RECEIVED_PR_REVIEW_THREADS: pluginId + '_received_pr_review_threads', + SET_SELECTED_PR: pluginId + '_set_selected_pr', + CLEAR_SELECTED_PR: pluginId + '_clear_selected_pr', + PR_REVIEW_THREADS_LOADING: pluginId + '_pr_review_threads_loading', + PR_REVIEW_THREADS_ERROR: pluginId + '_pr_review_threads_error', + RECEIVED_AI_AGENTS: pluginId + '_received_ai_agents', }; diff --git a/webapp/src/actions/index.ts b/webapp/src/actions/index.ts index 420b0ab2d..de25c5967 100644 --- a/webapp/src/actions/index.ts +++ b/webapp/src/actions/index.ts @@ -8,7 +8,7 @@ import {ClientError} from '@mattermost/client'; import {ApiError} from '../client/client'; import Client from '../client'; -import {APIError, PrsDetailsData, ShowRhsPluginActionData} from '../types/github_types'; +import {APIError, PrsDetailsData, ShowRhsPluginActionData, SelectedPRData} from '../types/github_types'; import {getPluginState} from '../selectors'; @@ -408,3 +408,152 @@ export function attachCommentToIssue(payload: AttachCommentToIssuePayload) { } }; } + +export function selectPR(prData: SelectedPRData) { + return { + type: ActionTypes.SET_SELECTED_PR, + data: prData, + }; +} + +export function clearSelectedPR() { + return { + type: ActionTypes.CLEAR_SELECTED_PR, + }; +} + +export function getPRReviewThreads(owner: string, repo: string, number: number) { + return async (dispatch: DispatchFunc) => { + try { + dispatch({ + type: ActionTypes.PR_REVIEW_THREADS_LOADING, + data: true, + }); + + const data = await Client.getPRReviewThreads(owner, repo, number); + + const connected = await checkAndHandleNotConnected(data)(dispatch); + if (!connected) { + dispatch({ + type: ActionTypes.PR_REVIEW_THREADS_LOADING, + data: false, + }); + return {error: data}; + } + + dispatch({ + type: ActionTypes.RECEIVED_PR_REVIEW_THREADS, + data, + }); + + dispatch({ + type: ActionTypes.PR_REVIEW_THREADS_LOADING, + data: false, + }); + + return {data}; + } catch (e) { + dispatch({ + type: ActionTypes.PR_REVIEW_THREADS_ERROR, + data: (e as ClientError).message, + }); + + dispatch({ + type: ActionTypes.PR_REVIEW_THREADS_LOADING, + data: false, + }); + + return {error: e as ClientError}; + } + }; +} + +export function replyToReviewComment(owner: string, repo: string, number: number, commentId: number, body: string) { + return async (dispatch: DispatchFunc) => { + try { + const data = await Client.replyToReviewComment(owner, repo, number, commentId, body); + + const connected = await checkAndHandleNotConnected(data)(dispatch); + if (!connected) { + return {error: data}; + } + + return {data}; + } catch (e) { + return {error: e as ClientError}; + } + }; +} + +export function toggleReaction(owner: string, repo: string, commentId: number, reaction: string) { + return async (dispatch: DispatchFunc) => { + try { + const data = await Client.toggleReaction(owner, repo, commentId, reaction); + + const connected = await checkAndHandleNotConnected(data)(dispatch); + if (!connected) { + return {error: data}; + } + + return {data}; + } catch (e) { + return {error: e as ClientError}; + } + }; +} + +export function resolveThread(threadId: string, action: string) { + return async (dispatch: DispatchFunc) => { + try { + const data = await Client.resolveThread(threadId, action); + + const connected = await checkAndHandleNotConnected(data)(dispatch); + if (!connected) { + return {error: data}; + } + + return {data}; + } catch (e) { + return {error: e as ClientError}; + } + }; +} + +export function postAIAssignment(owner: string, repo: string, number: number, body: string) { + return async (dispatch: DispatchFunc) => { + try { + const data = await Client.createPRComment(owner, repo, number, body); + + const connected = await checkAndHandleNotConnected(data)(dispatch); + if (!connected) { + return {error: data}; + } + + return {data}; + } catch (e) { + return {error: e as ClientError}; + } + }; +} + +export function getAIAgents() { + return async (dispatch: DispatchFunc) => { + try { + const data = await Client.getAIAgents(); + + const connected = await checkAndHandleNotConnected(data)(dispatch); + if (!connected) { + return {error: data}; + } + + dispatch({ + type: ActionTypes.RECEIVED_AI_AGENTS, + data, + }); + + return {data}; + } catch (e) { + return {error: e as ClientError}; + } + }; +} diff --git a/webapp/src/client/client.ts b/webapp/src/client/client.ts index 399d17a4c..c3b9cfa8b 100644 --- a/webapp/src/client/client.ts +++ b/webapp/src/client/client.ts @@ -4,7 +4,7 @@ import {Client4} from 'mattermost-redux/client'; import {ClientError} from '@mattermost/client'; -import {ConnectedData, GithubIssueData, GithubLabel, GithubUsersData, GitHubIssueCommentData, MentionsData, MilestoneData, PrsDetailsData, SidebarContentData, YourReposData, GitHubPullRequestData, ChannelRepositoriesData, RepositoryData, Organization} from '../types/github_types'; +import {ConnectedData, GithubIssueData, GithubLabel, GithubUsersData, GitHubIssueCommentData, MentionsData, MilestoneData, PrsDetailsData, SidebarContentData, YourReposData, GitHubPullRequestData, ChannelRepositoriesData, RepositoryData, Organization, PRReviewThreadsData, ResolveThreadResponse, ReactionToggleResponse, AIAgentsData} from '../types/github_types'; import manifest from '../manifest'; @@ -89,6 +89,30 @@ export default class Client { return this.doGet(`${this.url}/pr?owner=${owner}&repo=${repo}&number=${prNumber}`); }; + getPRReviewThreads = async (owner: string, repo: string, number: number) => { + return this.doGet(`${this.url}/pr-review-threads?owner=${owner}&repo=${repo}&number=${number}`); + }; + + replyToReviewComment = async (owner: string, repo: string, number: number, commentId: number, body: string) => { + return this.doPost<{}>(`${this.url}/pr-review-comment-reply`, {owner, repo, number, comment_id: commentId, body}); + }; + + toggleReaction = async (owner: string, repo: string, commentId: number, reaction: string) => { + return this.doPost(`${this.url}/pr-review-comment-reaction`, {owner, repo, comment_id: commentId, reaction}); + }; + + resolveThread = async (threadId: string, action: string) => { + return this.doPost(`${this.url}/pr-resolve-thread`, {thread_id: threadId, action}); + }; + + createPRComment = async (owner: string, repo: string, number: number, body: string) => { + return this.doPost<{}>(`${this.url}/pr-create-comment`, {owner, repo, number, body}); + }; + + getAIAgents = async () => { + return this.doGet(`${this.url}/plugin-settings/ai-agents`); + }; + private doGet = async (url: string): Promise => { const headers = { 'X-Timezone-Offset': new Date().getTimezoneOffset().toString(), diff --git a/webapp/src/components/sidebar_right/github_items.tsx b/webapp/src/components/sidebar_right/github_items.tsx index e98d2bb88..6ac224915 100644 --- a/webapp/src/components/sidebar_right/github_items.tsx +++ b/webapp/src/components/sidebar_right/github_items.tsx @@ -9,7 +9,7 @@ import {Badge, Tooltip, OverlayTrigger} from 'react-bootstrap'; import {makeStyleFromTheme, changeOpacity} from 'mattermost-redux/utils/theme_utils'; import {GitPullRequestIcon, IssueOpenedIcon, IconProps, CalendarIcon, PersonIcon, FileDiffIcon} from '@primer/octicons-react'; -import {GithubItemsProps, GithubLabel, GithubItem, Review} from '../../types/github_types'; +import {GithubItemsProps, GithubLabel, GithubItem, Review, SelectedPRData} from '../../types/github_types'; import {formatTimeSince} from '../../utils/date_utils'; @@ -46,6 +46,24 @@ function GithubItems(props: GithubItemsProps) { repoName = item.repository?.full_name; } + // Determine if this is a PR item for drill-down + const isPR = item.html_url && item.html_url.includes('/pull/'); + const handlePRClick = () => { + if (isPR && props.onSelectPR && repoName) { + const parts = repoName.split('/'); + if (parts.length === 2) { + const titleText = item.title || item.subject?.title || ''; + props.onSelectPR({ + owner: parts[0], + repo: parts[1], + number: item.number, + title: titleText, + url: item.html_url, + }); + } + } + }; + let userName = ''; if (item.user) { userName = item.user.login; @@ -264,7 +282,8 @@ function GithubItems(props: GithubItemsProps) { return (
diff --git a/webapp/src/components/sidebar_right/index.jsx b/webapp/src/components/sidebar_right/index.jsx index 25717d359..d73bd633b 100644 --- a/webapp/src/components/sidebar_right/index.jsx +++ b/webapp/src/components/sidebar_right/index.jsx @@ -4,9 +4,9 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {getReviewsDetails, getYourPrsDetails} from '../../actions'; +import {getReviewsDetails, getYourPrsDetails, selectPR, clearSelectedPR, getPRReviewThreads, getAIAgents} from '../../actions'; -import {getSidebarData} from 'src/selectors'; +import {getSidebarData, getSelectedPR} from 'src/selectors'; import SidebarRight from './sidebar_right.jsx'; @@ -21,6 +21,7 @@ function mapStateToProps(state) { enterpriseURL, orgs, rhsState, + selectedPR: getSelectedPR(state), }; } @@ -29,6 +30,10 @@ function mapDispatchToProps(dispatch) { actions: bindActionCreators({ getYourPrsDetails, getReviewsDetails, + selectPR, + clearSelectedPR, + getPRReviewThreads, + getAIAgents, }, dispatch), }; } diff --git a/webapp/src/components/sidebar_right/pr_review_detail/ai_assign_bar.tsx b/webapp/src/components/sidebar_right/pr_review_detail/ai_assign_bar.tsx new file mode 100644 index 000000000..ecbcbc110 --- /dev/null +++ b/webapp/src/components/sidebar_right/pr_review_detail/ai_assign_bar.tsx @@ -0,0 +1,114 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useCallback} from 'react'; + +import {Theme} from 'mattermost-redux/selectors/entities/preferences'; +import {changeOpacity} from 'mattermost-redux/utils/theme_utils'; + +import {AIAgent} from '../../../types/github_types'; + +type Props = { + selectedCount: number; + agents: AIAgent[]; + onAssign: (agentMention: string) => void; + theme: Theme; +}; + +const AIAssignBar: React.FC = ({selectedCount, agents, onAssign, theme}) => { + const defaultMention = agents.length > 0 ? (agents.find((a) => a.is_default)?.mention || agents[0].mention) : ''; + const [selectedAgent, setSelectedAgent] = useState(defaultMention); + + const handleAssign = useCallback(() => { + if (selectedAgent) { + onAssign(selectedAgent); + } + }, [selectedAgent, onAssign]); + + if (selectedCount === 0 || agents.length === 0) { + return null; + } + + return ( +
+ + {selectedCount + (selectedCount === 1 ? ' comment selected' : ' comments selected')} + +
+ + +
+
+ ); +}; + +const styles: Record = { + container: { + position: 'sticky', + bottom: 0, + padding: '10px 12px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + zIndex: 10, + }, + countText: { + fontSize: '13px', + fontWeight: 600, + }, + actions: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + select: { + padding: '4px 8px', + borderRadius: '4px', + fontSize: '12px', + outline: 'none', + }, + assignButton: { + padding: '5px 14px', + borderRadius: '4px', + border: 'none', + fontSize: '12px', + fontWeight: 600, + cursor: 'pointer', + }, +}; + +export default AIAssignBar; diff --git a/webapp/src/components/sidebar_right/pr_review_detail/diff_hunk_display.tsx b/webapp/src/components/sidebar_right/pr_review_detail/diff_hunk_display.tsx new file mode 100644 index 000000000..799586910 --- /dev/null +++ b/webapp/src/components/sidebar_right/pr_review_detail/diff_hunk_display.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; + +type Props = { + diffHunk: string; +}; + +const MAX_VISIBLE_LINES = 8; + +const DiffHunkDisplay: React.FC = ({diffHunk}) => { + const [expanded, setExpanded] = useState(false); + + if (!diffHunk) { + return null; + } + + const lines = diffHunk.split('\n'); + const isLong = lines.length > MAX_VISIBLE_LINES; + const visibleLines = expanded ? lines : lines.slice(0, MAX_VISIBLE_LINES); + + const getLineStyle = (line: string): React.CSSProperties => { + if (line.startsWith('@@')) { + return {backgroundColor: 'rgba(0, 90, 160, 0.15)', color: '#555'}; + } + if (line.startsWith('+')) { + return {backgroundColor: 'rgba(40, 167, 69, 0.15)'}; + } + if (line.startsWith('-')) { + return {backgroundColor: 'rgba(220, 53, 69, 0.15)'}; + } + return {}; + }; + + return ( +
+
+                {visibleLines.map((line, idx) => (
+                    
+ {line} +
+ ))} +
+ {isLong && !expanded && ( + + )} + {isLong && expanded && ( + + )} +
+ ); +}; + +const styles: Record = { + container: { + borderRadius: '4px', + overflow: 'hidden', + marginBottom: '8px', + border: '1px solid rgba(0, 0, 0, 0.1)', + }, + pre: { + margin: 0, + padding: '4px 0', + fontFamily: 'SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace', + fontSize: '11px', + lineHeight: '1.4', + overflowX: 'auto', + }, + line: { + padding: '0 8px', + whiteSpace: 'pre', + }, + expandButton: { + display: 'block', + width: '100%', + padding: '4px', + border: 'none', + background: 'rgba(0, 0, 0, 0.03)', + cursor: 'pointer', + fontSize: '11px', + color: '#555', + textAlign: 'center', + }, +}; + +export default DiffHunkDisplay; diff --git a/webapp/src/components/sidebar_right/pr_review_detail/file_group.tsx b/webapp/src/components/sidebar_right/pr_review_detail/file_group.tsx new file mode 100644 index 000000000..209d52da0 --- /dev/null +++ b/webapp/src/components/sidebar_right/pr_review_detail/file_group.tsx @@ -0,0 +1,119 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; + +import {Theme} from 'mattermost-redux/selectors/entities/preferences'; +import {changeOpacity} from 'mattermost-redux/utils/theme_utils'; + +import {ReviewThreadData} from '../../../types/github_types'; + +import ReviewThread from './review_thread'; + +type Props = { + filePath: string; + threads: ReviewThreadData[]; + selectedCommentIds: Set; + onToggleComment: (commentId: string) => void; + replyToReviewComment: (owner: string, repo: string, number: number, commentId: number, body: string) => Promise; + toggleReaction: (owner: string, repo: string, commentId: number, reaction: string) => Promise; + resolveThread: (threadId: string, action: string) => Promise; + theme: Theme; + owner: string; + repo: string; + prNumber: number; +}; + +const FileGroup: React.FC = ({ + filePath, + threads, + selectedCommentIds, + onToggleComment, + replyToReviewComment, + toggleReaction, + resolveThread, + theme, + owner, + repo, + prNumber, +}) => { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+
setCollapsed(!collapsed)} + > + + {'\u25BE'} + + + {filePath} + + + {threads.length + (threads.length === 1 ? ' thread' : ' threads')} + +
+ {!collapsed && ( +
+ {threads.map((thread) => ( + + ))} +
+ )} +
+ ); +}; + +const styles: Record = { + container: { + marginBottom: '4px', + }, + header: { + padding: '8px 12px', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '6px', + userSelect: 'none', + }, + arrow: { + fontSize: '12px', + transition: 'transform 0.15s', + display: 'inline-block', + }, + filePath: { + fontSize: '12px', + fontWeight: 600, + fontFamily: 'SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + flex: 1, + }, + threadCount: { + fontSize: '11px', + flexShrink: 0, + }, + threadsList: { + padding: '4px 8px', + }, +}; + +export default FileGroup; diff --git a/webapp/src/components/sidebar_right/pr_review_detail/index.tsx b/webapp/src/components/sidebar_right/pr_review_detail/index.tsx new file mode 100644 index 000000000..06ecd4afa --- /dev/null +++ b/webapp/src/components/sidebar_right/pr_review_detail/index.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators, Dispatch} from 'redux'; + +import {getSelectedPR, getPRReviewThreads as getPRReviewThreadsSelector, getPRReviewThreadsLoading, getAIAgents as getAIAgentsSelector, getThreadsGroupedByFile} from '../../../selectors'; +import {clearSelectedPR, getPRReviewThreads, replyToReviewComment, toggleReaction, resolveThread, postAIAssignment, getAIAgents} from '../../../actions'; + +import {GlobalState} from '../../../types/store'; + +import PRReviewDetail from './pr_review_detail'; + +function mapStateToProps(state: GlobalState) { + return { + selectedPR: getSelectedPR(state), + threads: getPRReviewThreadsSelector(state), + threadsGroupedByFile: getThreadsGroupedByFile(state), + loading: getPRReviewThreadsLoading(state), + aiAgents: getAIAgentsSelector(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + clearSelectedPR, + getPRReviewThreads, + replyToReviewComment, + toggleReaction, + resolveThread, + postAIAssignment, + getAIAgents, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(PRReviewDetail); diff --git a/webapp/src/components/sidebar_right/pr_review_detail/pr_review_detail.tsx b/webapp/src/components/sidebar_right/pr_review_detail/pr_review_detail.tsx new file mode 100644 index 000000000..866709df2 --- /dev/null +++ b/webapp/src/components/sidebar_right/pr_review_detail/pr_review_detail.tsx @@ -0,0 +1,172 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState, useCallback} from 'react'; +import Scrollbars from 'react-custom-scrollbars-2'; + +import {Theme} from 'mattermost-redux/selectors/entities/preferences'; +import {changeOpacity} from 'mattermost-redux/utils/theme_utils'; + +import {SelectedPRData, PRReviewThreadsData, ReviewThreadData, AIAgent} from '../../../types/github_types'; + +import {renderView, renderThumbHorizontal, renderThumbVertical} from '../sidebar_right'; + +import PRReviewDetailHeader from './pr_review_detail_header'; +import FileGroup from './file_group'; +import AIAssignBar from './ai_assign_bar'; + +type Props = { + selectedPR: SelectedPRData | null; + threads: PRReviewThreadsData | null; + threadsGroupedByFile: Record; + loading: boolean; + aiAgents: AIAgent[]; + theme: Theme; + actions: { + clearSelectedPR: () => void; + getPRReviewThreads: (owner: string, repo: string, number: number) => Promise; + replyToReviewComment: (owner: string, repo: string, number: number, commentId: number, body: string) => Promise; + toggleReaction: (owner: string, repo: string, commentId: number, reaction: string) => Promise; + resolveThread: (threadId: string, action: string) => Promise; + postAIAssignment: (owner: string, repo: string, number: number, body: string) => Promise; + getAIAgents: () => Promise; + }; +}; + +const PRReviewDetail: React.FC = ({ + selectedPR, + threads, + threadsGroupedByFile, + loading, + aiAgents, + theme, + actions, +}) => { + const [selectedCommentIds, setSelectedCommentIds] = useState>(new Set()); + + useEffect(() => { + if (selectedPR) { + actions.getPRReviewThreads(selectedPR.owner, selectedPR.repo, selectedPR.number); + actions.getAIAgents(); + } + }, [selectedPR?.owner, selectedPR?.repo, selectedPR?.number]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleToggleComment = useCallback((commentId: string) => { + setSelectedCommentIds((prev) => { + const next = new Set(prev); + if (next.has(commentId)) { + next.delete(commentId); + } else { + next.add(commentId); + } + return next; + }); + }, []); + + const handleAIAssign = useCallback((agentMention: string) => { + if (selectedCommentIds.size === 0 || !selectedPR) { + return; + } + + // Build a comment body mentioning the agent and referencing selected comment IDs + const commentRefs = Array.from(selectedCommentIds).join(', '); + const body = `${agentMention} Please review the following comment threads: ${commentRefs}`; + + actions.postAIAssignment(selectedPR.owner, selectedPR.repo, selectedPR.number, body); + setSelectedCommentIds(new Set()); + }, [selectedCommentIds, actions, selectedPR]); + + if (!selectedPR) { + return null; + } + + const summary = threads?.summary || null; + const title = threads?.pr_title || selectedPR.title; + const prUrl = threads?.pr_url || selectedPR.url; + const filePaths = Object.keys(threadsGroupedByFile).sort(); + + return ( + + + + {loading && ( +
+
+ + {'Loading review threads...'} + +
+ )} + {!loading && filePaths.length === 0 && ( +
+ + {'No review threads found for this pull request.'} + +
+ )} + {!loading && filePaths.map((filePath) => ( + + ))} + + + + ); +}; + +const styles: Record = { + loadingContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '40px 15px', + gap: '12px', + }, + loadingSpinner: { + width: '24px', + height: '24px', + border: '3px solid rgba(0, 0, 0, 0.1)', + borderRadius: '50%', + animation: 'spin 1s linear infinite', + }, + emptyState: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '40px 15px', + }, +}; + +export default PRReviewDetail; diff --git a/webapp/src/components/sidebar_right/pr_review_detail/pr_review_detail_header.tsx b/webapp/src/components/sidebar_right/pr_review_detail/pr_review_detail_header.tsx new file mode 100644 index 000000000..32ec66bce --- /dev/null +++ b/webapp/src/components/sidebar_right/pr_review_detail/pr_review_detail_header.tsx @@ -0,0 +1,110 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Theme} from 'mattermost-redux/selectors/entities/preferences'; +import {makeStyleFromTheme, changeOpacity} from 'mattermost-redux/utils/theme_utils'; + +import {PRReviewSummary} from '../../../types/github_types'; + +type Props = { + title: string; + prNumber: number; + prUrl: string; + summary: PRReviewSummary | null; + onBack: () => void; + theme: Theme; +}; + +const PRReviewDetailHeader: React.FC = ({title, prNumber, prUrl, summary, onBack, theme}) => { + const style = getStyle(theme); + + return ( +
+ + + {summary && ( +
+ + {summary.approved + ' approved'} + + {'|'} + + {summary.changes_requested + ' changes requested'} + + {'|'} + + {summary.unresolved_threads + ' unresolved threads'} + +
+ )} +
+ ); +}; + +const getStyle = makeStyleFromTheme((theme) => { + return { + container: { + padding: '12px 15px', + borderBottom: `1px solid ${changeOpacity(theme.centerChannelColor, 0.2)}`, + }, + backButton: { + background: 'none', + border: 'none', + color: theme.linkColor, + cursor: 'pointer', + padding: '0 0 8px 0', + fontSize: '13px', + fontWeight: 600, + }, + titleRow: { + marginBottom: '6px', + }, + titleLink: { + color: theme.centerChannelColor, + fontSize: '14px', + fontWeight: 700, + lineHeight: '1.4', + textDecoration: 'none', + }, + summaryRow: { + display: 'flex', + alignItems: 'center', + gap: '6px', + fontSize: '12px', + flexWrap: 'wrap', + }, + approvedCount: { + color: theme.onlineIndicator, + fontWeight: 600, + }, + changesRequestedCount: { + color: theme.dndIndicator, + fontWeight: 600, + }, + unresolvedCount: { + color: changeOpacity(theme.centerChannelColor, 0.7), + fontWeight: 600, + }, + separator: { + color: changeOpacity(theme.centerChannelColor, 0.3), + }, + }; +}); + +export default PRReviewDetailHeader; diff --git a/webapp/src/components/sidebar_right/pr_review_detail/reaction_bar.tsx b/webapp/src/components/sidebar_right/pr_review_detail/reaction_bar.tsx new file mode 100644 index 000000000..652e14137 --- /dev/null +++ b/webapp/src/components/sidebar_right/pr_review_detail/reaction_bar.tsx @@ -0,0 +1,128 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useCallback} from 'react'; + +import {Theme} from 'mattermost-redux/selectors/entities/preferences'; +import {changeOpacity} from 'mattermost-redux/utils/theme_utils'; + +import {REACTIONS} from '../../../constants'; + +type ReactionData = { + content: string; + count: number; + reacted: boolean; +}; + +type Props = { + reactions: ReactionData[]; + onToggleReaction: (content: string) => Promise; + theme: Theme; +}; + +const EMOJI_MAP: Record = { + '+1': '\uD83D\uDC4D', + '-1': '\uD83D\uDC4E', + laugh: '\uD83D\uDE04', + confused: '\uD83D\uDE15', + heart: '\u2764\uFE0F', + hooray: '\uD83C\uDF89', +}; + +const ReactionBar: React.FC = ({reactions, onToggleReaction, theme}) => { + const buildInitialState = useCallback(() => { + const state: Record = {}; + for (const r of REACTIONS) { + const found = reactions.find((rx) => rx.content === r); + state[r] = { + count: found ? found.count : 0, + reacted: found ? found.reacted : false, + }; + } + return state; + }, [reactions]); + + const [localReactions, setLocalReactions] = useState(buildInitialState); + + // Sync with props when reactions change from outside + React.useEffect(() => { + setLocalReactions(buildInitialState()); + }, [reactions, buildInitialState]); + + const handleToggle = useCallback(async (content: string) => { + const current = localReactions[content]; + const newReacted = !current.reacted; + const newCount = newReacted ? current.count + 1 : Math.max(0, current.count - 1); + + // Optimistic update + setLocalReactions((prev) => ({ + ...prev, + [content]: {count: newCount, reacted: newReacted}, + })); + + try { + await onToggleReaction(content); + } catch { + // Revert on error + setLocalReactions((prev) => ({ + ...prev, + [content]: {count: current.count, reacted: current.reacted}, + })); + } + }, [localReactions, onToggleReaction]); + + return ( +
+ {REACTIONS.map((content) => { + const data = localReactions[content]; + const isActive = data?.reacted; + const count = data?.count || 0; + const emoji = EMOJI_MAP[content] || content; + + return ( + + ); + })} +
+ ); +}; + +const styles: Record = { + container: { + display: 'flex', + flexWrap: 'wrap', + gap: '4px', + marginTop: '4px', + }, + button: { + display: 'inline-flex', + alignItems: 'center', + gap: '2px', + padding: '2px 6px', + borderRadius: '10px', + cursor: 'pointer', + fontSize: '12px', + lineHeight: '1.4', + }, + emoji: { + fontSize: '13px', + }, + count: { + fontSize: '11px', + fontWeight: 500, + }, +}; + +export default ReactionBar; diff --git a/webapp/src/components/sidebar_right/pr_review_detail/reply_box.tsx b/webapp/src/components/sidebar_right/pr_review_detail/reply_box.tsx new file mode 100644 index 000000000..77cfbb7c5 --- /dev/null +++ b/webapp/src/components/sidebar_right/pr_review_detail/reply_box.tsx @@ -0,0 +1,116 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useCallback} from 'react'; + +import {Theme} from 'mattermost-redux/selectors/entities/preferences'; +import {changeOpacity} from 'mattermost-redux/utils/theme_utils'; + +type Props = { + onSubmit: (body: string) => Promise; + theme: Theme; +}; + +const ReplyBox: React.FC = ({onSubmit, theme}) => { + const [text, setText] = useState(''); + const [expanded, setExpanded] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleFocus = useCallback(() => { + setExpanded(true); + }, []); + + const handleBlur = useCallback(() => { + if (!text.trim()) { + setExpanded(false); + } + }, [text]); + + const handleSubmit = useCallback(async () => { + const body = text.trim(); + if (!body || submitting) { + return; + } + + setSubmitting(true); + try { + await onSubmit(body); + setText(''); + setExpanded(false); + } finally { + setSubmitting(false); + } + }, [text, submitting, onSubmit]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + handleSubmit(); + } + }, [handleSubmit]); + + return ( +
+