Show reactions to threads

This commit is contained in:
2026-07-28 12:02:33 +02:00
parent f93c2c517a
commit 72e1eb1fda
6 changed files with 175 additions and 4 deletions

View File

@@ -6,8 +6,8 @@ review state, merge conflicts and affected files, checks, people, labels,
milestone, activity, change statistics, thread totals, submitted reviews, and milestone, activity, change statistics, thread totals, submitted reviews, and
the PR conversation. Review threads and comments are paginated rather than the PR conversation. Review threads and comments are paginated rather than
silently stopping at the first page. The silently stopping at the first page. The
thread viewer includes highlighted diff hunks and comment authors. Resolved thread viewer includes highlighted diff hunks, comment authors, and read-only
threads start folded. GitHub suggestion blocks are shown as 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 syntax-highlighted remove/add previews. Comments and PR descriptions render
GitHub Flavored Markdown, including quoted replies, inline and fenced code, GitHub Flavored Markdown, including quoted replies, inline and fenced code,
lists and tasks, links, tables, emphasis, strikethrough, emoji, and GitHub lists and tasks, links, tables, emphasis, strikethrough, emoji, and GitHub

View File

@@ -153,6 +153,10 @@ query ReviewThreadsPage($owner: String!, $name: String!, $number: Int!, $after:
line startLine originalLine originalStartLine line startLine originalLine originalStartLine
originalCommit { oid } originalCommit { oid }
author { login } author { login }
reactionGroups {
content viewerHasReacted
reactors { totalCount }
}
} }
} }
} }
@@ -172,6 +176,10 @@ query ReviewCommentsPage($id: ID!, $after: String) {
line startLine originalLine originalStartLine line startLine originalLine originalStartLine
originalCommit { oid } originalCommit { oid }
author { login } author { login }
reactionGroups {
content viewerHasReacted
reactors { totalCount }
}
} }
} }
} }
@@ -374,6 +382,10 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
line startLine originalLine originalStartLine line startLine originalLine originalStartLine
originalCommit { oid } originalCommit { oid }
author { login } author { login }
reactionGroups {
content viewerHasReacted
reactors { totalCount }
}
} }
} }
} }
@@ -484,6 +496,15 @@ type githubReviewComment struct {
OriginalCommit *struct{ OID string } OriginalCommit *struct{ OID string }
CreatedAt time.Time CreatedAt time.Time
Author *githubActor Author *githubActor
ReactionGroups []githubReactionGroup
}
type githubReactionGroup struct {
Content string
ViewerHasReacted bool
Reactors struct {
TotalCount int
}
} }
type githubReviewCommentConnection struct { type githubReviewCommentConnection struct {
@@ -1196,13 +1217,22 @@ func convertReviewThread(thread githubReviewThread) ReviewThread {
} }
func convertReviewComment(comment githubReviewComment) ReviewComment { func convertReviewComment(comment githubReviewComment) ReviewComment {
return ReviewComment{ item := ReviewComment{
ID: comment.ID, Author: actorLogin(comment.Author), Body: comment.Body, ID: comment.ID, Author: actorLogin(comment.Author), Body: comment.Body,
DiffHunk: comment.DiffHunk, CreatedAt: comment.CreatedAt, URL: comment.URL, DiffHunk: comment.DiffHunk, CreatedAt: comment.CreatedAt, URL: comment.URL,
Line: intValue(comment.Line), StartLine: intValue(comment.StartLine), Line: intValue(comment.Line), StartLine: intValue(comment.StartLine),
OriginalLine: intValue(comment.OriginalLine), OriginalStartLine: intValue(comment.OriginalStartLine), OriginalLine: intValue(comment.OriginalLine), OriginalStartLine: intValue(comment.OriginalStartLine),
OriginalCommitOID: commitOID(comment.OriginalCommit), Outdated: comment.Outdated, 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 { func actorLogin(actor *githubActor) string {

View File

@@ -338,7 +338,12 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
"id":"c","body":"change this","diffHunk":"@@ -1 +1 @@","createdAt":"2026-01-01T00:00:00Z", "id":"c","body":"change this","diffHunk":"@@ -1 +1 @@","createdAt":"2026-01-01T00:00:00Z",
"url":"cu","author":{"login":"reviewer"},"outdated":true, "url":"cu","author":{"login":"reviewer"},"outdated":true,
"line":100,"startLine":99,"originalLine":42,"originalStartLine":40, "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 { comment.OriginalCommitOID != "0123456789abcdef" || !comment.Outdated {
t.Fatalf("unexpected comment snapshot: %#v", comment) 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) { func TestGetPullRequestLoadsConflictFilesForConflictingPR(t *testing.T) {

69
tui.go
View File

@@ -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 { if thread.IsTruncated {
lines = append(lines, detailLine{}, detailLine{text: warnStyle.Render("Showing the first 100 comments in this thread.")}) 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 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 { func (m App) inlineReplyLines(width int) []detailLine {
rail := warnStyle.Render("│ ") rail := warnStyle.Render("│ ")
lines := []detailLine{ lines := []detailLine{

View File

@@ -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) { func TestOutdatedDetailHighlightsOriginalCodeRange(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.width = 100 m.width = 100

View File

@@ -174,4 +174,11 @@ type ReviewComment struct {
Outdated bool Outdated bool
CreatedAt time.Time CreatedAt time.Time
URL string URL string
Reactions []ReactionSummary
}
type ReactionSummary struct {
Content string
Count int
ViewerHasReacted bool
} }