From 2782d826ecfc0e3c21f8c67b537fc821a1a9d7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 26 Feb 2026 08:48:11 +0000 Subject: [PATCH] feat: improve PR review watcher + PR info panel (#281) --- apps/backend/internal/github/client.go | 10 + .../backend/internal/github/client_helpers.go | 254 +++++++++++++++++- .../internal/github/client_helpers_test.go | 115 +++++++- apps/backend/internal/github/constants.go | 31 +++ apps/backend/internal/github/controller.go | 30 +++ apps/backend/internal/github/gh_client.go | 192 +++++++++---- .../backend/internal/github/gh_client_test.go | 36 +++ apps/backend/internal/github/handlers.go | 49 ++++ apps/backend/internal/github/models.go | 70 +++-- apps/backend/internal/github/pat_client.go | 177 +++++++++--- .../internal/github/pat_client_test.go | 53 ++++ apps/backend/internal/github/poller.go | 7 + apps/backend/internal/github/service.go | 173 ++++++++++-- apps/backend/internal/github/service_test.go | 75 ++++++ .../orchestrator/event_handlers_github.go | 37 ++- .../orchestrator/handlers/handlers.go | 29 ++ .../internal/orchestrator/task_operations.go | 12 + apps/backend/pkg/websocket/actions.go | 9 +- apps/pnpm-lock.yaml | 45 +++- .../components/github/pr-checks-section.tsx | 56 ++-- .../components/github/pr-comments-section.tsx | 96 ++++--- .../web/components/github/pr-detail-panel.tsx | 175 ++++++++++-- .../components/github/pr-reviews-section.tsx | 128 ++++++--- apps/web/components/github/pr-shared.tsx | 77 ++++++ .../components/github/pr-topbar-button.tsx | 8 +- .../components/github/review-watch-dialog.tsx | 12 +- apps/web/components/review/review-dialog.tsx | 41 ++- apps/web/components/review/types.ts | 2 +- .../components/shared/markdown-components.tsx | 114 ++++++++ .../components/task/changes-panel-header.tsx | 5 +- .../task/changes-panel-timeline.tsx | 125 ++++++++- apps/web/components/task/changes-panel.tsx | 247 ++++++++++++----- .../components/task/chat/message-renderer.tsx | 57 +++- .../task/chat/messages/chat-message.tsx | 104 +------ .../task/dockview-desktop-layout.tsx | 10 + .../components/task/task-changes-panel.tsx | 41 ++- apps/web/components/task/task-top-bar.tsx | 52 ++-- apps/web/components/task/workflow-stepper.tsx | 2 +- .../hooks/domains/github/use-pr-commits.ts | 63 +++++ apps/web/hooks/domains/github/use-pr-diff.ts | 63 +++++ .../domains/kanban/use-kanban-navigation.ts | 161 ++++++----- apps/web/lib/api/domains/github-api.ts | 19 ++ apps/web/lib/types/github.ts | 31 +++ apps/web/package.json | 1 + apps/web/vitest.config.ts | 8 + 45 files changed, 2531 insertions(+), 571 deletions(-) create mode 100644 apps/web/components/shared/markdown-components.tsx create mode 100644 apps/web/hooks/domains/github/use-pr-commits.ts create mode 100644 apps/web/hooks/domains/github/use-pr-diff.ts diff --git a/apps/backend/internal/github/client.go b/apps/backend/internal/github/client.go index e4f4173ccc..d8506b1b97 100644 --- a/apps/backend/internal/github/client.go +++ b/apps/backend/internal/github/client.go @@ -47,4 +47,14 @@ type Client interface { // GetPRFeedback fetches aggregated feedback (reviews, comments, checks) for a PR. GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) + + // ListPRFiles lists files changed in a pull request. + ListPRFiles(ctx context.Context, owner, repo string, number int) ([]PRFile, error) + + // ListPRCommits lists commits in a pull request. + ListPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error) + + // SubmitReview submits a review on a pull request. + // event is one of "APPROVE", "COMMENT", "REQUEST_CHANGES". + SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error } diff --git a/apps/backend/internal/github/client_helpers.go b/apps/backend/internal/github/client_helpers.go index 72019defb3..e6ff095c7a 100644 --- a/apps/backend/internal/github/client_helpers.go +++ b/apps/backend/internal/github/client_helpers.go @@ -2,6 +2,10 @@ package github import ( "context" + "encoding/json" + "fmt" + "sort" + "strings" "time" ) @@ -14,9 +18,9 @@ func buildReviewSearchQuery(scope, filter, customQuery string) string { } var base string if scope == ReviewScopeUser { - base = "type:pr state:open user-review-requested:@me" + base = "type:pr state:open user-review-requested:@me -is:draft" } else { - base = "type:pr state:open review-requested:@me" + base = "type:pr state:open review-requested:@me -is:draft" } if filter != "" { base += " " + filter @@ -67,6 +71,7 @@ func convertRawCheckRuns(raw []ghCheckRun) []CheckRun { } checks[i] = CheckRun{ Name: cr.Name, + Source: checkSourceCheckRun, Status: cr.Status, Conclusion: conclusion, HTMLURL: cr.HTMLURL, @@ -78,7 +83,7 @@ func convertRawCheckRuns(raw []ghCheckRun) []CheckRun { return checks } -// convertRawComments converts raw ghComment structs into the domain PRComment type. +// convertRawComments converts raw review comments into the domain PRComment type. func convertRawComments(raw []ghComment) []PRComment { comments := make([]PRComment, len(raw)) for i, c := range raw { @@ -86,10 +91,12 @@ func convertRawComments(raw []ghComment) []PRComment { ID: c.ID, Author: c.User.Login, AuthorAvatar: c.User.AvatarURL, + AuthorIsBot: isGitHubBot(c.User.Type), Body: c.Body, Path: c.Path, Line: c.Line, Side: c.Side, + CommentType: commentTypeReview, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, InReplyTo: c.InReplyTo, @@ -98,6 +105,125 @@ func convertRawComments(raw []ghComment) []PRComment { return comments } +// ghIssueComment is the JSON shape for issue comments from the GitHub API. +type ghIssueComment struct { + ID int64 `json:"id"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + User struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + Type string `json:"type"` + } `json:"user"` +} + +// convertRawIssueComments converts PR conversation comments into PRComment rows. +func convertRawIssueComments(raw []ghIssueComment) []PRComment { + comments := make([]PRComment, len(raw)) + for i, c := range raw { + comments[i] = PRComment{ + ID: c.ID, + Author: c.User.Login, + AuthorAvatar: c.User.AvatarURL, + AuthorIsBot: isGitHubBot(c.User.Type), + Body: c.Body, + CommentType: commentTypeIssue, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + } + return comments +} + +// mergeAndSortPRComments combines review and issue comments and sorts by creation time. +func mergeAndSortPRComments(reviewComments, issueComments []PRComment) []PRComment { + comments := make([]PRComment, 0, len(reviewComments)+len(issueComments)) + comments = append(comments, reviewComments...) + comments = append(comments, issueComments...) + sort.SliceStable(comments, func(i, j int) bool { + if comments[i].CreatedAt.Equal(comments[j].CreatedAt) { + return comments[i].ID < comments[j].ID + } + return comments[i].CreatedAt.Before(comments[j].CreatedAt) + }) + return comments +} + +func isGitHubBot(userType string) bool { + return strings.EqualFold(userType, githubUserTypeBot) +} + +// ghStatusContext is the JSON shape from /commits/:ref/status. +type ghStatusContext struct { + Context string `json:"context"` + State string `json:"state"` // error, failure, pending, success + TargetURL string `json:"target_url"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// convertRawStatusContexts converts commit status contexts into CheckRun rows. +func convertRawStatusContexts(raw []ghStatusContext) []CheckRun { + checks := make([]CheckRun, len(raw)) + for i, st := range raw { + status := checkStatusCompleted + conclusion := st.State + switch st.State { + case commitStatusSuccess: + conclusion = checkStatusSuccess + case commitStatusFailure, commitStatusError: + conclusion = checkConclusionFail + case commitStatusPending: + status = "in_progress" + conclusion = "" + } + checks[i] = CheckRun{ + Name: st.Context, + Source: checkSourceStatusContext, + Status: status, + Conclusion: conclusion, + HTMLURL: st.TargetURL, + Output: st.Description, + StartedAt: parseTimePtr(st.CreatedAt), + } + if status == checkStatusCompleted { + checks[i].CompletedAt = parseTimePtr(st.UpdatedAt) + } + } + return checks +} + +// mergeChecks deduplicates check runs and commit statuses by normalized name/url. +// If both sources refer to the same item, check-run data wins. +func mergeChecks(checkRuns, statusChecks []CheckRun) []CheckRun { + merged := make([]CheckRun, 0, len(checkRuns)+len(statusChecks)) + byKey := make(map[string]int) + + for _, check := range statusChecks { + key := checkMergeKey(check) + byKey[key] = len(merged) + merged = append(merged, check) + } + for _, check := range checkRuns { + key := checkMergeKey(check) + if idx, ok := byKey[key]; ok { + merged[idx] = check + continue + } + byKey[key] = len(merged) + merged = append(merged, check) + } + return merged +} + +func checkMergeKey(check CheckRun) string { + name := strings.ToLower(strings.TrimSpace(check.Name)) + url := strings.ToLower(strings.TrimSpace(check.HTMLURL)) + return name + "|" + url +} + // convertSearchItemToPR converts common search result fields into a PR struct. func convertSearchItemToPR( number int, title, htmlURL, state, authorLogin, repositoryURL string, @@ -149,3 +275,125 @@ func hasChangesRequested(reviews []PRReview) bool { } return false } + +// --- PR files and commits parsing --- + +// ghPRFile is the JSON shape from the GitHub REST API for PR files. +type ghPRFile struct { + Filename string `json:"filename"` + Status string `json:"status"` // added, removed, modified, renamed, copied, changed, unchanged + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Patch string `json:"patch"` + PreviousFilename string `json:"previous_filename"` +} + +// ghPRCommit is the JSON shape from the GitHub REST API for PR commits. +type ghPRCommit struct { + SHA string `json:"sha"` + Commit struct { + Message string `json:"message"` + Author struct { + Date string `json:"date"` + } `json:"author"` + } `json:"commit"` + Author *struct { + Login string `json:"login"` + } `json:"author"` +} + +// parsePRFilesJSON parses the JSON response from the PR files API. +func parsePRFilesJSON(data string) ([]PRFile, error) { + var raw []ghPRFile + if err := json.Unmarshal([]byte(data), &raw); err != nil { + return nil, fmt.Errorf("parse PR files: %w", err) + } + return convertRawPRFiles(raw), nil +} + +// convertRawPRFiles converts raw GitHub API file structs to domain PRFile. +func convertRawPRFiles(raw []ghPRFile) []PRFile { + files := make([]PRFile, len(raw)) + for i, f := range raw { + files[i] = PRFile{ + Filename: f.Filename, + Status: f.Status, + Additions: f.Additions, + Deletions: f.Deletions, + Patch: buildFullDiff(f.Filename, f.Status, f.Patch, f.PreviousFilename), + OldPath: f.PreviousFilename, + } + } + return files +} + +// buildFullDiff wraps a GitHub patch fragment into a complete unified diff. +func buildFullDiff(filename, status, patch, previousFilename string) string { + if patch == "" { + return "" + } + var b strings.Builder + switch status { + case "added": + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", filename, filename) + b.WriteString("new file mode 100644\n") + fmt.Fprintf(&b, "--- /dev/null\n") + fmt.Fprintf(&b, "+++ b/%s\n", filename) + case "removed": + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", filename, filename) + b.WriteString("deleted file mode 100644\n") + fmt.Fprintf(&b, "--- a/%s\n", filename) + fmt.Fprintf(&b, "+++ /dev/null\n") + case "renamed": + oldName := previousFilename + if oldName == "" { + oldName = filename + } + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", oldName, filename) + fmt.Fprintf(&b, "rename from %s\n", oldName) + fmt.Fprintf(&b, "rename to %s\n", filename) + fmt.Fprintf(&b, "--- a/%s\n", oldName) + fmt.Fprintf(&b, "+++ b/%s\n", filename) + default: + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", filename, filename) + fmt.Fprintf(&b, "--- a/%s\n", filename) + fmt.Fprintf(&b, "+++ b/%s\n", filename) + } + b.WriteString(patch) + if !strings.HasSuffix(patch, "\n") { + b.WriteByte('\n') + } + return b.String() +} + +// parsePRCommitsJSON parses the JSON response from the PR commits API. +func parsePRCommitsJSON(data string) ([]PRCommitInfo, error) { + var raw []ghPRCommit + if err := json.Unmarshal([]byte(data), &raw); err != nil { + return nil, fmt.Errorf("parse PR commits: %w", err) + } + return convertRawPRCommits(raw), nil +} + +// convertRawPRCommits converts raw GitHub API commit structs to domain PRCommitInfo. +func convertRawPRCommits(raw []ghPRCommit) []PRCommitInfo { + commits := make([]PRCommitInfo, len(raw)) + for i, c := range raw { + author := "" + if c.Author != nil { + author = c.Author.Login + } + // First line of commit message + msg := c.Commit.Message + if idx := strings.Index(msg, "\n"); idx >= 0 { + msg = msg[:idx] + } + commits[i] = PRCommitInfo{ + SHA: c.SHA, + Message: msg, + AuthorLogin: author, + AuthorDate: c.Commit.Author.Date, + } + } + return commits +} diff --git a/apps/backend/internal/github/client_helpers_test.go b/apps/backend/internal/github/client_helpers_test.go index 6c7f96c88a..fda9e25a73 100644 --- a/apps/backend/internal/github/client_helpers_test.go +++ b/apps/backend/internal/github/client_helpers_test.go @@ -23,35 +23,35 @@ func TestBuildReviewSearchQuery(t *testing.T) { { name: "user scope without filter", scope: ReviewScopeUser, - want: "type:pr state:open user-review-requested:@me", + want: "type:pr state:open user-review-requested:@me -is:draft", }, { name: "user scope with repo filter", scope: ReviewScopeUser, filter: "repo:owner/repo", - want: "type:pr state:open user-review-requested:@me repo:owner/repo", + want: "type:pr state:open user-review-requested:@me -is:draft repo:owner/repo", }, { name: "user_and_teams scope without filter", scope: ReviewScopeUserAndTeams, - want: "type:pr state:open review-requested:@me", + want: "type:pr state:open review-requested:@me -is:draft", }, { name: "user_and_teams scope with org filter", scope: ReviewScopeUserAndTeams, filter: "org:myorg", - want: "type:pr state:open review-requested:@me org:myorg", + want: "type:pr state:open review-requested:@me -is:draft org:myorg", }, { name: "empty scope defaults to user_and_teams", scope: "", - want: "type:pr state:open review-requested:@me", + want: "type:pr state:open review-requested:@me -is:draft", }, { name: "empty customQuery falls through to scope logic", scope: ReviewScopeUserAndTeams, customQuery: "", - want: "type:pr state:open review-requested:@me", + want: "type:pr state:open review-requested:@me -is:draft", }, } @@ -103,6 +103,9 @@ func TestConvertRawCheckRuns(t *testing.T) { if checks[0].Name != "ci/test" { t.Errorf("expected name ci/test, got %s", checks[0].Name) } + if checks[0].Source != checkSourceCheckRun { + t.Errorf("expected source %q, got %q", checkSourceCheckRun, checks[0].Source) + } if checks[0].Conclusion != "success" { t.Errorf("expected conclusion success, got %s", checks[0].Conclusion) } @@ -143,7 +146,8 @@ func TestConvertRawComments(t *testing.T) { User: struct { Login string `json:"login"` AvatarURL string `json:"avatar_url"` - }{Login: "alice", AvatarURL: "https://avatar.example.com/alice"}, + Type string `json:"type"` + }{Login: "alice", AvatarURL: "https://avatar.example.com/alice", Type: "User"}, }, } @@ -161,6 +165,103 @@ func TestConvertRawComments(t *testing.T) { if comments[0].Line != 42 { t.Errorf("expected line 42, got %d", comments[0].Line) } + if comments[0].CommentType != commentTypeReview { + t.Errorf("expected comment type %q, got %q", commentTypeReview, comments[0].CommentType) + } + if comments[0].AuthorIsBot { + t.Error("expected non-bot comment") + } +} + +func TestConvertRawIssueComments(t *testing.T) { + now := time.Now() + raw := []ghIssueComment{ + { + ID: 10, + Body: "Snyk report", + CreatedAt: now, + UpdatedAt: now, + User: struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + Type string `json:"type"` + }{ + Login: "snyk-io", + AvatarURL: "https://avatar.example.com/snyk", + Type: "Bot", + }, + }, + } + + comments := convertRawIssueComments(raw) + if len(comments) != 1 { + t.Fatalf("expected 1 comment, got %d", len(comments)) + } + if comments[0].CommentType != commentTypeIssue { + t.Errorf("expected comment type %q, got %q", commentTypeIssue, comments[0].CommentType) + } + if !comments[0].AuthorIsBot { + t.Error("expected bot comment") + } +} + +func TestMergeAndSortPRComments(t *testing.T) { + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + t3 := time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC) + review := []PRComment{ + {ID: 2, CommentType: commentTypeReview, CreatedAt: t2}, + {ID: 3, CommentType: commentTypeReview, CreatedAt: t3}, + } + issue := []PRComment{ + {ID: 1, CommentType: commentTypeIssue, CreatedAt: t1}, + } + + got := mergeAndSortPRComments(review, issue) + if len(got) != 3 { + t.Fatalf("expected 3 comments, got %d", len(got)) + } + if got[0].ID != 1 || got[1].ID != 2 || got[2].ID != 3 { + t.Errorf("unexpected merge/sort order: %#v", got) + } +} + +func TestConvertRawStatusContextsAndMergeChecks(t *testing.T) { + conclusion := "success" + checkRuns := []ghCheckRun{ + { + Name: "ci/test", + Status: "completed", + Conclusion: &conclusion, + HTMLURL: "https://github.com/owner/repo/runs/1", + }, + } + statuses := []ghStatusContext{ + { + Context: "ci/test", + State: "pending", + TargetURL: "https://github.com/owner/repo/runs/1", + }, + { + Context: "license/snyk", + State: "success", + TargetURL: "https://app.snyk.io/check/2", + }, + } + + merged := mergeChecks(convertRawCheckRuns(checkRuns), convertRawStatusContexts(statuses)) + if len(merged) != 2 { + t.Fatalf("expected 2 checks after dedupe, got %d", len(merged)) + } + if merged[0].Name != "ci/test" || merged[0].Source != checkSourceCheckRun { + t.Errorf("expected check_run to win dedupe, got %#v", merged[0]) + } + if merged[1].Name != "license/snyk" || merged[1].Source != checkSourceStatusContext { + t.Errorf("expected status_context entry, got %#v", merged[1]) + } + if merged[1].Conclusion != checkStatusSuccess { + t.Errorf("expected success conclusion, got %q", merged[1].Conclusion) + } } func TestConvertSearchItemToPR(t *testing.T) { diff --git a/apps/backend/internal/github/constants.go b/apps/backend/internal/github/constants.go index 84a98361b7..f821664db1 100644 --- a/apps/backend/internal/github/constants.go +++ b/apps/backend/internal/github/constants.go @@ -29,6 +29,37 @@ const ( checkConclusionFail = "failure" ) +// Check source identifiers. +const ( + checkSourceCheckRun = "check_run" + checkSourceStatusContext = "status_context" +) + +// PR comment source identifiers. +const ( + commentTypeReview = "review" + commentTypeIssue = "issue" +) + +// Reviewer types. +const ( + reviewerTypeUser = "user" + reviewerTypeTeam = "team" +) + +// GitHub user type identifiers. +const ( + githubUserTypeBot = "Bot" +) + +// Commit status state values from /commits/:ref/status. +const ( + commitStatusSuccess = "success" + commitStatusPending = "pending" + commitStatusFailure = "failure" + commitStatusError = "error" +) + // Watch poll interval bounds in seconds (stored in the database). const ( defaultWatchPollIntervalSec = 300 diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index b6ab5b784b..509ea2b994 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -32,6 +32,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/task-prs/:taskId", c.httpGetTaskPR) api.GET("/prs/:owner/:repo/:number", c.httpGetPRFeedback) + api.POST("/prs/:owner/:repo/:number/reviews", c.httpSubmitReview) api.GET("/watches/pr", c.httpListPRWatches) api.DELETE("/watches/pr/:id", c.httpDeletePRWatch) @@ -104,6 +105,35 @@ func (c *Controller) httpGetPRFeedback(ctx *gin.Context) { ctx.JSON(http.StatusOK, feedback) } +func (c *Controller) httpSubmitReview(ctx *gin.Context) { + owner := ctx.Param("owner") + repo := ctx.Param("repo") + numberStr := ctx.Param("number") + number, err := strconv.Atoi(numberStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"}) + return + } + var req struct { + Event string `json:"event"` + Body string `json:"body"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + validEvents := map[string]bool{"APPROVE": true, "COMMENT": true, "REQUEST_CHANGES": true} + if !validEvents[req.Event] { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "event must be APPROVE, COMMENT, or REQUEST_CHANGES"}) + return + } + if err := c.service.SubmitReview(ctx.Request.Context(), owner, repo, number, req.Event, req.Body); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"submitted": true}) +} + func (c *Controller) httpListPRWatches(ctx *gin.Context) { watches, err := c.service.ListActivePRWatches(ctx.Request.Context()) if err != nil { diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index c8363ef18a..f6163d32d6 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "os/exec" "strings" "time" @@ -47,24 +48,34 @@ func (c *GHClient) GetAuthenticatedUser(ctx context.Context) (string, error) { return strings.TrimSpace(out), nil } +// ghRequestedReviewer is a user/team requested reviewer returned by gh pr view. +type ghRequestedReviewer struct { + TypeName string `json:"__typename"` + Login string `json:"login"` + Slug string `json:"slug"` + Name string `json:"name"` +} + // ghPR is the JSON shape returned by gh pr list/view. type ghPR struct { - Number int `json:"number"` - Title string `json:"title"` - URL string `json:"url"` - State string `json:"state"` - HeadRefName string `json:"headRefName"` - HeadRefOid string `json:"headRefOid"` - BaseRefName string `json:"baseRefName"` - IsDraft bool `json:"isDraft"` - Mergeable string `json:"mergeable"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - MergedAt string `json:"mergedAt"` - ClosedAt string `json:"closedAt"` - Author struct { + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + State string `json:"state"` + Body string `json:"body"` + HeadRefName string `json:"headRefName"` + HeadRefOid string `json:"headRefOid"` + BaseRefName string `json:"baseRefName"` + IsDraft bool `json:"isDraft"` + Mergeable string `json:"mergeable"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + MergedAt string `json:"mergedAt"` + ClosedAt string `json:"closedAt"` + ReviewRequests []ghRequestedReviewer `json:"reviewRequests"` + Author struct { Login string `json:"login"` } `json:"author"` } @@ -72,7 +83,7 @@ type ghPR struct { func (c *GHClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) { out, err := c.run(ctx, "pr", "view", fmt.Sprintf("%d", number), "--repo", fmt.Sprintf("%s/%s", owner, repo), - "--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,closedAt") + "--json", "number,title,url,state,body,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,closedAt,reviewRequests") if err != nil { return nil, fmt.Errorf("get PR #%d: %w", number, err) } @@ -249,23 +260,30 @@ type ghComment struct { User struct { Login string `json:"login"` AvatarURL string `json:"avatar_url"` + Type string `json:"type"` } `json:"user"` } func (c *GHClient) ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) { - endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number) - if since != nil { - endpoint += "?since=" + since.Format(time.RFC3339) - } - out, err := c.run(ctx, "api", endpoint, "--paginate") + reviewEndpoint := appendSinceQuery(fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number), since) + reviewOut, err := c.run(ctx, "api", reviewEndpoint, "--paginate") if err != nil { return nil, fmt.Errorf("list PR comments: %w", err) } - var raw []ghComment - if err := json.Unmarshal([]byte(out), &raw); err != nil { + var reviewRaw []ghComment + if err := json.Unmarshal([]byte(reviewOut), &reviewRaw); err != nil { return nil, fmt.Errorf("parse comments: %w", err) } - return convertRawComments(raw), nil + issueEndpoint := appendSinceQuery(fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number), since) + issueOut, err := c.run(ctx, "api", issueEndpoint, "--paginate") + if err != nil { + return nil, fmt.Errorf("list issue comments: %w", err) + } + var issueRaw []ghIssueComment + if err := json.Unmarshal([]byte(issueOut), &issueRaw); err != nil { + return nil, fmt.Errorf("parse issue comments: %w", err) + } + return mergeAndSortPRComments(convertRawComments(reviewRaw), convertRawIssueComments(issueRaw)), nil } // ghCheckRun is the JSON shape from the check-runs API. @@ -283,23 +301,69 @@ type ghCheckRun struct { } func (c *GHClient) ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error) { - out, err := c.run(ctx, "api", + checkRunsOut, err := c.run(ctx, "api", fmt.Sprintf("repos/%s/%s/commits/%s/check-runs", owner, repo, ref), "--jq", ".check_runs") if err != nil { return nil, fmt.Errorf("list check runs: %w", err) } - var raw []ghCheckRun - if err := json.Unmarshal([]byte(out), &raw); err != nil { + var checkRunsRaw []ghCheckRun + if err := json.Unmarshal([]byte(checkRunsOut), &checkRunsRaw); err != nil { return nil, fmt.Errorf("parse check runs: %w", err) } - return convertRawCheckRuns(raw), nil + statusOut, err := c.run(ctx, "api", + fmt.Sprintf("repos/%s/%s/commits/%s/status", owner, repo, ref), + "--jq", ".statuses") + if err != nil { + return nil, fmt.Errorf("list status contexts: %w", err) + } + var statusRaw []ghStatusContext + if err := json.Unmarshal([]byte(statusOut), &statusRaw); err != nil { + return nil, fmt.Errorf("parse status contexts: %w", err) + } + return mergeChecks(convertRawCheckRuns(checkRunsRaw), convertRawStatusContexts(statusRaw)), nil } func (c *GHClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) { return getPRFeedback(ctx, c, owner, repo, number) } +func (c *GHClient) ListPRFiles(ctx context.Context, owner, repo string, number int) ([]PRFile, error) { + out, err := c.run(ctx, "api", + fmt.Sprintf("repos/%s/%s/pulls/%d/files", owner, repo, number), + "--paginate") + if err != nil { + return nil, fmt.Errorf("list PR files: %w", err) + } + return parsePRFilesJSON(out) +} + +func (c *GHClient) ListPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error) { + out, err := c.run(ctx, "api", + fmt.Sprintf("repos/%s/%s/pulls/%d/commits", owner, repo, number), + "--paginate") + if err != nil { + return nil, fmt.Errorf("list PR commits: %w", err) + } + return parsePRCommitsJSON(out) +} + +func (c *GHClient) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error { + args := []string{"api", + fmt.Sprintf("repos/%s/%s/pulls/%d/reviews", owner, repo, number), + "-X", "POST", + "-f", "event=" + event, + } + if body != "" { + args = append(args, "-f", "body="+body) + } + _, err := c.run(ctx, args...) + if err != nil { + return fmt.Errorf("submit review on PR #%d: %w", number, err) + } + return nil +} + // run executes a gh CLI command and returns its stdout output. // Stderr is captured separately to avoid contaminating JSON output. func (c *GHClient) run(ctx context.Context, args ...string) (string, error) { @@ -365,29 +429,54 @@ func convertGHPR(raw *ghPR, owner, repo string) *PR { state = prStateMerged } pr := &PR{ - Number: raw.Number, - Title: raw.Title, - URL: raw.URL, - HTMLURL: raw.URL, - State: state, - HeadBranch: raw.HeadRefName, - HeadSHA: raw.HeadRefOid, - BaseBranch: raw.BaseRefName, - AuthorLogin: raw.Author.Login, - RepoOwner: owner, - RepoName: repo, - Draft: raw.IsDraft, - Mergeable: raw.Mergeable == "MERGEABLE", - Additions: raw.Additions, - Deletions: raw.Deletions, - CreatedAt: raw.CreatedAt, - UpdatedAt: raw.UpdatedAt, - MergedAt: parseTimePtr(raw.MergedAt), - ClosedAt: parseTimePtr(raw.ClosedAt), + Number: raw.Number, + Title: raw.Title, + URL: raw.URL, + HTMLURL: raw.URL, + State: state, + Body: raw.Body, + HeadBranch: raw.HeadRefName, + HeadSHA: raw.HeadRefOid, + BaseBranch: raw.BaseRefName, + AuthorLogin: raw.Author.Login, + RepoOwner: owner, + RepoName: repo, + Draft: raw.IsDraft, + Mergeable: raw.Mergeable == "MERGEABLE", + Additions: raw.Additions, + Deletions: raw.Deletions, + RequestedReviewers: convertGHRequestedReviewers(raw.ReviewRequests), + CreatedAt: raw.CreatedAt, + UpdatedAt: raw.UpdatedAt, + MergedAt: parseTimePtr(raw.MergedAt), + ClosedAt: parseTimePtr(raw.ClosedAt), } return pr } +func convertGHRequestedReviewers(raw []ghRequestedReviewer) []RequestedReviewer { + reviewers := make([]RequestedReviewer, 0, len(raw)) + for _, req := range raw { + switch req.TypeName { + case "Team": + login := req.Slug + if login == "" { + login = req.Name + } + if login == "" { + continue + } + reviewers = append(reviewers, RequestedReviewer{Login: login, Type: reviewerTypeTeam}) + default: + if req.Login == "" { + continue + } + reviewers = append(reviewers, RequestedReviewer{Login: req.Login, Type: reviewerTypeUser}) + } + } + return reviewers +} + func parseTimePtr(s string) *time.Time { if s == "" { return nil @@ -408,3 +497,10 @@ func parseRepoURL(url string) (string, string) { } return parts[len(parts)-2], parts[len(parts)-1] } + +func appendSinceQuery(endpoint string, since *time.Time) string { + if since == nil { + return endpoint + } + return endpoint + "?since=" + url.QueryEscape(since.Format(time.RFC3339)) +} diff --git a/apps/backend/internal/github/gh_client_test.go b/apps/backend/internal/github/gh_client_test.go index 7db24c3211..771d7a8f63 100644 --- a/apps/backend/internal/github/gh_client_test.go +++ b/apps/backend/internal/github/gh_client_test.go @@ -101,6 +101,10 @@ func TestConvertGHPR(t *testing.T) { Deletions: 50, CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + ReviewRequests: []ghRequestedReviewer{ + {TypeName: "User", Login: "alice-reviewer"}, + {TypeName: "Team", Slug: "core-platform"}, + }, Author: struct { Login string `json:"login"` }{Login: "alice"}, @@ -129,6 +133,15 @@ func TestConvertGHPR(t *testing.T) { if pr.Additions != 100 { t.Errorf("additions = %d, want 100", pr.Additions) } + if len(pr.RequestedReviewers) != 2 { + t.Fatalf("requested reviewers = %d, want 2", len(pr.RequestedReviewers)) + } + if pr.RequestedReviewers[0].Type != reviewerTypeUser { + t.Errorf("first reviewer type = %q, want %q", pr.RequestedReviewers[0].Type, reviewerTypeUser) + } + if pr.RequestedReviewers[1].Type != reviewerTypeTeam { + t.Errorf("second reviewer type = %q, want %q", pr.RequestedReviewers[1].Type, reviewerTypeTeam) + } if pr.MergedAt != nil { t.Error("expected nil MergedAt") } @@ -170,3 +183,26 @@ func TestConvertGHPR_NotMergeable(t *testing.T) { t.Error("expected mergeable = false for CONFLICTING") } } + +func TestConvertGHRequestedReviewers(t *testing.T) { + raw := []ghRequestedReviewer{ + {TypeName: "User", Login: "alice"}, + {TypeName: "Team", Slug: "my-team"}, + {TypeName: "Team", Name: "fallback-team-name"}, + {TypeName: "User"}, + } + + got := convertGHRequestedReviewers(raw) + if len(got) != 3 { + t.Fatalf("requested reviewers = %d, want 3", len(got)) + } + if got[0] != (RequestedReviewer{Login: "alice", Type: reviewerTypeUser}) { + t.Errorf("unexpected first reviewer: %#v", got[0]) + } + if got[1] != (RequestedReviewer{Login: "my-team", Type: reviewerTypeTeam}) { + t.Errorf("unexpected second reviewer: %#v", got[1]) + } + if got[2] != (RequestedReviewer{Login: "fallback-team-name", Type: reviewerTypeTeam}) { + t.Errorf("unexpected third reviewer: %#v", got[2]) + } +} diff --git a/apps/backend/internal/github/handlers.go b/apps/backend/internal/github/handlers.go index 160b97add2..34a885a7d8 100644 --- a/apps/backend/internal/github/handlers.go +++ b/apps/backend/internal/github/handlers.go @@ -31,6 +31,8 @@ func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Log dispatcher.RegisterFunc(ws.ActionGitHubReviewTriggerAll, wsTriggerAllReviewChecks(svc, log)) dispatcher.RegisterFunc(ws.ActionGitHubPRWatchesList, wsListPRWatches(svc, log)) dispatcher.RegisterFunc(ws.ActionGitHubPRWatchDelete, wsDeletePRWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubPRFilesGet, wsGetPRFiles(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitHubPRCommitsGet, wsGetPRCommits(svc, log)) dispatcher.RegisterFunc(ws.ActionGitHubStats, wsGetStats(svc, log)) } @@ -262,3 +264,50 @@ func wsGetStats(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *w return ws.NewResponse(msg.ID, msg.Action, stats) } } + +// parsePRParams extracts owner, repo, and number from a WS message payload. +// Returns a non-nil error response message if the payload is invalid or required fields are missing. +func parsePRParams(msg *ws.Message) (string, string, int, *ws.Message) { + payload, parseErr := parseMap(msg) + if parseErr != nil { + resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + return "", "", 0, resp + } + owner, _ := payload["owner"].(string) + repo, _ := payload["repo"].(string) + numberF, _ := payload["number"].(float64) + number := int(numberF) + if owner == "" || repo == "" || number == 0 { + resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "owner, repo, number required", nil) + return "", "", 0, resp + } + return owner, repo, number, nil +} + +func wsGetPRFiles(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + owner, repo, number, errResp := parsePRParams(msg) + if errResp != nil { + return errResp, nil + } + files, err := svc.GetPRFiles(ctx, owner, repo, number) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"files": files}) + } +} + +func wsGetPRCommits(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + owner, repo, number, errResp := parsePRParams(msg) + if errResp != nil { + return errResp, nil + } + commits, err := svc.GetPRCommits(ctx, owner, repo, number) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"commits": commits}) + } +} diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index 55669ac1d1..1ea432abbb 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -6,25 +6,33 @@ import "time" // PR represents a GitHub Pull Request. type PR struct { - Number int `json:"number"` - Title string `json:"title"` - URL string `json:"url"` - HTMLURL string `json:"html_url"` - State string `json:"state"` // open, closed, merged - HeadBranch string `json:"head_branch"` - HeadSHA string `json:"head_sha"` - BaseBranch string `json:"base_branch"` - AuthorLogin string `json:"author_login"` - RepoOwner string `json:"repo_owner"` - RepoName string `json:"repo_name"` - Draft bool `json:"draft"` - Mergeable bool `json:"mergeable"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - MergedAt *time.Time `json:"merged_at,omitempty"` - ClosedAt *time.Time `json:"closed_at,omitempty"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + State string `json:"state"` // open, closed, merged + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + BaseBranch string `json:"base_branch"` + AuthorLogin string `json:"author_login"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` + Body string `json:"body"` + Draft bool `json:"draft"` + Mergeable bool `json:"mergeable"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + RequestedReviewers []RequestedReviewer `json:"requested_reviewers"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *time.Time `json:"merged_at,omitempty"` + ClosedAt *time.Time `json:"closed_at,omitempty"` +} + +// RequestedReviewer represents a pending reviewer request on a PR. +type RequestedReviewer struct { + Login string `json:"login"` + Type string `json:"type"` // user, team } // PRReview represents a review on a PR. @@ -42,10 +50,12 @@ type PRComment struct { ID int64 `json:"id"` Author string `json:"author"` AuthorAvatar string `json:"author_avatar"` + AuthorIsBot bool `json:"author_is_bot"` Body string `json:"body"` Path string `json:"path"` Line int `json:"line"` Side string `json:"side"` // LEFT, RIGHT + CommentType string `json:"comment_type"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` InReplyTo *int64 `json:"in_reply_to,omitempty"` @@ -54,6 +64,7 @@ type PRComment struct { // CheckRun represents a CI check result. type CheckRun struct { Name string `json:"name"` + Source string `json:"source"` // check_run, status_context Status string `json:"status"` // queued, in_progress, completed Conclusion string `json:"conclusion"` // success, failure, neutral, cancelled, timed_out, action_required, skipped HTMLURL string `json:"html_url"` @@ -255,6 +266,27 @@ type PRStats struct { PRsByDay []DailyCount `json:"prs_by_day"` } +// PRFile represents a file changed in a pull request. +type PRFile struct { + Filename string `json:"filename"` + Status string `json:"status"` // added, removed, modified, renamed, copied, changed, unchanged + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Patch string `json:"patch,omitempty"` + OldPath string `json:"old_path,omitempty"` +} + +// PRCommitInfo represents a commit in a pull request. +type PRCommitInfo struct { + SHA string `json:"sha"` + Message string `json:"message"` + AuthorLogin string `json:"author_login"` + AuthorDate string `json:"author_date"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + FilesChanged int `json:"files_changed"` +} + // DailyCount holds a date and count for chart data. type DailyCount struct { Date string `json:"date"` diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index 8a6d680fd9..bcbefd4a77 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -1,6 +1,7 @@ package github import ( + "bytes" "context" "encoding/json" "fmt" @@ -191,30 +192,103 @@ func (c *PATClient) ListPRReviews(ctx context.Context, owner, repo string, numbe func (c *PATClient) ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) { endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments?per_page=100", owner, repo, number) if since != nil { - endpoint += "&since=" + since.Format(time.RFC3339) + endpoint += "&since=" + url.QueryEscape(since.Format(time.RFC3339)) } - var raw []ghComment - if err := c.get(ctx, endpoint, &raw); err != nil { + var reviewRaw []ghComment + if err := c.get(ctx, endpoint, &reviewRaw); err != nil { return nil, err } - return convertRawComments(raw), nil + issueEndpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/comments?per_page=100", owner, repo, number) + if since != nil { + issueEndpoint += "&since=" + url.QueryEscape(since.Format(time.RFC3339)) + } + var issueRaw []ghIssueComment + if err := c.get(ctx, issueEndpoint, &issueRaw); err != nil { + return nil, err + } + return mergeAndSortPRComments(convertRawComments(reviewRaw), convertRawIssueComments(issueRaw)), nil } func (c *PATClient) ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error) { - var result struct { + var checkRunsResult struct { CheckRuns []ghCheckRun `json:"check_runs"` } endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/check-runs", owner, repo, ref) - if err := c.get(ctx, endpoint, &result); err != nil { + if err := c.get(ctx, endpoint, &checkRunsResult); err != nil { return nil, err } - return convertRawCheckRuns(result.CheckRuns), nil + var statusResult struct { + Statuses []ghStatusContext `json:"statuses"` + } + statusEndpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/status", owner, repo, ref) + if err := c.get(ctx, statusEndpoint, &statusResult); err != nil { + return nil, err + } + return mergeChecks( + convertRawCheckRuns(checkRunsResult.CheckRuns), + convertRawStatusContexts(statusResult.Statuses), + ), nil } func (c *PATClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) { return getPRFeedback(ctx, c, owner, repo, number) } +func (c *PATClient) ListPRFiles(ctx context.Context, owner, repo string, number int) ([]PRFile, error) { + var raw []ghPRFile + endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/files?per_page=100", owner, repo, number) + if err := c.get(ctx, endpoint, &raw); err != nil { + return nil, fmt.Errorf("list PR files: %w", err) + } + return convertRawPRFiles(raw), nil +} + +func (c *PATClient) ListPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error) { + var raw []ghPRCommit + endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/commits?per_page=100", owner, repo, number) + if err := c.get(ctx, endpoint, &raw); err != nil { + return nil, fmt.Errorf("list PR commits: %w", err) + } + return convertRawPRCommits(raw), nil +} + +func (c *PATClient) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error { + endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, number) + payload := map[string]string{"event": event} + if body != "" { + payload["body"] = body + } + jsonBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal review payload: %w", err) + } + return c.post(ctx, endpoint, jsonBody) +} + +func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) error { + u := githubAPIBase + endpoint + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request POST %s: %w", endpoint, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("GitHub API POST %s returned %d: %s", endpoint, resp.StatusCode, string(respBody)) + } + return nil +} + func (c *PATClient) get(ctx context.Context, endpoint string, result interface{}) error { url := githubAPIBase + endpoint req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -240,19 +314,27 @@ func (c *PATClient) get(ctx context.Context, endpoint string, result interface{} // patPR is the JSON shape from the GitHub REST API for PRs. type patPR struct { - Number int `json:"number"` - Title string `json:"title"` - HTMLURL string `json:"html_url"` - State string `json:"state"` - Draft bool `json:"draft"` - Mergeable *bool `json:"mergeable"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - MergedAt *string `json:"merged_at"` - ClosedAt *string `json:"closed_at"` - User struct { + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + Body string `json:"body"` + State string `json:"state"` + Draft bool `json:"draft"` + Mergeable *bool `json:"mergeable"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *string `json:"merged_at"` + ClosedAt *string `json:"closed_at"` + RequestedReviewers []struct { + Login string `json:"login"` + } `json:"requested_reviewers"` + RequestedTeams []struct { + Slug string `json:"slug"` + Name string `json:"name"` + } `json:"requested_teams"` + User struct { Login string `json:"login"` } `json:"user"` Head struct { @@ -288,22 +370,24 @@ func convertPatPR(raw *patPR, owner, repo string) *PR { mergeable = *raw.Mergeable } pr := &PR{ - Number: raw.Number, - Title: raw.Title, - HTMLURL: raw.HTMLURL, - State: state, - HeadBranch: raw.Head.Ref, - HeadSHA: raw.Head.SHA, - BaseBranch: raw.Base.Ref, - AuthorLogin: raw.User.Login, - RepoOwner: owner, - RepoName: repo, - Draft: raw.Draft, - Mergeable: mergeable, - Additions: raw.Additions, - Deletions: raw.Deletions, - CreatedAt: raw.CreatedAt, - UpdatedAt: raw.UpdatedAt, + Number: raw.Number, + Title: raw.Title, + HTMLURL: raw.HTMLURL, + Body: raw.Body, + State: state, + HeadBranch: raw.Head.Ref, + HeadSHA: raw.Head.SHA, + BaseBranch: raw.Base.Ref, + AuthorLogin: raw.User.Login, + RepoOwner: owner, + RepoName: repo, + Draft: raw.Draft, + Mergeable: mergeable, + Additions: raw.Additions, + Deletions: raw.Deletions, + RequestedReviewers: convertPatRequestedReviewers(raw), + CreatedAt: raw.CreatedAt, + UpdatedAt: raw.UpdatedAt, } if raw.MergedAt != nil { pr.MergedAt = parseTimePtr(*raw.MergedAt) @@ -313,3 +397,24 @@ func convertPatPR(raw *patPR, owner, repo string) *PR { } return pr } + +func convertPatRequestedReviewers(raw *patPR) []RequestedReviewer { + reviewers := make([]RequestedReviewer, 0, len(raw.RequestedReviewers)+len(raw.RequestedTeams)) + for _, reviewer := range raw.RequestedReviewers { + if reviewer.Login == "" { + continue + } + reviewers = append(reviewers, RequestedReviewer{Login: reviewer.Login, Type: reviewerTypeUser}) + } + for _, team := range raw.RequestedTeams { + login := team.Slug + if login == "" { + login = team.Name + } + if login == "" { + continue + } + reviewers = append(reviewers, RequestedReviewer{Login: login, Type: reviewerTypeTeam}) + } + return reviewers +} diff --git a/apps/backend/internal/github/pat_client_test.go b/apps/backend/internal/github/pat_client_test.go index 89fab5a4e3..9c78056bed 100644 --- a/apps/backend/internal/github/pat_client_test.go +++ b/apps/backend/internal/github/pat_client_test.go @@ -16,6 +16,17 @@ func TestConvertPatPR(t *testing.T) { Deletions: 30, CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2025, 3, 2, 0, 0, 0, 0, time.UTC), + RequestedReviewers: []struct { + Login string `json:"login"` + }{ + {Login: "alice-reviewer"}, + }, + RequestedTeams: []struct { + Slug string `json:"slug"` + Name string `json:"name"` + }{ + {Slug: "platform-team"}, + }, User: struct { Login string `json:"login"` }{Login: "bob"}, @@ -48,6 +59,15 @@ func TestConvertPatPR(t *testing.T) { if pr.Mergeable { t.Error("expected mergeable = false when nil") } + if len(pr.RequestedReviewers) != 2 { + t.Fatalf("requested reviewers = %d, want 2", len(pr.RequestedReviewers)) + } + if pr.RequestedReviewers[0] != (RequestedReviewer{Login: "alice-reviewer", Type: reviewerTypeUser}) { + t.Errorf("unexpected first requested reviewer: %#v", pr.RequestedReviewers[0]) + } + if pr.RequestedReviewers[1] != (RequestedReviewer{Login: "platform-team", Type: reviewerTypeTeam}) { + t.Errorf("unexpected second requested reviewer: %#v", pr.RequestedReviewers[1]) + } if pr.MergedAt != nil { t.Error("expected nil MergedAt") } @@ -104,3 +124,36 @@ func TestConvertPatPR_Mergeable(t *testing.T) { t.Error("expected mergeable = true") } } + +func TestConvertPatRequestedReviewers(t *testing.T) { + raw := &patPR{ + RequestedReviewers: []struct { + Login string `json:"login"` + }{ + {Login: "alice"}, + {}, + }, + RequestedTeams: []struct { + Slug string `json:"slug"` + Name string `json:"name"` + }{ + {Slug: "my-team"}, + {Name: "fallback-team"}, + {}, + }, + } + + got := convertPatRequestedReviewers(raw) + if len(got) != 3 { + t.Fatalf("requested reviewers = %d, want 3", len(got)) + } + if got[0] != (RequestedReviewer{Login: "alice", Type: reviewerTypeUser}) { + t.Errorf("unexpected first reviewer: %#v", got[0]) + } + if got[1] != (RequestedReviewer{Login: "my-team", Type: reviewerTypeTeam}) { + t.Errorf("unexpected second reviewer: %#v", got[1]) + } + if got[2] != (RequestedReviewer{Login: "fallback-team", Type: reviewerTypeTeam}) { + t.Errorf("unexpected third reviewer: %#v", got[2]) + } +} diff --git a/apps/backend/internal/github/poller.go b/apps/backend/internal/github/poller.go index 0f2765fe65..2cbd4a6939 100644 --- a/apps/backend/internal/github/poller.go +++ b/apps/backend/internal/github/poller.go @@ -240,6 +240,13 @@ func (p *Poller) checkReviewWatches(ctx context.Context) { } p.logger.Debug("checking review watches", zap.Int("count", len(watches))) for _, watch := range watches { + p.logger.Debug("polling review watch", + zap.String("watch_id", watch.ID), + zap.String("workspace_id", watch.WorkspaceID), + zap.String("custom_query", watch.CustomQuery), + zap.Int("repo_filters", len(watch.Repos)), + zap.String("review_scope", watch.ReviewScope)) + newPRs, err := p.service.CheckReviewWatch(ctx, watch) if err != nil { p.logger.Debug("failed to check review watch", diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index cbb4385de0..57812303ed 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -61,6 +61,14 @@ func (s *Service) GetStatus(ctx context.Context) (*GitHubStatus, error) { return status, nil } +// SubmitReview submits a review on a pull request. +func (s *Service) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error { + if s.client == nil { + return fmt.Errorf("github client not configured") + } + return s.client.SubmitReview(ctx, owner, repo, number, event, body) +} + // --- PR Watch operations --- // CreatePRWatch creates a new PR watch for a session. @@ -309,9 +317,10 @@ func (s *Service) SyncTaskPR(ctx context.Context, taskID string, feedback *PRFee tp.ClosedAt = feedback.PR.ClosedAt tp.CommentCount = len(feedback.Comments) tp.ReviewCount = len(feedback.Reviews) - tp.ReviewState = computeOverallReviewState(feedback.Reviews) + reviewState, pendingReviewCount := deriveReviewSyncState(feedback.PR, feedback.Reviews) + tp.ReviewState = reviewState tp.ChecksState = computeOverallCheckStatus(feedback.Checks) - tp.PendingReviewCount = countPendingReviews(feedback.Reviews) + tp.PendingReviewCount = pendingReviewCount now := time.Now().UTC() tp.LastSyncedAt = &now @@ -338,6 +347,24 @@ func (s *Service) GetPRFeedback(ctx context.Context, owner, repo string, number return s.client.GetPRFeedback(ctx, owner, repo, number) } +// --- PR files and commits (live) --- + +// GetPRFiles fetches files changed in a PR from GitHub. +func (s *Service) GetPRFiles(ctx context.Context, owner, repo string, number int) ([]PRFile, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + return s.client.ListPRFiles(ctx, owner, repo, number) +} + +// GetPRCommits fetches commits in a PR from GitHub. +func (s *Service) GetPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + return s.client.ListPRCommits(ctx, owner, repo, number) +} + // --- Review Watch operations --- // CreateReviewWatch creates a new review watch and triggers an initial poll. @@ -463,7 +490,10 @@ func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]* s.logger.Debug("checking review watch for pending PRs", zap.String("watch_id", watch.ID), - zap.Int("repo_filters", len(watch.Repos))) + zap.Int("repo_filters", len(watch.Repos)), + zap.String("custom_query", watch.CustomQuery), + zap.String("review_scope", watch.ReviewScope), + zap.Bool("enabled", watch.Enabled)) prs, err := s.fetchReviewPRs(ctx, watch) if err != nil { @@ -482,11 +512,26 @@ func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]* s.logger.Error("failed to check review PR task", zap.Error(err)) continue } - if !exists { + if exists { + s.logger.Debug("skipping already-tracked PR", + zap.String("watch_id", watch.ID), + zap.String("repo", pr.RepoOwner+"/"+pr.RepoName), + zap.Int("pr_number", pr.Number)) + } else { newPRs = append(newPRs, pr) } } + // Enrich new PRs with full details (branch info) from the PR API, + // since the search API does not return head/base branch. + s.enrichPRDetails(ctx, newPRs) + + s.logger.Debug("review watch check complete", + zap.String("watch_id", watch.ID), + zap.Int("total_fetched", len(prs)), + zap.Int("new_prs", len(newPRs)), + zap.Int("already_tracked", len(prs)-len(newPRs))) + // Update last polled now := time.Now().UTC() watch.LastPolledAt = &now @@ -496,35 +541,69 @@ func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]* } // fetchReviewPRs fetches PRs needing review based on the watch configuration. -// If CustomQuery is set, it is used as the full search query (bypassing scope and repo filters). -// Otherwise, scope + repo filters determine the query. +// When repo filters are set, they are always applied — even when a custom query is present +// (the filter qualifier is appended to the query for each repo). func (s *Service) fetchReviewPRs(ctx context.Context, watch *ReviewWatch) ([]*PR, error) { - customQuery := watch.CustomQuery - scope := watch.ReviewScope + hasRepos := len(watch.Repos) > 0 - if customQuery != "" { - return s.client.ListReviewRequestedPRs(ctx, "", "", customQuery) + s.logger.Debug("fetchReviewPRs: starting", + zap.String("watch_id", watch.ID), + zap.String("custom_query", watch.CustomQuery), + zap.String("scope", watch.ReviewScope), + zap.Int("repo_count", len(watch.Repos)), + zap.Bool("has_repos", hasRepos)) + + // No repo filters: use query verbatim (custom or scope-based) + if !hasRepos { + if watch.CustomQuery != "" { + s.logger.Debug("fetchReviewPRs: using custom query (all repos)", + zap.String("query", watch.CustomQuery)) + return s.client.ListReviewRequestedPRs(ctx, "", "", watch.CustomQuery) + } + s.logger.Debug("fetchReviewPRs: using scope (all repos)", + zap.String("scope", watch.ReviewScope)) + return s.client.ListReviewRequestedPRs(ctx, watch.ReviewScope, "", "") } - if len(watch.Repos) == 0 { - return s.client.ListReviewRequestedPRs(ctx, scope, "", "") - } + // Has repo filters: iterate repos, appending filter to customQuery or scope + prs := s.fetchReviewPRsWithFilter(ctx, watch) + return prs, nil +} +// fetchReviewPRsWithFilter queries each repo filter individually and deduplicates results. +// When customQuery is set, the repo qualifier is appended to it; otherwise scope+filter is used. +func (s *Service) fetchReviewPRsWithFilter(ctx context.Context, watch *ReviewWatch) []*PR { var allPRs []*PR seen := make(map[string]bool) + for _, repo := range watch.Repos { - var filter string - if repo.Name == "" { - filter = "org:" + repo.Owner + qualifier := repoFilterToQualifier(repo) + + var prs []*PR + var err error + if watch.CustomQuery != "" { + query := watch.CustomQuery + " " + qualifier + s.logger.Debug("fetchReviewPRs: querying with custom query + filter", + zap.String("watch_id", watch.ID), + zap.String("query", query)) + prs, err = s.client.ListReviewRequestedPRs(ctx, "", "", query) } else { - filter = fmt.Sprintf("repo:%s/%s", repo.Owner, repo.Name) + s.logger.Debug("fetchReviewPRs: querying with scope + filter", + zap.String("watch_id", watch.ID), + zap.String("scope", watch.ReviewScope), + zap.String("filter", qualifier)) + prs, err = s.client.ListReviewRequestedPRs(ctx, watch.ReviewScope, qualifier, "") } - prs, err := s.client.ListReviewRequestedPRs(ctx, scope, filter, "") if err != nil { s.logger.Error("failed to list review PRs", - zap.String("filter", filter), zap.Error(err)) + zap.String("filter", qualifier), zap.Error(err)) continue } + + s.logger.Debug("fetchReviewPRs: got results for filter", + zap.String("filter", qualifier), + zap.Int("count", len(prs))) + for _, pr := range prs { key := fmt.Sprintf("%s/%s#%d", pr.RepoOwner, pr.RepoName, pr.Number) if !seen[key] { @@ -533,7 +612,42 @@ func (s *Service) fetchReviewPRs(ctx context.Context, watch *ReviewWatch) ([]*PR } } } - return allPRs, nil + return allPRs +} + +// repoFilterToQualifier converts a RepoFilter to a GitHub search qualifier string. +func repoFilterToQualifier(repo RepoFilter) string { + if repo.Name == "" { + return "org:" + repo.Owner + } + return fmt.Sprintf("repo:%s/%s", repo.Owner, repo.Name) +} + +// enrichPRDetails fetches full PR details for PRs missing branch info (from the search API). +func (s *Service) enrichPRDetails(ctx context.Context, prs []*PR) { + for _, pr := range prs { + if pr.HeadBranch != "" && pr.BaseBranch != "" { + continue + } + s.logger.Debug("enriching PR with full details (missing branch info)", + zap.String("repo", pr.RepoOwner+"/"+pr.RepoName), + zap.Int("pr_number", pr.Number)) + + full, err := s.client.GetPR(ctx, pr.RepoOwner, pr.RepoName, pr.Number) + if err != nil { + s.logger.Warn("failed to fetch full PR details, branch info will be empty", + zap.String("repo", pr.RepoOwner+"/"+pr.RepoName), + zap.Int("pr_number", pr.Number), + zap.Error(err)) + continue + } + pr.HeadBranch = full.HeadBranch + pr.HeadSHA = full.HeadSHA + pr.BaseBranch = full.BaseBranch + pr.Additions = full.Additions + pr.Deletions = full.Deletions + pr.Mergeable = full.Mergeable + } } // ListUserOrgs returns the authenticated user's orgs, prepending their own username. @@ -701,3 +815,22 @@ func countPendingReviews(reviews []PRReview) int { } return count } + +func countPendingRequestedReviewers(pr *PR) int { + if pr == nil { + return 0 + } + return len(pr.RequestedReviewers) +} + +func deriveReviewSyncState(pr *PR, reviews []PRReview) (string, int) { + pendingReviewCount := countPendingRequestedReviewers(pr) + if pendingReviewCount == 0 { + pendingReviewCount = countPendingReviews(reviews) + } + reviewState := computeOverallReviewState(reviews) + if reviewState == "" && pendingReviewCount > 0 { + reviewState = computedReviewStatePending + } + return reviewState, pendingReviewCount +} diff --git a/apps/backend/internal/github/service_test.go b/apps/backend/internal/github/service_test.go index 898ecfaf84..4e1d2d235a 100644 --- a/apps/backend/internal/github/service_test.go +++ b/apps/backend/internal/github/service_test.go @@ -275,6 +275,81 @@ func TestCountPendingReviews(t *testing.T) { } } +func TestCountPendingRequestedReviewers(t *testing.T) { + tests := []struct { + name string + pr *PR + want int + }{ + {"nil pr", nil, 0}, + {"none", &PR{}, 0}, + { + "with requested reviewers", + &PR{ + RequestedReviewers: []RequestedReviewer{ + {Login: "alice", Type: reviewerTypeUser}, + {Login: "core-team", Type: reviewerTypeTeam}, + }, + }, + 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := countPendingRequestedReviewers(tt.pr) + if got != tt.want { + t.Errorf("countPendingRequestedReviewers() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestDeriveReviewSyncState(t *testing.T) { + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + tests := []struct { + name string + pr *PR + reviews []PRReview + wantState string + wantCount int + }{ + { + name: "uses requested reviewers before review-derived pending", + pr: &PR{RequestedReviewers: []RequestedReviewer{{Login: "alice", Type: reviewerTypeUser}}}, + reviews: []PRReview{{Author: "alice", State: reviewStateCommented, CreatedAt: t1}}, + wantState: computedReviewStatePending, + wantCount: 1, + }, + { + name: "fallback to review-derived pending when no requests", + pr: &PR{}, + reviews: []PRReview{{Author: "alice", State: reviewStateCommented, CreatedAt: t1}}, + wantState: computedReviewStatePending, + wantCount: 1, + }, + { + name: "no submitted reviews but pending requests yields pending state", + pr: &PR{RequestedReviewers: []RequestedReviewer{{Login: "core", Type: reviewerTypeTeam}}}, + reviews: nil, + wantState: computedReviewStatePending, + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotState, gotCount := deriveReviewSyncState(tt.pr, tt.reviews) + if gotState != tt.wantState { + t.Errorf("deriveReviewSyncState() state = %q, want %q", gotState, tt.wantState) + } + if gotCount != tt.wantCount { + t.Errorf("deriveReviewSyncState() pending count = %d, want %d", gotCount, tt.wantCount) + } + }) + } +} + func TestFindLatestCommentTime(t *testing.T) { t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) t2 := time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC) diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index bfb6861897..b7b7f215cd 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -110,6 +110,13 @@ func (s *Service) createReviewTask(ctx context.Context, evt *github.NewReviewPRE pr := evt.PR repoSlug := fmt.Sprintf("%s/%s", pr.RepoOwner, pr.RepoName) + s.logger.Debug("creating review task from PR", + zap.String("repo", repoSlug), + zap.Int("pr_number", pr.Number), + zap.String("head_branch", pr.HeadBranch), + zap.String("base_branch", pr.BaseBranch), + zap.String("review_watch_id", evt.ReviewWatchID)) + title := fmt.Sprintf("PR #%d: %s", pr.Number, pr.Title) description := interpolateReviewPrompt(evt.Prompt, pr) @@ -124,12 +131,14 @@ func (s *Service) createReviewTask(ctx context.Context, evt *github.NewReviewPRE Description: description, Repositories: repositories, Metadata: map[string]interface{}{ - "review_watch_id": evt.ReviewWatchID, - "pr_number": pr.Number, - "pr_url": pr.HTMLURL, - "pr_repo": repoSlug, - "pr_author": pr.AuthorLogin, - "pr_branch": pr.HeadBranch, + "review_watch_id": evt.ReviewWatchID, + "pr_number": pr.Number, + "pr_url": pr.HTMLURL, + "pr_repo": repoSlug, + "pr_author": pr.AuthorLogin, + "pr_branch": pr.HeadBranch, + "agent_profile_id": evt.AgentProfileID, + "executor_profile_id": evt.ExecutorProfileID, }, }) if err != nil { @@ -220,19 +229,31 @@ func (s *Service) resolveReviewRepository(ctx context.Context, workspaceID strin if s.repositoryResolver == nil { return nil } + repoSlug := fmt.Sprintf("%s/%s", pr.RepoOwner, pr.RepoName) + s.logger.Debug("resolving review repository", + zap.String("repo", repoSlug), + zap.String("pr_base_branch", pr.BaseBranch), + zap.String("pr_head_branch", pr.HeadBranch)) + repoID, baseBranch, err := s.repositoryResolver.ResolveForReview( ctx, workspaceID, "github", pr.RepoOwner, pr.RepoName, pr.BaseBranch, ) if err != nil { s.logger.Warn("failed to resolve repository for review task (continuing without repo)", - zap.String("repo", fmt.Sprintf("%s/%s", pr.RepoOwner, pr.RepoName)), + zap.String("repo", repoSlug), zap.Error(err)) return nil } if repoID == "" { return nil } - return []ReviewTaskRepository{{RepositoryID: repoID, BaseBranch: baseBranch}} + s.logger.Debug("resolved review repository", + zap.String("repo", repoSlug), + zap.String("repo_id", repoID), + zap.String("base_branch", baseBranch)) + // Use the PR's head branch so the worktree checks out the PR code, not the merge target. + // The actual base branch is preserved in the TaskPR record for reference. + return []ReviewTaskRepository{{RepositoryID: repoID, BaseBranch: pr.HeadBranch}} } // detectPushAndAssociatePR checks if a push happened and looks for a PR on diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index c74208df1e..6d43140f83 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -38,6 +38,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionPermissionRespond, h.wsRespondToPermission) d.RegisterFunc(ws.ActionTaskSessionResume, h.wsResumeTaskSession) d.RegisterFunc(ws.ActionTaskSessionStatus, h.wsGetTaskSessionStatus) + d.RegisterFunc(ws.ActionTaskSessionPrepare, h.wsPrepareTaskSession) d.RegisterFunc(ws.ActionAgentCancel, h.wsCancelAgent) } @@ -222,6 +223,34 @@ func (h *Handlers) wsResumeTaskSession(ctx context.Context, msg *ws.Message) (*w return ws.NewResponse(msg.ID, msg.Action, resp) } +type wsPrepareTaskSessionRequest struct { + TaskID string `json:"task_id"` +} + +type wsPrepareTaskSessionResponse struct { + SessionID string `json:"session_id"` +} + +func (h *Handlers) wsPrepareTaskSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsPrepareTaskSessionRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.TaskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + + sessionID, err := h.service.PrepareTaskSession(ctx, req.TaskID, "", "", "", "", true) + if err != nil { + h.logger.Error("failed to prepare task session", + zap.String("task_id", req.TaskID), + zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to prepare session: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, wsPrepareTaskSessionResponse{SessionID: sessionID}) +} + type wsStopTaskRequest struct { TaskID string `json:"task_id"` Reason string `json:"reason,omitempty"` diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index ff5f8eec7e..b1bbd43902 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -92,6 +92,18 @@ func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentPr return "", err } + // Resolve agent/executor profile from task metadata if not explicitly provided + if agentProfileID == "" { + if v, ok := task.Metadata["agent_profile_id"].(string); ok && v != "" { + agentProfileID = v + } + } + if executorProfileID == "" { + if v, ok := task.Metadata["executor_profile_id"].(string); ok && v != "" { + executorProfileID = v + } + } + // Create session entry in database sessionID, err := s.executor.PrepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID) if err != nil { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index f762ccef81..26e475da4f 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -69,9 +69,10 @@ const ( ActionTaskPlanUpdate = "task.plan.update" ActionTaskPlanDelete = "task.plan.delete" - ActionTaskSessionList = "task.session.list" - ActionTaskSessionResume = "task.session.resume" - ActionTaskSessionStatus = "task.session.status" + ActionTaskSessionList = "task.session.list" + ActionTaskSessionResume = "task.session.resume" + ActionTaskSessionStatus = "task.session.status" + ActionTaskSessionPrepare = "task.session.prepare" // Agent actions ActionAgentList = "agent.list" @@ -293,6 +294,8 @@ const ( ActionGitHubReviewTriggerAll = "github.review_watches.trigger_all" ActionGitHubPRWatchesList = "github.pr_watches.list" ActionGitHubPRWatchDelete = "github.pr_watches.delete" + ActionGitHubPRFilesGet = "github.pr_files.get" + ActionGitHubPRCommitsGet = "github.pr_commits.get" ActionGitHubTaskPRUpdated = "github.task_pr.updated" // Notification ActionGitHubPRFeedbackNotify = "github.pr_feedback.notify" // Notification ActionGitHubNewReviewPRNotify = "github.new_review_pr.notify" // Notification diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index b8a1cc6f24..63221d2440 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -388,7 +388,7 @@ importers: version: 0.55.1 next: specifier: 16.1.1 - version: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -419,6 +419,9 @@ importers: remark-breaks: specifier: ^4.0.0 version: 4.0.0 + remark-gemoji: + specifier: ^8.0.0 + version: 8.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -433,7 +436,7 @@ importers: version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) styled-jsx: specifier: 5.1.6 - version: 5.1.6(react@19.2.3) + version: 5.1.6(@babel/core@7.28.6)(react@19.2.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -4331,6 +4334,9 @@ packages: fzf@0.5.2: resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + gemoji@8.1.0: + resolution: {integrity: sha512-HA4Gx59dw2+tn+UAa7XEV4ufUKI4fH1KgcbenVA9YKSj1QJTT0xh5Mwv5HMFNN3l2OtUe3ZIfuRwSyZS5pLIWw==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -5751,6 +5757,9 @@ packages: remark-breaks@4.0.0: resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + remark-gemoji@8.0.0: + resolution: {integrity: sha512-/fL9rc72FYwFGtOKcT+QeQdx9Q9t5v4N6KLXSDOTEgaedzK85I9judBqB2eqz+g4b0ERMejlwSOuPK+wket6aA==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -10247,8 +10256,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.1 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10270,7 +10279,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10281,22 +10290,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10307,7 +10316,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -10675,6 +10684,8 @@ snapshots: fzf@0.5.2: {} + gemoji@8.1.0: {} + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -11768,7 +11779,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.1 '@swc/helpers': 0.5.15 @@ -11777,7 +11788,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.1 '@next/swc-darwin-x64': 16.1.1 @@ -12407,6 +12418,12 @@ snapshots: mdast-util-newline-to-break: 2.0.0 unified: 11.0.5 + remark-gemoji@8.0.0: + dependencies: + '@types/mdast': 4.0.4 + gemoji: 8.1.0 + mdast-util-find-and-replace: 3.0.2 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -12880,10 +12897,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(react@19.2.3): + styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 + optionalDependencies: + '@babel/core': 7.28.6 stylis@4.3.6: {} diff --git a/apps/web/components/github/pr-checks-section.tsx b/apps/web/components/github/pr-checks-section.tsx index 4a9c7dd20f..87cc49b308 100644 --- a/apps/web/components/github/pr-checks-section.tsx +++ b/apps/web/components/github/pr-checks-section.tsx @@ -27,8 +27,11 @@ function conclusionLabel(check: CheckRun): string | null { } function checkDurationText(check: CheckRun): string | null { - if (check.started_at && check.completed_at) + if (check.started_at && check.completed_at) { + const ms = new Date(check.completed_at).getTime() - new Date(check.started_at).getTime(); + if (ms < 1000) return null; return formatDuration(check.started_at, check.completed_at); + } if (check.started_at && !check.completed_at) return `${formatElapsed(check.started_at)} running`; return null; } @@ -91,33 +94,32 @@ export function ChecksSection({ {checks.map((check) => { const label = conclusionLabel(check); const duration = checkDurationText(check); + const checkKey = `${check.name}-${check.html_url}-${check.source}-${check.started_at ?? ""}`; return ( -
-
- - {check.html_url ? ( - - {check.name} - - ) : ( - {check.name} - )} - {isFailedCheck(check) && ( -
- onAddAsContext(buildCheckMessage(check))} /> -
- )} -
- {(label || duration) && ( -
- {label && {label}} - {label && duration && ·} - {duration && {duration}} +
+ + {check.html_url ? ( + + {check.name} + + ) : ( + {check.name} + )} + {label && {label}} + {duration && ( + {duration} + )} + {isFailedCheck(check) && ( +
+ onAddAsContext(buildCheckMessage(check))} />
)}
diff --git a/apps/web/components/github/pr-comments-section.tsx b/apps/web/components/github/pr-comments-section.tsx index 392c34b836..2ab1f76a0d 100644 --- a/apps/web/components/github/pr-comments-section.tsx +++ b/apps/web/components/github/pr-comments-section.tsx @@ -1,12 +1,7 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import type { PRComment } from "@/lib/types/github"; -import { - CollapsibleSection, - AddToContextButton, - AuthorAvatar, - AuthorLink, - formatTimeAgo, -} from "./pr-shared"; +import { CollapsibleSection, AddToContextButton, FeedbackItemRow } from "./pr-shared"; function buildCommentMessage(comment: PRComment, prUrl: string): string { const location = comment.path @@ -72,6 +67,21 @@ function buildThreads(comments: PRComment[]): CommentThread[] { })); } +function CommentMetaBadge({ comment, isReply }: { comment: PRComment; isReply?: boolean }) { + if (comment.path) { + return ( + + {comment.path} + {comment.line > 0 && `:${comment.line}`} + + ); + } + if (!isReply) { + return (general); + } + return null; +} + function CommentItem({ comment, prUrl, @@ -84,28 +94,15 @@ function CommentItem({ isReply?: boolean; }) { return ( -
-
-
- - - {comment.path && ( - - {comment.path} - {comment.line > 0 && `:${comment.line}`} - - )} - {!comment.path && !isReply && ( - (general) - )} - - {formatTimeAgo(comment.created_at)} - - onAddAsContext(buildCommentMessage(comment, prUrl))} /> -
-

{comment.body}

-
-
+ } + onAddAsContext={() => onAddAsContext(buildCommentMessage(comment, prUrl))} + isReply={isReply} + /> ); } @@ -154,7 +151,17 @@ export function CommentsSection({ prUrl: string; onAddAsContext: (message: string) => void; }) { - const threads = useMemo(() => buildThreads(comments), [comments]); + const [showBotComments, setShowBotComments] = useState(false); + const humanComments = useMemo( + () => comments.filter((comment) => !comment.author_is_bot), + [comments], + ); + const botComments = useMemo( + () => comments.filter((comment) => comment.author_is_bot), + [comments], + ); + const humanThreads = useMemo(() => buildThreads(humanComments), [humanComments]); + const botThreads = useMemo(() => buildThreads(botComments), [botComments]); return ( No comments yet

)} - {threads.map((thread) => ( + {humanThreads.map((thread) => ( ))} + {botComments.length > 0 && ( + + )} + {showBotComments && + botThreads.map((thread) => ( + + ))}
); } diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx index c928a1a91b..d26f17e161 100644 --- a/apps/web/components/github/pr-detail-panel.tsx +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { setPanelTitle } from "@/lib/layout/panel-portal-manager"; import { IconRefresh, @@ -8,6 +8,7 @@ import { IconMinus, IconAlertTriangle, IconGitMerge, + IconCheck, } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; @@ -22,8 +23,15 @@ import { usePRFeedback } from "@/hooks/domains/github/use-pr-feedback"; import { useCommentsStore } from "@/lib/state/slices/comments"; import type { PRFeedbackComment } from "@/lib/state/slices/comments"; import { useToast } from "@/components/toast-provider"; +import { submitPRReview } from "@/lib/api/domains/github-api"; import type { TaskPR, PRFeedback } from "@/lib/types/github"; -import { formatTimeAgo, AuthorLink } from "./pr-shared"; +import { + formatTimeAgo, + AuthorLink, + getTimeAgoColor, + CollapsibleSection, + PRMarkdownBody, +} from "./pr-shared"; import { ReviewStateBadge } from "./pr-reviews-section"; import { ChecksSection } from "./pr-checks-section"; import { ReviewsSection } from "./pr-reviews-section"; @@ -83,15 +91,132 @@ function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) { return { addAsContext }; } +type PRPanelMetrics = { + reviewCount: number; + pendingReviewCount: number; + commentCount: number; + reviewState: TaskPR["review_state"]; +}; + +function computeLiveReviewState(feedback: PRFeedback, fallbackState: TaskPR["review_state"]) { + const requestedReviewers = feedback.pr.requested_reviewers ?? []; + if (feedback.reviews.length === 0) { + return requestedReviewers.length > 0 ? "pending" : fallbackState || ""; + } + const latestByAuthor = new Map(); + for (const review of feedback.reviews) { + const current = latestByAuthor.get(review.author); + const createdAt = new Date(review.created_at).getTime(); + if (!current || createdAt > current.createdAt) { + latestByAuthor.set(review.author, { state: review.state, createdAt }); + } + } + let hasChangesRequested = false; + let allApproved = true; + for (const review of latestByAuthor.values()) { + if (review.state === "CHANGES_REQUESTED") hasChangesRequested = true; + if (review.state !== "APPROVED") allApproved = false; + } + if (hasChangesRequested) return "changes_requested"; + if (allApproved) return "approved"; + return "pending"; +} + +function derivePanelMetrics(taskPR: TaskPR, feedback: PRFeedback | null): PRPanelMetrics { + if (!feedback) { + return { + reviewCount: taskPR.review_count, + pendingReviewCount: taskPR.pending_review_count, + commentCount: taskPR.comment_count, + reviewState: taskPR.review_state, + }; + } + const pendingReviewCount = feedback.pr.requested_reviewers?.length ?? taskPR.pending_review_count; + return { + reviewCount: feedback.reviews.length, + pendingReviewCount, + commentCount: feedback.comments.length, + reviewState: computeLiveReviewState(feedback, taskPR.review_state), + }; +} + // --- Main content --- +function DescriptionSection({ body }: { body: string }) { + if (!body) return null; + return ( + +
+ +
+
+ ); +} + +function ApproveButton({ + taskPR, + feedback, + onRefresh, +}: { + taskPR: TaskPR; + feedback: PRFeedback | null; + onRefresh: () => void; +}) { + const { toast } = useToast(); + const [submitting, setSubmitting] = useState(false); + + if (taskPR.state !== "open") return null; + + const alreadyApproved = feedback?.reviews.some( + (r) => r.state === "APPROVED" && r.author === feedback.pr.author_login, + ); + if (alreadyApproved) return null; + + const handleApprove = async () => { + setSubmitting(true); + try { + await submitPRReview(taskPR.owner, taskPR.repo, taskPR.pr_number, "APPROVE"); + toast({ description: "PR approved", variant: "success" }); + onRefresh(); + } catch (e) { + toast({ + title: "Failed to approve", + description: e instanceof Error ? e.message : "An error occurred", + variant: "error", + }); + } finally { + setSubmitting(false); + } + }; + + return ( + + ); +} + function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: string }) { const { feedback, loading, refresh } = usePRFeedback(taskPR.owner, taskPR.repo, taskPR.pr_number); const { addAsContext } = useAddPRFeedbackAsContext(sessionId, taskPR.pr_number); + const metrics = derivePanelMetrics(taskPR, feedback); return (
- +
@@ -102,12 +227,13 @@ function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: str )} {feedback && ( <> + addAsContext("review", msg)} /> · - opened {formatTimeAgo(taskPR.created_at)} + + opened {formatTimeAgo(taskPR.created_at)} + {taskPR.merged_at && ( <> · @@ -214,7 +342,7 @@ function HeaderDateLine({ taskPR }: { taskPR: TaskPR }) { ); } -function HeaderStatsLine({ taskPR }: { taskPR: TaskPR }) { +function HeaderStatsLine({ taskPR, metrics }: { taskPR: TaskPR; metrics: PRPanelMetrics }) { return (
@@ -227,19 +355,19 @@ function HeaderStatsLine({ taskPR }: { taskPR: TaskPR }) { · - {taskPR.review_count} review{taskPR.review_count !== 1 ? "s" : ""} - {taskPR.pending_review_count > 0 && ( + {metrics.reviewCount} review{metrics.reviewCount !== 1 ? "s" : ""} + {metrics.pendingReviewCount > 0 && ( {" "} - ({taskPR.pending_review_count} pending) + ({metrics.pendingReviewCount} pending) )} · - {taskPR.comment_count} comment{taskPR.comment_count !== 1 ? "s" : ""} + {metrics.commentCount} comment{metrics.commentCount !== 1 ? "s" : ""} - {taskPR.review_state && } + {metrics.reviewState && }
); } @@ -247,11 +375,13 @@ function HeaderStatsLine({ taskPR }: { taskPR: TaskPR }) { function PRHeader({ taskPR, feedback, + metrics, loading, onRefresh, }: { taskPR: TaskPR; feedback: PRFeedback | null; + metrics: PRPanelMetrics; loading: boolean; onRefresh: () => void; }) { @@ -261,13 +391,22 @@ function PRHeader({ return (
- +
+
+ +
+ +
#{taskPR.pr_number} - - {taskPR.head_branch} → {taskPR.base_branch} - + + {taskPR.head_branch} + + → + + {taskPR.base_branch} +
{showWarnings && (
@@ -288,7 +427,7 @@ function PRHeader({
)} - +
); } diff --git a/apps/web/components/github/pr-reviews-section.tsx b/apps/web/components/github/pr-reviews-section.tsx index 8ee53e7232..8ee009edb1 100644 --- a/apps/web/components/github/pr-reviews-section.tsx +++ b/apps/web/components/github/pr-reviews-section.tsx @@ -1,13 +1,7 @@ import { IconCheck, IconX, IconClock } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; -import type { PRReview } from "@/lib/types/github"; -import { - CollapsibleSection, - AddToContextButton, - AuthorAvatar, - AuthorLink, - formatTimeAgo, -} from "./pr-shared"; +import type { PRReview, RequestedReviewer } from "@/lib/types/github"; +import { CollapsibleSection, FeedbackItemRow } from "./pr-shared"; function ReviewStateIcon({ state }: { state: string }) { if (state === "APPROVED") return ; @@ -65,6 +59,11 @@ function buildAllReviewsMessage(reviews: PRReview[], prUrl: string): string { return parts.join("\n"); } +function formatPendingReviewer(reviewer: RequestedReviewer): string { + if (reviewer.type === "team") return `${reviewer.login} (team)`; + return reviewer.login; +} + function formatReviewSummary(reviews: PRReview[]): string { const approved = reviews.filter((r) => r.state === "APPROVED").length; const changes = reviews.filter((r) => r.state === "CHANGES_REQUESTED").length; @@ -76,23 +75,87 @@ function formatReviewSummary(reviews: PRReview[]): string { return parts.join(", "); } +function computeSectionSummary( + reviews: PRReview[], + requestedReviewers: RequestedReviewer[], + pendingReviewCount: number, +) { + const submittedSummary = reviews.length > 0 ? formatReviewSummary(reviews) : ""; + const pendingCount = + requestedReviewers.length > 0 ? requestedReviewers.length : pendingReviewCount; + const summaryParts: string[] = []; + if (submittedSummary) summaryParts.push(submittedSummary); + if (pendingCount > 0) summaryParts.push(`${pendingCount} pending`); + const summary = summaryParts.length > 0 ? ` \u2014 ${summaryParts.join(", ")}` : ""; + return { pendingCount, summary, totalCount: reviews.length + pendingCount }; +} + +function ReviewMetaBadge({ state }: { state: string }) { + return ( + <> + + {reviewStateLabel(state)} + + ); +} + +function SubmittedReviewRow({ + review, + prUrl, + onAddAsContext, +}: { + review: PRReview; + prUrl: string; + onAddAsContext: (message: string) => void; +}) { + const hasActionable = review.state === "CHANGES_REQUESTED" || !!review.body; + return ( + } + onAddAsContext={ + hasActionable ? () => onAddAsContext(buildReviewMessage(review, prUrl)) : undefined + } + /> + ); +} + +function PendingReviewRow({ reviewer }: { reviewer: RequestedReviewer }) { + return ( +
+
+ + {formatPendingReviewer(reviewer)} + Pending review +
+
+ ); +} + export function ReviewsSection({ reviews, + requestedReviewers, prUrl, reviewState, - reviewCount, pendingReviewCount, onAddAsContext, }: { reviews: PRReview[]; + requestedReviewers: RequestedReviewer[]; prUrl: string; reviewState: string; - reviewCount: number; pendingReviewCount: number; onAddAsContext: (message: string) => void; }) { - const summary = reviews.length > 0 ? ` \u2014 ${formatReviewSummary(reviews)}` : ""; - const pendingText = pendingReviewCount > 0 ? ` (${pendingReviewCount} pending)` : ""; + const { pendingCount, summary, totalCount } = computeSectionSummary( + reviews, + requestedReviewers, + pendingReviewCount, + ); + const pendingText = pendingCount > 0 ? ` (${pendingCount} pending)` : ""; const subtitle = reviewState ? (
@@ -104,40 +167,29 @@ export function ReviewsSection({ return ( onAddAsContext(buildAllReviewsMessage(reviews, prUrl))} + onAddAll={ + reviews.length > 0 + ? () => onAddAsContext(buildAllReviewsMessage(reviews, prUrl)) + : undefined + } addAllLabel="Add all reviews to chat context" > - {reviews.length === 0 && ( + {reviews.length === 0 && pendingCount === 0 && (

No reviews yet

)} {reviews.map((review) => ( -
-
- - - - - {reviewStateLabel(review.state)} - - - {formatTimeAgo(review.created_at)} - - {(review.state === "CHANGES_REQUESTED" || review.body) && ( - onAddAsContext(buildReviewMessage(review, prUrl))} - /> - )} -
- {review.body && ( -

{review.body}

- )} -
+ review={review} + prUrl={prUrl} + onAddAsContext={onAddAsContext} + /> + ))} + {requestedReviewers.map((reviewer) => ( + ))}
); diff --git a/apps/web/components/github/pr-shared.tsx b/apps/web/components/github/pr-shared.tsx index 7193a93628..bb45a82699 100644 --- a/apps/web/components/github/pr-shared.tsx +++ b/apps/web/components/github/pr-shared.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; +import ReactMarkdown from "react-markdown"; import { IconMessagePlus, IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; export function formatTimeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); @@ -150,3 +152,78 @@ export function AddToContextButton({ ); } + +export function PRMarkdownBody({ body }: { body: string }) { + return ( +
+ + {body} + +
+ ); +} + +export function getTimeAgoColor(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const hours = diff / (1000 * 60 * 60); + if (hours < 24) return "text-green-600 dark:text-green-400"; + if (hours < 48) return "text-muted-foreground"; + return "text-orange-600 dark:text-orange-400"; +} + +export function ExpandableBody({ body, className }: { body: string; className?: string }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ +
+ +
+ ); +} + +export function FeedbackItemRow({ + author, + authorAvatar, + body, + createdAt, + metaBadge, + onAddAsContext, + isReply, +}: { + author: string; + authorAvatar: string; + body?: string; + createdAt: string; + metaBadge?: React.ReactNode; + onAddAsContext?: () => void; + isReply?: boolean; +}) { + return ( +
+
+
+ + + {metaBadge} + + {formatTimeAgo(createdAt)} + + {onAddAsContext && } +
+ {body && } +
+
+ ); +} diff --git a/apps/web/components/github/pr-topbar-button.tsx b/apps/web/components/github/pr-topbar-button.tsx index 0248acb369..c51533b047 100644 --- a/apps/web/components/github/pr-topbar-button.tsx +++ b/apps/web/components/github/pr-topbar-button.tsx @@ -9,6 +9,12 @@ import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { useAppStore } from "@/components/state-provider"; import type { TaskPR } from "@/lib/types/github"; +function prIconColor(state: string): string { + if (state === "merged") return "text-purple-500"; + if (state === "closed") return "text-red-500"; + return "text-green-600"; +} + function PRStatusIcon({ pr }: { pr: TaskPR }) { if (pr.checks_state === "failure" || pr.review_state === "changes_requested") { return ; @@ -46,7 +52,7 @@ export const PRTopbarButton = memo(function PRTopbarButton() { className="cursor-pointer gap-1.5 px-2" onClick={addPRPanel} > - + #{pr.pr_number} diff --git a/apps/web/components/github/review-watch-dialog.tsx b/apps/web/components/github/review-watch-dialog.tsx index b7764f7aa2..1444019e32 100644 --- a/apps/web/components/github/review-watch-dialog.tsx +++ b/apps/web/components/github/review-watch-dialog.tsx @@ -44,8 +44,8 @@ type ReviewWatchDialogProps = { }; const QUERY_TEMPLATES = { - meAndTeams: "type:pr state:open review-requested:@me", - me: "type:pr state:open user-review-requested:@me", + meAndTeams: "type:pr state:open review-requested:@me -is:draft", + me: "type:pr state:open user-review-requested:@me -is:draft", } as const; type FormState = { @@ -171,7 +171,13 @@ function useWatchFormData(workspaceId: string) { [executors], ); - return { workflows, agentProfiles, allExecutorProfiles }; + // Filter out passthrough/TUI profiles — they don't accept initial prompts + const filteredAgentProfiles = useMemo( + () => agentProfiles.filter((p) => !p.cli_passthrough), + [agentProfiles], + ); + + return { workflows, agentProfiles: filteredAgentProfiles, allExecutorProfiles }; } // --- Section header --- diff --git a/apps/web/components/review/review-dialog.tsx b/apps/web/components/review/review-dialog.tsx index d209f9fd55..84b7a803ac 100644 --- a/apps/web/components/review/review-dialog.tsx +++ b/apps/web/components/review/review-dialog.tsx @@ -4,6 +4,7 @@ import { memo, useMemo, useCallback, createRef, useState, useRef, useEffect } fr import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; import type { DiffComment } from "@/lib/diff/types"; import type { FileInfo, CumulativeDiff } from "@/lib/state/slices/session-runtime/types"; +import type { PRDiffFile } from "@/lib/types/github"; import { useCommentsStore, isDiffComment } from "@/lib/state/slices/comments"; import { useSessionFileReviews } from "@/hooks/use-session-file-reviews"; import { useGitOperations } from "@/hooks/use-git-operations"; @@ -51,11 +52,36 @@ function addCommittedFiles(fileMap: Map, files: CumulativeDi } } +function prFileStatus(status: string): "added" | "deleted" | "modified" { + if (status === "added") return "added"; + if (status === "removed") return "deleted"; + return "modified"; +} + +function addPRFiles(fileMap: Map, files: PRDiffFile[]) { + for (const file of files) { + if (fileMap.has(file.filename)) continue; + const diff = file.patch ? normalizeDiffContent(file.patch) : ""; + if (diff) + fileMap.set(file.filename, { + path: file.filename, + diff, + status: prFileStatus(file.status), + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + staged: false, + source: "pr", + }); + } +} + function buildAllFiles( gitStatusFiles: Record | null, cumulativeDiff: CumulativeDiff | null, + prDiffFiles?: PRDiffFile[], ): ReviewFile[] { const fileMap = new Map(); + if (prDiffFiles) addPRFiles(fileMap, prDiffFiles); if (gitStatusFiles) addUncommittedFiles(fileMap, gitStatusFiles); if (cumulativeDiff?.files) addCommittedFiles(fileMap, cumulativeDiff.files); return Array.from(fileMap.values()).sort((a, b) => a.path.localeCompare(b.path)); @@ -70,6 +96,7 @@ type ReviewDialogProps = { onOpenFile?: (filePath: string) => void; gitStatusFiles: Record | null; cumulativeDiff: CumulativeDiff | null; + prDiffFiles?: PRDiffFile[]; }; function computeReviewSets( @@ -192,7 +219,15 @@ function useReviewDialogHandlers(opts: ReviewDialogHandlerOptions) { } function useReviewDialogState(props: ReviewDialogProps) { - const { open, onOpenChange, sessionId, onSendComments, gitStatusFiles, cumulativeDiff } = props; + const { + open, + onOpenChange, + sessionId, + onSendComments, + gitStatusFiles, + cumulativeDiff, + prDiffFiles, + } = props; const [selectedFile, setSelectedFile] = useState(null); const [splitView, setSplitView] = useState(() => typeof window === "undefined" ? false : localStorage.getItem("diff-view-mode") === "split", @@ -210,8 +245,8 @@ function useReviewDialogState(props: ReviewDialogProps) { const [filter, setFilter] = useState(""); const allFiles = useMemo( - () => buildAllFiles(gitStatusFiles, cumulativeDiff), - [gitStatusFiles, cumulativeDiff], + () => buildAllFiles(gitStatusFiles, cumulativeDiff, prDiffFiles), + [gitStatusFiles, cumulativeDiff, prDiffFiles], ); const filteredFiles = useMemo(() => { if (!filter.trim()) return allFiles; diff --git a/apps/web/components/review/types.ts b/apps/web/components/review/types.ts index 29bdf65f89..36a092308d 100644 --- a/apps/web/components/review/types.ts +++ b/apps/web/components/review/types.ts @@ -5,7 +5,7 @@ export type ReviewFile = { additions: number; deletions: number; staged: boolean; - source: "uncommitted" | "committed"; + source: "uncommitted" | "committed" | "pr"; }; export type FileTreeNode = { diff --git a/apps/web/components/shared/markdown-components.tsx b/apps/web/components/shared/markdown-components.tsx new file mode 100644 index 0000000000..20ef8c4ca2 --- /dev/null +++ b/apps/web/components/shared/markdown-components.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { isValidElement, type ReactNode } from "react"; +import remarkGfm from "remark-gfm"; +import remarkBreaks from "remark-breaks"; +import remarkGemoji from "remark-gemoji"; +import { InlineCode } from "@/components/task/chat/messages/inline-code"; +import { CodeBlock } from "@/components/task/chat/messages/code-block"; + +/** Shared remark plugins used by all markdown renderers */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const remarkPlugins: any[] = [remarkGfm, remarkBreaks, remarkGemoji]; + +/** + * Recursively extracts text content from React children. + * Optimized with fast paths for common cases (string/number). + */ +export function getTextContent(children: ReactNode): string { + if (typeof children === "string") return children; + if (typeof children === "number") return String(children); + if (children == null) return ""; + + if (Array.isArray(children)) { + let result = ""; + for (let i = 0; i < children.length; i++) { + result += getTextContent(children[i]); + } + return result; + } + + if (isValidElement(children)) { + const props = children.props as { children?: ReactNode }; + if (props.children) { + return getTextContent(props.children); + } + } + return ""; +} + +/** + * Shared markdown component overrides for ReactMarkdown. + * Used by both chat messages and PR comment/review rendering. + */ +export const markdownComponents = { + code: ({ className, children }: { className?: string; children?: ReactNode }) => { + const content = getTextContent(children).replace(/\n$/, ""); + const hasLanguage = className?.startsWith("language-"); + const hasNewlines = content.includes("\n"); + if (hasLanguage || hasNewlines) { + return {content}; + } + return {content}; + }, + ol: ({ children }: { children?: ReactNode }) => ( +
    {children}
+ ), + ul: ({ children }: { children?: ReactNode }) => ( +
    {children}
+ ), + li: ({ children }: { children?: ReactNode }) =>
  • {children}
  • , + a: ({ href, children }: { href?: string; children?: ReactNode }) => ( + + {children} + + ), + p: ({ children }: { children?: ReactNode }) =>

    {children}

    , + h1: ({ children }: { children?: ReactNode }) => ( +

    {children}

    + ), + h2: ({ children }: { children?: ReactNode }) => ( +

    {children}

    + ), + h3: ({ children }: { children?: ReactNode }) => ( +

    {children}

    + ), + h4: ({ children }: { children?: ReactNode }) => ( +

    {children}

    + ), + h5: ({ children }: { children?: ReactNode }) => ( +
    {children}
    + ), + hr: ({ children }: { children?: ReactNode }) =>
    {children}, + table: ({ children }: { children?: ReactNode }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }: { children?: ReactNode }) => ( + {children} + ), + tbody: ({ children }: { children?: ReactNode }) => ( + {children} + ), + tr: ({ children }: { children?: ReactNode }) => ( + {children} + ), + th: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + td: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), +}; diff --git a/apps/web/components/task/changes-panel-header.tsx b/apps/web/components/task/changes-panel-header.tsx index 4d6415ce49..f171e0120d 100644 --- a/apps/web/components/task/changes-panel-header.tsx +++ b/apps/web/components/task/changes-panel-header.tsx @@ -106,6 +106,7 @@ function PullDropdown({ export function ChangesPanelHeader({ hasChanges, hasCommits, + hasPRFiles, displayBranch, baseBranchDisplay, behindCount, @@ -117,6 +118,7 @@ export function ChangesPanelHeader({ }: { hasChanges: boolean; hasCommits: boolean; + hasPRFiles?: boolean; displayBranch: string | null; baseBranchDisplay: string; behindCount: number; @@ -126,10 +128,11 @@ export function ChangesPanelHeader({ onPull: () => void; onRebase: () => void; }) { + const showDiffReview = hasChanges || hasCommits || !!hasPRFiles; return ( +
    +
    + + +
    + + ); +} + +// --- PR commits section --- + +type PRCommitItem = { + sha: string; + message: string; + author_login: string; + author_date: string; +}; + +type PRCommitsSectionProps = { + commits: PRCommitItem[]; + isLast: boolean; + onOpenCommitDetail?: (sha: string) => void; +}; + +export function PRCommitsSection({ commits, isLast, onOpenCommitDetail }: PRCommitsSectionProps) { + return ( + +
      + {commits.map((commit, index) => ( +
    • onOpenCommitDetail?.(commit.sha)} + > + + + {commit.sha.slice(0, 7)} + + {commit.message} + {commit.author_login && ( + + {commit.author_login} + + )} +
    • + ))} +
    +
    + ); +} + // --- Review progress bar --- type ReviewProgressBarProps = { diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index 44815ef7d2..c0fcd2e629 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -16,8 +16,15 @@ import { CommitsSection, ActionButtonsSection, ReviewProgressBar, + PRFilesSection, + PRCommitsSection, } from "./changes-panel-timeline"; +import type { PRChangedFile } from "./changes-panel-timeline"; import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks"; +import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { usePRDiff } from "@/hooks/domains/github/use-pr-diff"; +import { usePRCommits } from "@/hooks/domains/github/use-pr-commits"; +import type { PRDiffFile } from "@/lib/types/github"; type ChangesPanelProps = { onOpenDiffFile: (path: string) => void; @@ -53,20 +60,33 @@ type CumulativeDiffFiles = Record< { diff?: string; status?: string; additions?: number; deletions?: number } >; +function addUncommittedPaths(paths: Set, files: FileInfo[]) { + for (const file of files) { + if (file.diff && normalizeDiffContent(file.diff)) paths.add(file.path); + } +} + +function addCumulativePaths(paths: Set, files: CumulativeDiffFiles) { + for (const [path, file] of Object.entries(files)) { + if (!paths.has(path) && file.diff && normalizeDiffContent(file.diff)) paths.add(path); + } +} + +function addPRPaths(paths: Set, files: PRDiffFile[]) { + for (const file of files) { + if (!paths.has(file.filename) && file.patch) paths.add(file.filename); + } +} + function collectReviewPaths( uncommittedFiles: FileInfo[], cumulativeDiffFiles: CumulativeDiffFiles | undefined, + prFiles?: PRDiffFile[], ): Set { const paths = new Set(); - for (const file of uncommittedFiles) { - if (file.diff && normalizeDiffContent(file.diff)) paths.add(file.path); - } - if (cumulativeDiffFiles) { - for (const [path, file] of Object.entries(cumulativeDiffFiles)) { - if (paths.has(path)) continue; - if (file.diff && normalizeDiffContent(file.diff)) paths.add(path); - } - } + addUncommittedPaths(paths, uncommittedFiles); + if (cumulativeDiffFiles) addCumulativePaths(paths, cumulativeDiffFiles); + if (prFiles) addPRPaths(paths, prFiles); return paths; } @@ -74,11 +94,16 @@ function getDiffForPath( path: string, uncommittedFiles: FileInfo[], cumulativeDiffFiles: CumulativeDiffFiles | undefined, + prFiles?: PRDiffFile[], ): string { const uncommitted = uncommittedFiles.find((f) => f.path === path); if (uncommitted?.diff) return normalizeDiffContent(uncommitted.diff); const cumDiff = cumulativeDiffFiles?.[path]?.diff; if (cumDiff) return normalizeDiffContent(cumDiff); + if (prFiles) { + const prFile = prFiles.find((f) => f.filename === path); + if (prFile?.patch) return normalizeDiffContent(prFile.patch); + } return ""; } @@ -86,14 +111,15 @@ function computeReviewProgress( uncommittedFiles: FileInfo[], cumulativeDiff: { files?: CumulativeDiffFiles } | null, reviews: Map, + prFiles?: PRDiffFile[], ) { const cumulativeDiffFiles = cumulativeDiff?.files; - const paths = collectReviewPaths(uncommittedFiles, cumulativeDiffFiles); + const paths = collectReviewPaths(uncommittedFiles, cumulativeDiffFiles, prFiles); let reviewed = 0; for (const path of paths) { const state = reviews.get(path); if (!state?.reviewed) continue; - const diffContent = getDiffForPath(path, uncommittedFiles, cumulativeDiffFiles); + const diffContent = getDiffForPath(path, uncommittedFiles, cumulativeDiffFiles, prFiles); if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) continue; reviewed++; } @@ -114,6 +140,32 @@ function computeStagedStats(stagedFiles: FileInfo[]) { }; } +function mapPRFilesToChangedFiles(files: PRDiffFile[]): PRChangedFile[] { + return files.map((file) => { + let status: FileInfo["status"]; + switch (file.status) { + case "added": + status = "added"; + break; + case "removed": + status = "deleted"; + break; + case "renamed": + status = "renamed"; + break; + default: + status = "modified"; + } + return { + path: file.filename, + status, + plus: file.additions, + minus: file.deletions, + oldPath: file.old_path, + }; + }); +} + function getBaseBranchDisplay(baseBranch: string | undefined): string { return baseBranch ? baseBranch.replace(/^origin\//, "") : "main"; } @@ -140,11 +192,15 @@ type ChangesPanelBodyProps = { hasUnstaged: boolean; hasStaged: boolean; hasCommits: boolean; + hasPRFiles: boolean; + hasPRCommits: boolean; canPush: boolean; canCreatePR: boolean; existingPrUrl: string | undefined; unstagedFiles: ChangedFile[]; stagedFiles: ChangedFile[]; + prFiles: PRChangedFile[]; + prCommits: { sha: string; message: string; author_login: string; author_date: string }[]; commits: ReturnType["commits"]; pendingStageFiles: Set; reviewedCount: number; @@ -224,46 +280,43 @@ function ChangesPanelDialogsSection({ ); } -function ChangesPanelTimeline( - props: Pick< - ChangesPanelBodyProps, - | "hasAnything" - | "hasUnstaged" - | "hasStaged" - | "hasCommits" - | "canPush" - | "canCreatePR" - | "existingPrUrl" - | "unstagedFiles" - | "stagedFiles" - | "commits" - | "pendingStageFiles" - | "aheadCount" - | "isLoading" - | "dialogs" - | "onOpenDiffFile" - | "onEditFile" - | "onOpenCommitDetail" - | "onRevertCommit" - | "onStageAll" - | "onStage" - | "onUnstage" - | "onPush" - | "onForcePush" - >, -) { - if (!props.hasAnything) { - return ( -
    - Your changed files will appear here -
    - ); - } +type TimelineProps = Pick< + ChangesPanelBodyProps, + | "hasAnything" + | "hasUnstaged" + | "hasStaged" + | "hasCommits" + | "hasPRFiles" + | "hasPRCommits" + | "canPush" + | "canCreatePR" + | "existingPrUrl" + | "unstagedFiles" + | "stagedFiles" + | "prFiles" + | "prCommits" + | "commits" + | "pendingStageFiles" + | "aheadCount" + | "isLoading" + | "dialogs" + | "onOpenDiffFile" + | "onEditFile" + | "onOpenCommitDetail" + | "onRevertCommit" + | "onStageAll" + | "onStage" + | "onUnstage" + | "onPush" + | "onForcePush" +>; + +function TimelineLocalChanges(props: TimelineProps) { const showStaged = props.hasUnstaged || props.hasStaged; const showCommits = props.hasStaged || props.hasCommits; return ( -
    + <> {props.hasUnstaged && ( )} + + ); +} + +function ChangesPanelTimeline(props: TimelineProps) { + if (!props.hasAnything) { + return ( +
    + Your changed files will appear here +
    + ); + } + + const hasLocalChanges = props.hasUnstaged || props.hasStaged || props.hasCommits; + + return ( +
    + {props.hasPRFiles && ( + + )} + {props.hasPRCommits && ( + + )} +
    ); } @@ -342,6 +427,24 @@ function ChangesPanelBody(props: ChangesPanelBodyProps) { ); } +function useChangesPanelPRData() { + const taskPR = useActiveTaskPR(); + const { files: prDiffFiles } = usePRDiff( + taskPR?.owner ?? null, + taskPR?.repo ?? null, + taskPR?.pr_number ?? null, + ); + const { commits: prCommitsList } = usePRCommits( + taskPR?.owner ?? null, + taskPR?.repo ?? null, + taskPR?.pr_number ?? null, + ); + const hasPRFiles = prDiffFiles.length > 0; + const hasPRCommits = prCommitsList.length > 0; + const prFiles = useMemo(() => mapPRFilesToChangedFiles(prDiffFiles), [prDiffFiles]); + return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles }; +} + const ChangesPanel = memo(function ChangesPanel({ onOpenDiffFile, onEditFile, @@ -355,29 +458,26 @@ const ChangesPanel = memo(function ChangesPanel({ const git = useSessionGit(activeSessionId); const { toast } = useToast(); const { reviews } = useSessionFileReviews(activeSessionId); + const { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles } = useChangesPanelPRData(); const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]); const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]); const stagedFiles = useMemo(() => mapToChangedFiles(git.stagedFiles), [git.stagedFiles]); const { reviewedCount, totalFileCount } = useMemo( - () => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews), - [git.allFiles, git.cumulativeDiff, reviews], - ); - const { stagedFileCount, stagedAdditions, stagedDeletions } = useMemo( - () => computeStagedStats(git.stagedFiles), - [git.stagedFiles], + () => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prDiffFiles), + [git.allFiles, git.cumulativeDiff, reviews, prDiffFiles], ); + const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]); - const { - handleGitOperation, - handlePull, - handleRebase, - handlePush, - handleForcePush, - handleRevertCommit, - } = useChangesGitHandlers(git, toast, baseBranch); - const dialogs = useChangesDialogHandlers(git, toast, handleGitOperation, taskTitle, baseBranch); + const gitHandlers = useChangesGitHandlers(git, toast, baseBranch); + const dialogs = useChangesDialogHandlers( + git, + toast, + gitHandlers.handleGitOperation, + taskTitle, + baseBranch, + ); if (isArchived) return ; @@ -386,25 +486,30 @@ const ChangesPanel = memo(function ChangesPanel({ git.stage([path]).then(() => undefined)} onUnstage={(path) => git.unstage([path]).then(() => undefined)} - onPush={handlePush} - onForcePush={handleForcePush} - stagedFileCount={stagedFileCount} - stagedAdditions={stagedAdditions} - stagedDeletions={stagedDeletions} + onPush={gitHandlers.handlePush} + onForcePush={gitHandlers.handleForcePush} + stagedFileCount={staged.stagedFileCount} + stagedAdditions={staged.stagedAdditions} + stagedDeletions={staged.stagedDeletions} displayBranch={git.branch} baseBranch={baseBranch} /> diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index d0f7302ead..ecb70e34f6 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -1,8 +1,11 @@ "use client"; -import { memo, type ReactElement } from "react"; +import { memo, useState, useCallback, type ReactElement } from "react"; +import { IconPlayerPlay } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; import type { Message, TaskSessionState } from "@/lib/types/http"; import type { ToolCallMetadata } from "@/components/task/chat/types"; +import { getWebSocketClient } from "@/lib/ws/connection"; import { ChatMessage } from "@/components/task/chat/messages/chat-message"; import { PermissionRequestMessage } from "@/components/task/chat/messages/permission-request-message"; import { StatusMessage } from "@/components/task/chat/messages/status-message"; @@ -30,6 +33,37 @@ type AdapterContext = { onScrollToMessage?: (messageId: string) => void; }; +function TaskDescriptionStartButton({ taskId, sessionId }: { taskId: string; sessionId: string }) { + const [isStarting, setIsStarting] = useState(false); + const handleStart = useCallback(async () => { + const client = getWebSocketClient(); + if (!client) return; + setIsStarting(true); + try { + await client.request("orchestrator.start", { task_id: taskId, session_id: sessionId }, 15000); + } catch (error) { + console.error("Failed to start agent:", error); + } finally { + setIsStarting(false); + } + }, [taskId, sessionId]); + + return ( +
    + +
    + ); +} + type MessageAdapter = { matches: (comment: Message, ctx: AdapterContext) => boolean; render: (comment: Message, ctx: AdapterContext) => ReactElement; @@ -160,14 +194,21 @@ const adapters: MessageAdapter[] = [ comment.author_type === "user" || (ctx.isTaskDescription && ctx.sessionState !== "FAILED") ) { + const showStartButton = + ctx.isTaskDescription && ctx.sessionState === "CREATED" && ctx.taskId && ctx.sessionId; return ( - + <> + + {showStartButton && ( + + )} + ); } return ( diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index 968d92a674..dfdb4e4f4f 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -1,45 +1,14 @@ "use client"; -import { memo, isValidElement, useState, useCallback, type ReactNode } from "react"; +import { memo, useState, useCallback } from "react"; import ReactMarkdown from "react-markdown"; -import remarkBreaks from "remark-breaks"; -import remarkGfm from "remark-gfm"; import { IconWand, IconMessageDots, IconFile } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import type { Message } from "@/lib/types/http"; import { RichBlocks } from "@/components/task/chat/messages/rich-blocks"; -import { InlineCode } from "@/components/task/chat/messages/inline-code"; -import { CodeBlock } from "@/components/task/chat/messages/code-block"; import { MessageActions } from "@/components/task/chat/messages/message-actions"; import { useMessageNavigation } from "@/hooks/use-message-navigation"; - -/** - * Recursively extracts text content from React children. - * Optimized with fast paths for common cases (string/number). - */ -function getTextContent(children: ReactNode): string { - // Fast path: most common cases first - if (typeof children === "string") return children; - if (typeof children === "number") return String(children); - if (children == null) return ""; - - // Use loop instead of map().join() to avoid intermediate array allocation - if (Array.isArray(children)) { - let result = ""; - for (let i = 0; i < children.length; i++) { - result += getTextContent(children[i]); - } - return result; - } - - if (isValidElement(children)) { - const props = children.props as { children?: ReactNode }; - if (props.children) { - return getTextContent(props.children); - } - } - return ""; -} +import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; type ChatMessageProps = { comment: Message; @@ -92,69 +61,7 @@ function renderContentWithFileRefs(content: string): React.ReactNode[] { return parts.length > 0 ? parts : [content]; } -// ── Markdown component overrides (stable reference) ───────────────── - -const markdownComponents = { - code: ({ className, children }: { className?: string; children?: ReactNode }) => { - const content = getTextContent(children).replace(/\n$/, ""); - const hasLanguage = className?.startsWith("language-"); - const hasNewlines = content.includes("\n"); - if (hasLanguage || hasNewlines) { - return {content}; - } - return {content}; - }, - ol: ({ children }: { children?: ReactNode }) => ( -
      {children}
    - ), - ul: ({ children }: { children?: ReactNode }) => ( -
      {children}
    - ), - li: ({ children }: { children?: ReactNode }) =>
  • {children}
  • , - p: ({ children }: { children?: ReactNode }) =>

    {children}

    , - h1: ({ children }: { children?: ReactNode }) => ( -

    {children}

    - ), - h2: ({ children }: { children?: ReactNode }) => ( -

    {children}

    - ), - h3: ({ children }: { children?: ReactNode }) => ( -

    {children}

    - ), - h4: ({ children }: { children?: ReactNode }) => ( -

    {children}

    - ), - h5: ({ children }: { children?: ReactNode }) => ( -
    {children}
    - ), - hr: ({ children }: { children?: ReactNode }) =>
    {children}, - table: ({ children }: { children?: ReactNode }) => ( -
    - - {children} -
    -
    - ), - thead: ({ children }: { children?: ReactNode }) => ( - {children} - ), - tbody: ({ children }: { children?: ReactNode }) => ( - {children} - ), - tr: ({ children }: { children?: ReactNode }) => ( - {children} - ), - th: ({ children }: { children?: ReactNode }) => ( - - {children} - - ), - td: ({ children }: { children?: ReactNode }) => ( - - {children} - - ), -}; +// ── Markdown component overrides imported from shared/markdown-components ───── function renderUserMessageBody( hasContent: boolean, @@ -352,10 +259,7 @@ function AgentMessageContent({ comment, showRaw, onToggleRaw, showRichBlocks }: ) : (
    - + {comment.content || "(empty)"} {showRichBlocks ? : null} diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 580146febc..47ea8d9dcf 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -41,6 +41,8 @@ import { PRDetailPanelComponent } from "@/components/github/pr-detail-panel"; import { PreviewController } from "./preview/preview-controller"; import { ReviewDialog } from "@/components/review/review-dialog"; import { useCumulativeDiff } from "@/hooks/domains/session/use-cumulative-diff"; +import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { usePRDiff } from "@/hooks/domains/github/use-pr-diff"; import { formatReviewCommentsAsMarkdown } from "@/components/task/chat/messages/review-comments-attachment"; import { stopVscode } from "@/lib/api/domains/vscode-api"; import { getWebSocketClient } from "@/lib/ws/connection"; @@ -537,6 +539,12 @@ function useReviewDialog(effectiveSessionId: string | null) { const reviewGitStatus = useSessionGitStatus(effectiveSessionId); const { diff: reviewCumulativeDiff } = useCumulativeDiff(effectiveSessionId); const { openFile: reviewOpenFile } = useFileEditors(); + const reviewTaskPR = useActiveTaskPR(); + const { files: reviewPRDiffFiles } = usePRDiff( + reviewTaskPR?.owner ?? null, + reviewTaskPR?.repo ?? null, + reviewTaskPR?.pr_number ?? null, + ); const handleReviewSendComments = useCallback( (comments: DiffComment[]) => { @@ -574,6 +582,7 @@ function useReviewDialog(effectiveSessionId: string | null) { baseBranch, reviewGitStatus, reviewCumulativeDiff, + reviewPRDiffFiles, reviewOpenFile, handleReviewSendComments, }; @@ -725,6 +734,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ onOpenFile={review.reviewOpenFile} gitStatusFiles={review.reviewGitStatus?.files ?? null} cumulativeDiff={review.reviewCumulativeDiff} + prDiffFiles={review.reviewPRDiffFiles} /> )}
    diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index 79e349e017..e370f614a1 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -6,6 +6,8 @@ import { useToast } from "@/components/toast-provider"; import { useAppStore } from "@/components/state-provider"; import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; import { useCumulativeDiff } from "@/hooks/domains/session/use-cumulative-diff"; +import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { usePRDiff } from "@/hooks/domains/github/use-pr-diff"; import { useGitOperations } from "@/hooks/use-git-operations"; import { useSessionFileReviews } from "@/hooks/use-session-file-reviews"; import { useCommentsStore, isDiffComment } from "@/lib/state/slices/comments"; @@ -15,6 +17,7 @@ import { formatReviewCommentsAsMarkdown } from "@/lib/state/slices/comments/form import { ReviewDiffList } from "@/components/review/review-diff-list"; import type { ReviewFile } from "@/components/review/types"; import { hashDiff, normalizeDiffContent } from "@/components/review/types"; +import type { PRDiffFile } from "@/lib/types/github"; import { usePanelActions } from "@/hooks/use-panel-actions"; import { ChangesTopBar } from "./changes-top-bar"; import type { SelectedDiff } from "./task-layout"; @@ -80,12 +83,37 @@ function addCumulativeFiles( } } -/** Merge uncommitted + committed files into a single sorted list */ +function prFileStatus(status: string): "added" | "deleted" | "modified" { + if (status === "added") return "added"; + if (status === "removed") return "deleted"; + return "modified"; +} + +function addPRFiles(fileMap: Map, files: PRDiffFile[]) { + for (const file of files) { + if (fileMap.has(file.filename)) continue; + const diff = file.patch ? normalizeDiffContent(file.patch) : ""; + if (diff) + fileMap.set(file.filename, { + path: file.filename, + diff, + status: prFileStatus(file.status), + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + staged: false, + source: "pr", + }); + } +} + +/** Merge PR + uncommitted + committed files into a single sorted list */ function mergeReviewFiles( gitStatus: ReturnType, cumulativeDiff: { files?: Record } | null, + prDiffFiles?: PRDiffFile[], ): ReviewFile[] { const fileMap = new Map(); + if (prDiffFiles) addPRFiles(fileMap, prDiffFiles); if (gitStatus?.files) addUncommittedFiles(fileMap, gitStatus.files as Record); if (cumulativeDiff?.files) addCumulativeFiles(fileMap, cumulativeDiff.files); @@ -96,6 +124,12 @@ function useChangesData(selectedDiff: SelectedDiff | null, onClearSelected: () = const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff(activeSessionId); + const pr = useActiveTaskPR(); + const { files: prDiffFiles } = usePRDiff( + pr?.owner ?? null, + pr?.repo ?? null, + pr?.pr_number ?? null, + ); const { reviews } = useSessionFileReviews(activeSessionId); const byId = useCommentsStore((s) => s.byId); const commentSessionIds = useCommentsStore((s) => @@ -103,8 +137,9 @@ function useChangesData(selectedDiff: SelectedDiff | null, onClearSelected: () = ); const allFiles = useMemo( - () => mergeReviewFiles(gitStatus, cumulativeDiff), - [gitStatus, cumulativeDiff], + () => + mergeReviewFiles(gitStatus, cumulativeDiff, prDiffFiles.length > 0 ? prDiffFiles : undefined), + [gitStatus, cumulativeDiff, prDiffFiles], ); const { reviewedFiles, staleFiles } = useMemo(() => { diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 6b004abe3e..c87763b62b 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -90,7 +90,7 @@ const TaskTopBar = memo(function TaskTopBar({ const displayBranch = worktreeBranch || baseBranch; return ( -
    +
    -
    - - {displayBranch} +
    + + {displayBranch} { @@ -296,10 +296,10 @@ function TopBarLeft({ remoteStatusError?: string | null; }) { return ( -
    - - - +
    + + + {repositoryName && ( <> - - - {repositoryName} + + + + + {repositoryName} + + {repositoryName} + )} - - - {taskTitle ?? "Task details"} + + + + + + {taskTitle ?? "Task details"} + + + {taskTitle ?? "Task details"} + - +
    + +
    +
    {sortedSteps.map((step, index) => ( void, +) { + const client = getWebSocketClient(); + if (!client) return; + + setState({ commits: [], loading: true, error: null }); + try { + const response = await client.request<{ commits?: PRCommitInfo[] }>("github.pr_commits.get", { + owner, + repo, + number: prNumber, + }); + setState({ commits: response?.commits ?? [], loading: false, error: null }); + } catch (err) { + setState({ + commits: [], + loading: false, + error: err instanceof Error ? err.message : "Failed to fetch PR commits", + }); + } +} + +/** + * Fetches the commits in a pull request via WebSocket. + * Returns commit metadata from the GitHub API. + */ +export function usePRCommits(owner: string | null, repo: string | null, prNumber: number | null) { + const [state, setState] = useState(INITIAL_STATE); + + const refresh = useCallback(() => { + if (!owner || !repo || !prNumber) return; + void fetchPRCommits(owner, repo, prNumber, setState); + }, [owner, repo, prNumber]); + + useEffect(() => { + if (!owner || !repo || !prNumber) return; + void fetchPRCommits(owner, repo, prNumber, setState); + }, [owner, repo, prNumber]); + + return { ...state, refresh }; +} diff --git a/apps/web/hooks/domains/github/use-pr-diff.ts b/apps/web/hooks/domains/github/use-pr-diff.ts new file mode 100644 index 0000000000..9bd4f9679f --- /dev/null +++ b/apps/web/hooks/domains/github/use-pr-diff.ts @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useCallback, useState } from "react"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import type { PRDiffFile } from "@/lib/types/github"; + +type PRDiffState = { + files: PRDiffFile[]; + loading: boolean; + error: string | null; +}; + +const INITIAL_STATE: PRDiffState = { + files: [], + loading: false, + error: null, +}; + +async function fetchPRFiles( + owner: string, + repo: string, + prNumber: number, + setState: (s: PRDiffState) => void, +) { + const client = getWebSocketClient(); + if (!client) return; + + setState({ files: [], loading: true, error: null }); + try { + const response = await client.request<{ files?: PRDiffFile[] }>("github.pr_files.get", { + owner, + repo, + number: prNumber, + }); + setState({ files: response?.files ?? [], loading: false, error: null }); + } catch (err) { + setState({ + files: [], + loading: false, + error: err instanceof Error ? err.message : "Failed to fetch PR files", + }); + } +} + +/** + * Fetches the files changed in a pull request via WebSocket. + * Returns structured diff data from the GitHub API with full unified diff patches. + */ +export function usePRDiff(owner: string | null, repo: string | null, prNumber: number | null) { + const [state, setState] = useState(INITIAL_STATE); + + const refresh = useCallback(() => { + if (!owner || !repo || !prNumber) return; + void fetchPRFiles(owner, repo, prNumber, setState); + }, [owner, repo, prNumber]); + + useEffect(() => { + if (!owner || !repo || !prNumber) return; + void fetchPRFiles(owner, repo, prNumber, setState); + }, [owner, repo, prNumber]); + + return { ...state, refresh }; +} diff --git a/apps/web/hooks/domains/kanban/use-kanban-navigation.ts b/apps/web/hooks/domains/kanban/use-kanban-navigation.ts index b485c1e3e3..b98a8678ae 100644 --- a/apps/web/hooks/domains/kanban/use-kanban-navigation.ts +++ b/apps/web/hooks/domains/kanban/use-kanban-navigation.ts @@ -4,6 +4,7 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; import { getWebSocketClient } from "@/lib/ws/connection"; import { linkToSession } from "@/lib/links"; +import { useAppStore } from "@/components/state-provider"; import type { Task } from "@/components/kanban-card"; type NavigationOptions = { @@ -16,6 +17,42 @@ type NavigationOptions = { setTaskSessionAvailability: React.Dispatch>>; }; +async function fetchLatestSession( + taskId: string, + setAvailability: React.Dispatch>>, +): Promise { + const client = getWebSocketClient(); + if (!client) return null; + try { + const response = await client.request<{ sessions: Array<{ id: string }> }>( + "task.session.list", + { task_id: taskId }, + 10000, + ); + setAvailability((prev) => ({ ...prev, [taskId]: response.sessions.length > 0 })); + return response.sessions[0]?.id ?? null; + } catch (error) { + console.error("Failed to load task sessions:", error); + return null; + } +} + +async function preparePRSession(taskId: string): Promise { + const client = getWebSocketClient(); + if (!client) return null; + try { + const response = await client.request<{ session_id: string }>( + "task.session.prepare", + { task_id: taskId }, + 15000, + ); + return response.session_id ?? null; + } catch (error) { + console.error("Failed to prepare session for PR task:", error); + return null; + } +} + export function useKanbanNavigation({ enablePreviewOnClick, isMobile, @@ -26,91 +63,53 @@ export function useKanbanNavigation({ setTaskSessionAvailability, }: NavigationOptions) { const router = useRouter(); + const taskPRs = useAppStore((s) => s.taskPRs.byTaskId); - // Fetch latest session ID for a task const fetchLatestSessionId = useCallback( - async (taskId: string) => { - const client = getWebSocketClient(); - if (!client) return null; - try { - const response = await client.request<{ sessions: Array<{ id: string }> }>( - "task.session.list", - { task_id: taskId }, - 10000, - ); - setTaskSessionAvailability((prev) => ({ - ...prev, - [taskId]: response.sessions.length > 0, - })); - return response.sessions[0]?.id ?? null; - } catch (error) { - console.error("Failed to load task sessions:", error); - return null; - } - }, + (taskId: string) => fetchLatestSession(taskId, setTaskSessionAvailability), [setTaskSessionAvailability], ); - // Open task (always navigates, opens dialog if no session) - const handleOpenTask = useCallback( - async (task: Task) => { - const latestSessionId = await fetchLatestSessionId(task.id); - if (!latestSessionId) { - setEditingTask(task); - setIsDialogOpen(true); - return; - } - if (onOpenTask) { - onOpenTask(task, latestSessionId); - } else { - router.push(linkToSession(latestSessionId)); - } + const navigateToSession = useCallback( + (task: Task, sessionId: string) => { + if (onOpenTask) onOpenTask(task, sessionId); + else router.push(linkToSession(sessionId)); }, - [fetchLatestSessionId, onOpenTask, router, setEditingTask, setIsDialogOpen], + [onOpenTask, router], + ); + + const handleNoSession = useCallback( + async (task: Task) => { + if (taskPRs[task.id]) { + const sessionId = await preparePRSession(task.id); + if (sessionId) { + router.push(linkToSession(sessionId)); + return; + } + } + setEditingTask(task); + setIsDialogOpen(true); + }, + [taskPRs, router, setEditingTask, setIsDialogOpen], + ); + + const handleOpenTask = useCallback( + async (task: Task) => { + const sessionId = await fetchLatestSessionId(task.id); + if (!sessionId) return handleNoSession(task); + navigateToSession(task, sessionId); + }, + [fetchLatestSessionId, handleNoSession, navigateToSession], ); - // Handle card click (preview or navigate based on settings) const handleCardClick = useCallback( async (task: Task) => { - // On mobile, always navigate to task (no preview panel) - if (isMobile) { - const latestSessionId = await fetchLatestSessionId(task.id); - if (!latestSessionId) { - setEditingTask(task); - setIsDialogOpen(true); - return; - } - - if (onOpenTask) { - onOpenTask(task, latestSessionId); - } else { - router.push(linkToSession(latestSessionId)); - } - return; - } - - // Desktop/tablet: preview or navigate based on settings - const shouldOpenPreview = enablePreviewOnClick; - - if (shouldOpenPreview) { - // Preview mode - just call the preview handler without fetching session - if (onPreviewTask) { - onPreviewTask(task); - } - } else { - // Navigate mode - fetch session and navigate - const latestSessionId = await fetchLatestSessionId(task.id); - if (!latestSessionId) { - setEditingTask(task); - setIsDialogOpen(true); - return; - } - - if (onOpenTask) { - onOpenTask(task, latestSessionId); - } else { - router.push(linkToSession(latestSessionId)); - } + if (isMobile || !enablePreviewOnClick) { + const sessionId = await fetchLatestSessionId(task.id); + if (!sessionId) return handleNoSession(task); + navigateToSession(task, sessionId); + } else if (onPreviewTask) { + onPreviewTask(task); } }, [ @@ -118,16 +117,10 @@ export function useKanbanNavigation({ enablePreviewOnClick, onPreviewTask, fetchLatestSessionId, - onOpenTask, - router, - setEditingTask, - setIsDialogOpen, + handleNoSession, + navigateToSession, ], ); - return { - handleOpenTask, - handleCardClick, - fetchLatestSessionId, - }; + return { handleOpenTask, handleCardClick, fetchLatestSessionId }; } diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts index a4dc0b0210..5e12b5c4ca 100644 --- a/apps/web/lib/api/domains/github-api.ts +++ b/apps/web/lib/api/domains/github-api.ts @@ -41,6 +41,25 @@ export async function getPRFeedback( return fetchJson(`/api/v1/github/prs/${owner}/${repo}/${number}`, options); } +// Submit PR review +export async function submitPRReview( + owner: string, + repo: string, + number: number, + event: "APPROVE" | "COMMENT" | "REQUEST_CHANGES", + body?: string, +) { + return fetchJson<{ submitted: boolean }>( + `/api/v1/github/prs/${owner}/${repo}/${number}/reviews`, + { + init: { + method: "POST", + body: JSON.stringify({ event, body: body ?? "" }), + }, + }, + ); +} + // PR watches export async function listPRWatches(options?: ApiRequestOptions) { return fetchJson("/api/v1/github/watches/pr", options); diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts index c3efe44c23..b391b29535 100644 --- a/apps/web/lib/types/github.ts +++ b/apps/web/lib/types/github.ts @@ -11,6 +11,7 @@ export type GitHubStatus = { export type GitHubPR = { number: number; title: string; + body?: string; url: string; html_url: string; state: "open" | "closed" | "merged"; @@ -23,12 +24,18 @@ export type GitHubPR = { mergeable: boolean; additions: number; deletions: number; + requested_reviewers: RequestedReviewer[]; created_at: string; updated_at: string; merged_at: string | null; closed_at: string | null; }; +export type RequestedReviewer = { + login: string; + type: "user" | "team"; +}; + export type PRReview = { id: number; author: string; @@ -42,10 +49,12 @@ export type PRComment = { id: number; author: string; author_avatar: string; + author_is_bot: boolean; body: string; path: string; line: number; side: string; + comment_type: "review" | "issue"; created_at: string; updated_at: string; in_reply_to: number | null; @@ -53,6 +62,7 @@ export type PRComment = { export type CheckRun = { name: string; + source: "check_run" | "status_context"; status: string; conclusion: string; html_url: string; @@ -199,3 +209,24 @@ export type CreateReviewWatchRequest = { }; export type UpdateReviewWatchRequest = Partial>; + +// PR diff file (from GitHub API) +export type PRDiffFile = { + filename: string; + status: string; // added, removed, modified, renamed, copied, changed, unchanged + additions: number; + deletions: number; + patch: string; + old_path?: string; +}; + +// PR commit info (from GitHub API) +export type PRCommitInfo = { + sha: string; + message: string; + author_login: string; + author_date: string; + additions: number; + deletions: number; + files_changed: number; +}; diff --git a/apps/web/package.json b/apps/web/package.json index c075143fe9..c120c9a9ec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -95,6 +95,7 @@ "react-virtuoso": "^4.18.1", "recharts": "2.15.4", "remark-breaks": "^4.0.0", + "remark-gemoji": "^8.0.0", "remark-gfm": "^4.0.1", "shadcn": "^3.6.3", "shiki": "^3.22.0", diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index d22e699c0d..6571964f8b 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,6 +1,14 @@ +import path from "path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + "@kandev/ui": path.resolve(__dirname, "../packages/ui/src"), + "@kandev/theme": path.resolve(__dirname, "../packages/theme/src"), + }, + }, test: { environment: "happy-dom", },