From 72e1eb1fda68565a83ccf37c48eccb8f2d7c941d Mon Sep 17 00:00:00 2001 From: Pablu Date: Tue, 28 Jul 2026 12:02:33 +0200 Subject: [PATCH] Show reactions to threads --- README.md | 4 +-- github.go | 32 ++++++++++++++++++++++- github_test.go | 12 ++++++++- tui.go | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ tui_test.go | 55 ++++++++++++++++++++++++++++++++++++++++ types.go | 7 +++++ 6 files changed, 175 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8c9a74d..a62ae22 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ review state, merge conflicts and affected files, checks, people, labels, milestone, activity, change statistics, thread totals, submitted reviews, and the PR conversation. Review threads and comments are paginated rather than silently stopping at the first page. The -thread viewer includes highlighted diff hunks and comment authors. Resolved -threads start folded. GitHub suggestion blocks are shown as +thread viewer includes highlighted diff hunks, comment authors, and read-only +reaction counts on individual comments. Resolved threads start folded. GitHub suggestion blocks are shown as syntax-highlighted remove/add previews. Comments and PR descriptions render GitHub Flavored Markdown, including quoted replies, inline and fenced code, lists and tasks, links, tables, emphasis, strikethrough, emoji, and GitHub diff --git a/github.go b/github.go index ac8c8be..11660e0 100644 --- a/github.go +++ b/github.go @@ -153,6 +153,10 @@ query ReviewThreadsPage($owner: String!, $name: String!, $number: Int!, $after: line startLine originalLine originalStartLine originalCommit { oid } author { login } + reactionGroups { + content viewerHasReacted + reactors { totalCount } + } } } } @@ -172,6 +176,10 @@ query ReviewCommentsPage($id: ID!, $after: String) { line startLine originalLine originalStartLine originalCommit { oid } author { login } + reactionGroups { + content viewerHasReacted + reactors { totalCount } + } } } } @@ -374,6 +382,10 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { line startLine originalLine originalStartLine originalCommit { oid } author { login } + reactionGroups { + content viewerHasReacted + reactors { totalCount } + } } } } @@ -484,6 +496,15 @@ type githubReviewComment struct { OriginalCommit *struct{ OID string } CreatedAt time.Time Author *githubActor + ReactionGroups []githubReactionGroup +} + +type githubReactionGroup struct { + Content string + ViewerHasReacted bool + Reactors struct { + TotalCount int + } } type githubReviewCommentConnection struct { @@ -1196,13 +1217,22 @@ func convertReviewThread(thread githubReviewThread) ReviewThread { } func convertReviewComment(comment githubReviewComment) ReviewComment { - return ReviewComment{ + item := ReviewComment{ ID: comment.ID, Author: actorLogin(comment.Author), Body: comment.Body, DiffHunk: comment.DiffHunk, CreatedAt: comment.CreatedAt, URL: comment.URL, Line: intValue(comment.Line), StartLine: intValue(comment.StartLine), OriginalLine: intValue(comment.OriginalLine), OriginalStartLine: intValue(comment.OriginalStartLine), OriginalCommitOID: commitOID(comment.OriginalCommit), Outdated: comment.Outdated, } + for _, group := range comment.ReactionGroups { + if group.Reactors.TotalCount > 0 { + item.Reactions = append(item.Reactions, ReactionSummary{ + Content: group.Content, Count: group.Reactors.TotalCount, + ViewerHasReacted: group.ViewerHasReacted, + }) + } + } + return item } func actorLogin(actor *githubActor) string { diff --git a/github_test.go b/github_test.go index 813c584..55b063e 100644 --- a/github_test.go +++ b/github_test.go @@ -338,7 +338,12 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) { "id":"c","body":"change this","diffHunk":"@@ -1 +1 @@","createdAt":"2026-01-01T00:00:00Z", "url":"cu","author":{"login":"reviewer"},"outdated":true, "line":100,"startLine":99,"originalLine":42,"originalStartLine":40, - "originalCommit":{"oid":"0123456789abcdef"} + "originalCommit":{"oid":"0123456789abcdef"}, + "reactionGroups":[ + {"content":"THUMBS_UP","viewerHasReacted":true,"reactors":{"totalCount":3}}, + {"content":"EYES","viewerHasReacted":false,"reactors":{"totalCount":1}}, + {"content":"HEART","viewerHasReacted":false,"reactors":{"totalCount":0}} + ] }]} }]} }}}}`)) @@ -375,6 +380,11 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) { comment.OriginalCommitOID != "0123456789abcdef" || !comment.Outdated { t.Fatalf("unexpected comment snapshot: %#v", comment) } + if len(comment.Reactions) != 2 || + comment.Reactions[0] != (ReactionSummary{Content: "THUMBS_UP", Count: 3, ViewerHasReacted: true}) || + comment.Reactions[1] != (ReactionSummary{Content: "EYES", Count: 1}) { + t.Fatalf("unexpected comment reactions: %#v", comment.Reactions) + } } func TestGetPullRequestLoadsConflictFilesForConflictingPR(t *testing.T) { diff --git a/tui.go b/tui.go index c7474c8..66b1c3d 100644 --- a/tui.go +++ b/tui.go @@ -2089,6 +2089,14 @@ func (m App) detailLines(width int) []detailLine { )...) } } + for reactionIndex, reactionLine := range renderReactionSummary( + comment.Reactions, max(1, width-4), + ) { + lines = append(lines, detailLine{ + rail: rail, text: reactionLine, + anchor: fmt.Sprintf("comment:%s:reactions:%d", comment.ID, reactionIndex), + }) + } } if thread.IsTruncated { lines = append(lines, detailLine{}, detailLine{text: warnStyle.Render("Showing the first 100 comments in this thread.")}) @@ -2100,6 +2108,67 @@ func (m App) detailLines(width int) []detailLine { return lines } +func renderReactionSummary(reactions []ReactionSummary, width int) []string { + var badges []string + for _, reaction := range reactions { + if reaction.Count <= 0 { + continue + } + badge := fmt.Sprintf("%s %d", reactionEmoji(reaction.Content), reaction.Count) + if reaction.ViewerHasReacted { + badge = titleStyle.Render(badge) + } else { + badge = dimStyle.Render(badge) + } + badges = append(badges, badge) + } + if len(badges) == 0 { + return nil + } + + lines := []string{} + current := "" + for _, badge := range badges { + candidate := badge + if current != "" { + candidate = current + " " + badge + } + if current != "" && ansi.StringWidth(candidate) > width { + lines = append(lines, current) + current = badge + } else { + current = candidate + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +func reactionEmoji(content string) string { + switch content { + case "THUMBS_UP": + return "👍" + case "THUMBS_DOWN": + return "👎" + case "LAUGH": + return "😄" + case "HOORAY": + return "🎉" + case "CONFUSED": + return "😕" + case "HEART": + return "❤️" + case "ROCKET": + return "🚀" + case "EYES": + return "👀" + default: + return ":" + strings.ToLower(content) + ":" + } +} + func (m App) inlineReplyLines(width int) []detailLine { rail := warnStyle.Render("│ ") lines := []detailLine{ diff --git a/tui_test.go b/tui_test.go index f792e80..b451877 100644 --- a/tui_test.go +++ b/tui_test.go @@ -926,6 +926,61 @@ func TestReviewAnchorUsesCommentSnapshotCoordinates(t *testing.T) { } } +func TestThreadDetailShowsCommentReactions(t *testing.T) { + m := NewApp(nil, "o", "r", false, 50, 10*time.Second) + m.width = 80 + m.details = PRDetails{Threads: []ReviewThread{{ + ID: "thread", Path: "main.go", Comments: []ReviewComment{{ + ID: "comment", Author: "reviewer", Body: "Please change this.", + Reactions: []ReactionSummary{ + {Content: "THUMBS_UP", Count: 3, ViewerHasReacted: true}, + {Content: "EYES", Count: 1}, + {Content: "HEART", Count: 2}, + }, + }}, + }}} + + var rendered strings.Builder + reactionLines := 0 + for _, line := range m.detailLines(50) { + rendered.WriteString(ansi.Strip(line.rail + line.text)) + rendered.WriteByte('\n') + if strings.Contains(line.anchor, ":reactions:") { + reactionLines++ + if ansi.StringWidth(line.text) > 46 { + t.Fatalf("reaction line exceeds detail width: %q", ansi.Strip(line.text)) + } + } + } + plain := rendered.String() + for _, wanted := range []string{"Please change this.", "👍 3", "👀 1", "❤️ 2"} { + if !strings.Contains(plain, wanted) { + t.Fatalf("thread detail is missing %q:\n%s", wanted, plain) + } + } + if reactionLines == 0 { + t.Fatalf("reactions were not attached to their comment:\n%s", plain) + } +} + +func TestReactionSummaryWrapsBetweenBadges(t *testing.T) { + reactions := []ReactionSummary{ + {Content: "THUMBS_UP", Count: 10}, + {Content: "THUMBS_DOWN", Count: 2}, + {Content: "LAUGH", Count: 4}, + {Content: "HOORAY", Count: 7}, + } + lines := renderReactionSummary(reactions, 12) + if len(lines) < 2 { + t.Fatalf("reaction badges did not wrap: %#v", lines) + } + for _, line := range lines { + if width := ansi.StringWidth(line); width > 12 { + t.Fatalf("reaction line width = %d: %q", width, ansi.Strip(line)) + } + } +} + func TestOutdatedDetailHighlightsOriginalCodeRange(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.width = 100 diff --git a/types.go b/types.go index 820369a..c4c0571 100644 --- a/types.go +++ b/types.go @@ -174,4 +174,11 @@ type ReviewComment struct { Outdated bool CreatedAt time.Time URL string + Reactions []ReactionSummary +} + +type ReactionSummary struct { + Content string + Count int + ViewerHasReacted bool }