Show reactions to threads
This commit is contained in:
@@ -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
|
||||
|
||||
32
github.go
32
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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
69
tui.go
69
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{
|
||||
|
||||
55
tui_test.go
55
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
|
||||
|
||||
Reference in New Issue
Block a user