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
|
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
|
||||||
|
|||||||
32
github.go
32
github.go
@@ -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 {
|
||||||
|
|||||||
@@ -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
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 {
|
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{
|
||||||
|
|||||||
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) {
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user