Add resolve and reply support

This commit is contained in:
2026-07-28 11:27:20 +02:00
parent fdaee6a69e
commit 0e880fa9b0
8 changed files with 664 additions and 44 deletions

View File

@@ -1,6 +1,6 @@
# gh-threads # gh-threads
A read-only terminal UI for people receiving GitHub pull-request reviews. It A terminal UI for people receiving GitHub pull-request reviews. It
shows open PRs and a scrollable PR dashboard with the description, branches, shows open PRs and a scrollable PR dashboard with the description, branches,
review state, checks, people, labels, milestone, activity, change statistics, review state, checks, people, labels, milestone, activity, change statistics,
thread totals, submitted reviews, and the PR conversation. Review threads and thread totals, submitted reviews, and the PR conversation. Review threads and
@@ -140,6 +140,8 @@ history and metadata.
| `/` | Fuzzy-search paths and filter with `status:`, `author:`, `updated:true` | | `/` | Fuzzy-search paths and filter with `status:`, `author:`, `updated:true` |
| `F` | Clear active thread filters | | `F` | Clear active thread filters |
| `n` / `N` | Next / previous thread with a new update | | `n` / `N` | Next / previous thread with a new update |
| `c` | Compose a reply to the selected thread |
| `R` | Resolve or unresolve the selected thread |
| `↑` / `↓` | Choose a fuzzy-search match | | `↑` / `↓` | Choose a fuzzy-search match |
| `g` / `G` | First / last item | | `g` / `G` | First / last item |
| `enter` / `l` | Open the selected PR dashboard or its review threads | | `enter` / `l` | Open the selected PR dashboard or its review threads |
@@ -151,13 +153,20 @@ history and metadata.
| `r` | Refresh now | | `r` | Refresh now |
| `q` | Quit | | `q` | Quit |
The reply composer appears inline beneath the selected thread so its code and
comments remain visible while writing. It supports multiple lines: `enter`
inserts a newline, `ctrl-s` opens the rendered confirmation preview, and `esc`
cancels. Replies and resolution changes require an explicit `y` confirmation.
Write keys remain disabled for cached snapshots, during refreshes, and whenever
GitHub does not grant the corresponding per-thread capability.
## Current scope ## Current scope
The application is intentionally read-only. The dashboard and contextual help The application can reply to review threads and resolve or unresolve them.
show the write capability gate, including why each future write action is Other write operations remain disabled. The dashboard shows the capability
unavailable. Read state persists beside the configuration, and recent PR data gate, including why each action is unavailable. Read state
is cached for offline fallback. Check contexts and annotations are paginated. persists beside the configuration, and recent PR data is cached for offline
GitHub features which depend fallback. Check contexts and annotations are paginated. GitHub features which depend
on server-side context, such as unfurling issue references or displaying on server-side context, such as unfurling issue references or displaying
uploaded images, are represented textually in the terminal. See uploaded images, are represented textually in the terminal. See
[`TODO.md`](TODO.md) for remaining read-only work and write-support preparation. [`TODO.md`](TODO.md) for remaining read-only work and write-support preparation.

View File

@@ -31,8 +31,9 @@ The following read-only improvements remain before or alongside write support.
## Preparation for write support ## Preparation for write support
- Fetch per-comment update/delete permissions and the exact reply target IDs. - Fetch per-comment update/delete permissions.
- Define head-SHA conflict handling for comments and suggestions composed - Define head-SHA conflict handling for comments and suggestions composed
against an older revision. against an older revision.
- Design confirmation and optimistic-update behavior for resolving threads, - Design confirmation and optimistic-update behavior for submitting reviews
submitting reviews, and applying suggestions. and applying suggestions. Thread replies and resolution changes now use
confirmed server responses.

View File

@@ -118,6 +118,26 @@ func (c *CachedGitHubService) LivePullRequest(
return details, nil return details, nil
} }
func (c *CachedGitHubService) SetThreadResolved(
ctx context.Context, threadID string, resolved bool,
) (ReviewThread, error) {
writer, ok := c.remote.(GitHubWriteService)
if !ok {
return ReviewThread{}, errors.New("GitHub service does not support write actions")
}
return writer.SetThreadResolved(ctx, threadID, resolved)
}
func (c *CachedGitHubService) ReplyToThread(
ctx context.Context, threadID, body string,
) (ReviewComment, error) {
writer, ok := c.remote.(GitHubWriteService)
if !ok {
return ReviewComment{}, errors.New("GitHub service does not support write actions")
}
return writer.ReplyToThread(ctx, threadID, body)
}
func (c *CachedGitHubService) pullRequestsKey(owner, repo string, limit int, showAll bool) string { func (c *CachedGitHubService) pullRequestsKey(owner, repo string, limit int, showAll bool) string {
return fmt.Sprintf("prs:%s/%s:%d:%t", owner, repo, limit, showAll) return fmt.Sprintf("prs:%s/%s:%d:%t", owner, repo, limit, showAll)
} }

104
github.go
View File

@@ -20,6 +20,11 @@ type GitHubService interface {
GetPullRequest(context.Context, string, string, int) (PRDetails, error) GetPullRequest(context.Context, string, string, int) (PRDetails, error)
} }
type GitHubWriteService interface {
SetThreadResolved(context.Context, string, bool) (ReviewThread, error)
ReplyToThread(context.Context, string, string) (ReviewComment, error)
}
type GitHubClient struct { type GitHubClient struct {
endpoint string endpoint string
token string token string
@@ -422,6 +427,38 @@ query CheckAnnotationsPage($id: ID!, $after: String) {
} }
}` }`
const resolveThreadMutation = `
mutation ResolveReviewThread($input: ResolveReviewThreadInput!) {
resolveReviewThread(input: $input) {
thread {
id isResolved isOutdated viewerCanResolve viewerCanUnresolve viewerCanReply path
line originalLine diffSide startLine originalStartLine startDiffSide
}
}
}`
const unresolveThreadMutation = `
mutation UnresolveReviewThread($input: UnresolveReviewThreadInput!) {
unresolveReviewThread(input: $input) {
thread {
id isResolved isOutdated viewerCanResolve viewerCanUnresolve viewerCanReply path
line originalLine diffSide startLine originalStartLine startDiffSide
}
}
}`
const replyToThreadMutation = `
mutation ReplyToReviewThread($input: AddPullRequestReviewThreadReplyInput!) {
addPullRequestReviewThreadReply(input: $input) {
comment {
id body diffHunk createdAt url outdated
line startLine originalLine originalStartLine
originalCommit { oid }
author { login }
}
}
}`
type githubActor struct { type githubActor struct {
Login string `json:"login"` Login string `json:"login"`
Name string `json:"name"` Name string `json:"name"`
@@ -1047,6 +1084,63 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
return details, nil return details, nil
} }
func (c *GitHubClient) SetThreadResolved(
ctx context.Context, threadID string, resolved bool,
) (ReviewThread, error) {
query := resolveThreadMutation
variables := map[string]any{"input": map[string]any{"threadId": threadID}}
if resolved {
var data struct {
ResolveReviewThread *struct {
Thread *githubReviewThread
}
}
if err := c.query(ctx, query, variables, &data); err != nil {
return ReviewThread{}, err
}
if data.ResolveReviewThread == nil || data.ResolveReviewThread.Thread == nil {
return ReviewThread{}, errors.New("GitHub returned no resolved review thread")
}
return convertReviewThread(*data.ResolveReviewThread.Thread), nil
}
var data struct {
UnresolveReviewThread *struct {
Thread *githubReviewThread
}
}
if err := c.query(ctx, unresolveThreadMutation, variables, &data); err != nil {
return ReviewThread{}, err
}
if data.UnresolveReviewThread == nil || data.UnresolveReviewThread.Thread == nil {
return ReviewThread{}, errors.New("GitHub returned no unresolved review thread")
}
return convertReviewThread(*data.UnresolveReviewThread.Thread), nil
}
func (c *GitHubClient) ReplyToThread(
ctx context.Context, threadID, body string,
) (ReviewComment, error) {
var data struct {
AddPullRequestReviewThreadReply *struct {
Comment *githubReviewComment
}
}
if err := c.query(ctx, replyToThreadMutation, map[string]any{
"input": map[string]any{
"pullRequestReviewThreadId": threadID,
"body": body,
},
}, &data); err != nil {
return ReviewComment{}, err
}
if data.AddPullRequestReviewThreadReply == nil ||
data.AddPullRequestReviewThreadReply.Comment == nil {
return ReviewComment{}, errors.New("GitHub returned no review reply")
}
return convertReviewComment(*data.AddPullRequestReviewThreadReply.Comment), nil
}
func convertReviewThread(thread githubReviewThread) ReviewThread { func convertReviewThread(thread githubReviewThread) ReviewThread {
item := ReviewThread{ item := ReviewThread{
ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide, ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide,
@@ -1068,15 +1162,19 @@ func convertReviewThread(thread githubReviewThread) ReviewThread {
item.DiffSide = thread.StartDiffSide item.DiffSide = thread.StartDiffSide
} }
for _, comment := range thread.Comments.Nodes { for _, comment := range thread.Comments.Nodes {
item.Comments = append(item.Comments, ReviewComment{ item.Comments = append(item.Comments, convertReviewComment(comment))
}
return item
}
func convertReviewComment(comment githubReviewComment) ReviewComment {
return 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,
})
} }
return item
} }
func actorLogin(actor *githubActor) string { func actorLogin(actor *githubActor) string {

View File

@@ -119,6 +119,62 @@ func TestGraphQLErrorsAreReturned(t *testing.T) {
} }
} }
func TestThreadWriteMutationsUseThreadIDs(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
input, _ := request.Variables["input"].(map[string]any)
switch {
case strings.Contains(request.Query, "unresolveReviewThread"):
if input["threadId"] != "thread" {
t.Fatalf("unresolve input = %#v", input)
}
_, _ = w.Write([]byte(`{"data":{"unresolveReviewThread":{"thread":{
"id":"thread","path":"a.go","isResolved":false,"viewerCanResolve":true
}}}}`))
case strings.Contains(request.Query, "resolveReviewThread"):
if input["threadId"] != "thread" {
t.Fatalf("resolve input = %#v", input)
}
_, _ = w.Write([]byte(`{"data":{"resolveReviewThread":{"thread":{
"id":"thread","path":"a.go","isResolved":true,"viewerCanUnresolve":true
}}}}`))
case strings.Contains(request.Query, "addPullRequestReviewThreadReply"):
if input["pullRequestReviewThreadId"] != "thread" || input["body"] != "reply body" {
t.Fatalf("reply input = %#v", input)
}
_, _ = w.Write([]byte(`{"data":{"addPullRequestReviewThreadReply":{"comment":{
"id":"comment","body":"reply body","createdAt":"2026-01-01T00:00:00Z",
"url":"https://example/comment","author":{"login":"me"}
}}}}`))
default:
t.Fatalf("unexpected mutation: %s", request.Query)
}
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
resolved, err := client.SetThreadResolved(context.Background(), "thread", true)
if err != nil || !resolved.IsResolved || !resolved.ViewerCanUnresolve {
t.Fatalf("resolve result = %#v, error = %v", resolved, err)
}
unresolved, err := client.SetThreadResolved(context.Background(), "thread", false)
if err != nil || unresolved.IsResolved || !unresolved.ViewerCanResolve {
t.Fatalf("unresolve result = %#v, error = %v", unresolved, err)
}
comment, err := client.ReplyToThread(context.Background(), "thread", "reply body")
if err != nil || comment.ID != "comment" || comment.Author != "me" || comment.Body != "reply body" {
t.Fatalf("reply result = %#v, error = %v", comment, err)
}
if requests != 3 {
t.Fatalf("mutation requests = %d, want 3", requests)
}
}
func TestCheckContextsAndAnnotationsArePaginated(t *testing.T) { func TestCheckContextsAndAnnotationsArePaginated(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request graphQLRequest var request graphQLRequest

View File

@@ -157,3 +157,5 @@ func exitf(format string, args ...any) {
// Keep interface drift visible at compile time. // Keep interface drift visible at compile time.
var _ GitHubService = (*GitHubClient)(nil) var _ GitHubService = (*GitHubClient)(nil)
var _ GitHubWriteService = (*GitHubClient)(nil)
var _ GitHubWriteService = (*CachedGitHubService)(nil)

362
tui.go
View File

@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"slices" "slices"
@@ -31,6 +32,30 @@ const (
type tickMsg time.Time type tickMsg time.Time
type pathTickMsg time.Time type pathTickMsg time.Time
type writeMode int
const (
writeNone writeMode = iota
writeReply
writeReplyConfirm
writeReplyBusy
writeResolveConfirm
writeResolveBusy
)
type threadResolvedMsg struct {
threadID string
thread ReviewThread
err error
}
type threadRepliedMsg struct {
threadID string
comment ReviewComment
err error
}
type prsLoadedMsg struct { type prsLoadedMsg struct {
prs []PullRequest prs []PullRequest
err error err error
@@ -71,6 +96,10 @@ type App struct {
searchOrigin int searchOrigin int
helpVisible bool helpVisible bool
helpScroll int helpScroll int
writeMode writeMode
writeThreadID string
replyDraft string
resolveTarget bool
foldResolved bool foldResolved bool
threadListWidthPercent int threadListWidthPercent int
@@ -220,6 +249,134 @@ func (m App) loadLiveDetails(pr PullRequest) tea.Cmd {
} }
} }
func (m *App) startReply() {
thread := m.selectedThread()
if reason := m.writeActionUnavailable("reply", thread); reason != "" {
m.err = errors.New(reason)
return
}
m.writeMode, m.writeThreadID, m.replyDraft, m.err = writeReply, thread.ID, "", nil
m.folded[thread.ID] = false
m.focus = threadDetailPane
m.scroll = m.detailMaxScroll()
}
func (m *App) startResolveToggle() {
thread := m.selectedThread()
if reason := m.writeActionUnavailable("resolve", thread); reason != "" {
m.err = errors.New(reason)
return
}
m.writeMode, m.writeThreadID, m.resolveTarget, m.err =
writeResolveConfirm, thread.ID, !thread.IsResolved, nil
}
func (m App) writeActionUnavailable(action string, thread *ReviewThread) string {
if m.loading {
return "write action unavailable while PR data is refreshing"
}
if m.details.FromCache {
return "write action unavailable from an offline cached snapshot"
}
if _, ok := m.service.(GitHubWriteService); !ok {
return "configured GitHub service does not support write actions"
}
if thread == nil {
return "select a review thread first"
}
switch action {
case "reply":
if !thread.ViewerCanReply {
return "GitHub did not grant reply permission for this thread"
}
case "resolve":
if thread.IsResolved && !thread.ViewerCanUnresolve {
return "GitHub did not grant unresolve permission for this thread"
}
if !thread.IsResolved && !thread.ViewerCanResolve {
return "GitHub did not grant resolve permission for this thread"
}
}
return ""
}
func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
k := key.String()
if k == "ctrl+c" {
return m, tea.Quit
}
switch m.writeMode {
case writeReply:
switch k {
case "esc":
m.writeMode, m.replyDraft, m.writeThreadID = writeNone, "", ""
m.scroll = min(m.scroll, m.detailMaxScroll())
case "ctrl+s":
if strings.TrimSpace(m.replyDraft) == "" {
m.err = errors.New("reply cannot be empty")
} else {
m.writeMode, m.err = writeReplyConfirm, nil
}
case "enter":
m.replyDraft += "\n"
case "backspace":
runes := []rune(m.replyDraft)
if len(runes) > 0 {
m.replyDraft = string(runes[:len(runes)-1])
}
m.err = nil
default:
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
m.replyDraft += string(key.Runes)
m.err = nil
}
}
if m.writeMode == writeReply {
m.scroll = m.detailMaxScroll()
}
case writeReplyConfirm:
switch k {
case "y":
m.writeMode = writeReplyBusy
return m, m.submitReply()
case "n", "esc":
m.writeMode = writeReply
m.scroll = m.detailMaxScroll()
}
case writeResolveConfirm:
switch k {
case "y":
m.writeMode = writeResolveBusy
return m, m.submitResolution()
case "n", "esc":
m.writeMode, m.writeThreadID = writeNone, ""
}
}
return m, nil
}
func (m App) submitReply() tea.Cmd {
writer := m.service.(GitHubWriteService)
threadID, body := m.writeThreadID, strings.TrimRight(m.replyDraft, "\n")
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
comment, err := writer.ReplyToThread(ctx, threadID, body)
return threadRepliedMsg{threadID: threadID, comment: comment, err: err}
}
}
func (m App) submitResolution() tea.Cmd {
writer := m.service.(GitHubWriteService)
threadID, resolved := m.writeThreadID, m.resolveTarget
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
thread, err := writer.SetThreadResolved(ctx, threadID, resolved)
return threadResolvedMsg{threadID: threadID, thread: thread, err: err}
}
}
func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@@ -328,6 +485,53 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
} }
case threadResolvedMsg:
m.writeMode = writeNone
if msg.err != nil {
m.err = fmt.Errorf("change thread resolution: %w", msg.err)
return m, nil
}
selected := ""
if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) {
selected = m.details.Threads[m.threadIndex].ID
}
for index := range m.details.Threads {
if m.details.Threads[index].ID != msg.threadID {
continue
}
thread := &m.details.Threads[index]
thread.IsResolved = msg.thread.IsResolved
thread.IsOutdated = msg.thread.IsOutdated
thread.ViewerCanResolve = msg.thread.ViewerCanResolve
thread.ViewerCanUnresolve = msg.thread.ViewerCanUnresolve
thread.ViewerCanReply = msg.thread.ViewerCanReply
m.folded[thread.ID] = thread.IsResolved && m.foldResolved
break
}
sortReviewThreads(m.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
m.threadIndex = indexThread(m.details.Threads, selected)
m.writeThreadID = ""
m.err = nil
m.lastRefresh = time.Now()
case threadRepliedMsg:
if msg.err != nil {
m.writeMode = writeReply
m.err = fmt.Errorf("reply to review thread: %w", msg.err)
m.scroll = m.detailMaxScroll()
return m, nil
}
m.writeMode = writeNone
for index := range m.details.Threads {
if m.details.Threads[index].ID == msg.threadID {
m.details.Threads[index].Comments = append(m.details.Threads[index].Comments, msg.comment)
m.threadIndex = index
m.markCurrentThreadRead()
break
}
}
m.replyDraft, m.writeThreadID = "", ""
m.err = nil
m.lastRefresh = time.Now()
} }
key, ok := msg.(tea.KeyMsg) key, ok := msg.(tea.KeyMsg)
@@ -335,6 +539,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
k := key.String() k := key.String()
if m.writeMode != writeNone {
return m.updateWriteInput(key)
}
if m.helpVisible { if m.helpVisible {
switch k { switch k {
case "ctrl+c": case "ctrl+c":
@@ -409,6 +616,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
switch k { switch k {
case "c":
if m.screen == threadScreen {
m.startReply()
}
case "R":
if m.screen == threadScreen {
m.startResolveToggle()
}
case "F": case "F":
if m.screen == threadScreen { if m.screen == threadScreen {
m.searchQuery = "" m.searchQuery = ""
@@ -933,6 +1148,9 @@ func (m App) View() string {
if m.width == 0 { if m.width == 0 {
return "Loading…" return "Loading…"
} }
if m.writeMode != writeNone && m.writeMode != writeReply {
return m.viewWritePopup()
}
if m.helpVisible { if m.helpVisible {
return m.viewHelp() return m.viewHelp()
} }
@@ -945,6 +1163,73 @@ func (m App) View() string {
return m.viewThreads() return m.viewThreads()
} }
func (m App) viewWritePopup() string {
width := max(20, min(76, m.width-4))
thread := m.threadByID(m.writeThreadID)
location := "selected thread"
if thread != nil {
location = fmt.Sprintf("%s:%d", thread.Path, thread.Line)
}
var lines []string
switch m.writeMode {
case writeReply:
lines = []string{
titleStyle.Render("Reply to " + location),
"",
}
draft := m.replyDraft + "█"
for _, sourceLine := range strings.Split(draft, "\n") {
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width-2, ""), width-2, false)
lines = append(lines, strings.Split(wrapped, "\n")...)
}
if m.err != nil {
lines = append(lines, "", badStyle.Render(m.err.Error()))
}
lines = append(lines, "", dimStyle.Render("enter newline • ctrl-s review • esc cancel"))
case writeReplyConfirm:
lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""}
lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...)
lines = append(lines, "", warnStyle.Render("y submit • n/esc continue editing"))
case writeReplyBusy:
lines = []string{titleStyle.Render("Submitting reply…"), "", dimStyle.Render(location)}
case writeResolveConfirm:
action := "resolve"
if !m.resolveTarget {
action = "unresolve"
}
lines = []string{
titleStyle.Render(strings.ToUpper(action[:1]) + action[1:] + " " + location + "?"),
"",
warnStyle.Render("y confirm • n/esc cancel"),
}
case writeResolveBusy:
action := "Resolving"
if !m.resolveTarget {
action = "Unresolving"
}
lines = []string{titleStyle.Render(action + " thread…"), "", dimStyle.Render(location)}
}
maxLines := max(3, m.height-4)
if len(lines) > maxLines {
lines = lines[len(lines)-maxLines:]
}
for index := range lines {
lines[index] = ansi.Truncate(lines[index], width, "")
}
popup := paneStyle(true).Width(width).Render(strings.Join(lines, "\n"))
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup)
}
func (m App) threadByID(id string) *ReviewThread {
for index := range m.details.Threads {
if m.details.Threads[index].ID == id {
thread := m.details.Threads[index]
return &thread
}
}
return nil
}
type helpBinding struct { type helpBinding struct {
key string key string
action string action string
@@ -999,18 +1284,13 @@ func (m App) helpBindings() []helpBinding {
{"/", "Fuzzy-search file paths and combine status:, author:, and updated:true filters"}, {"/", "Fuzzy-search file paths and combine status:, author:, and updated:true filters"},
{"F", "Clear the active thread filter and show every thread"}, {"F", "Clear the active thread filter and show every thread"},
{"n / N", "Next / previous new update"}, {"n / N", "Next / previous new update"},
{"c", "Compose a reply to the selected thread"},
{"R", "Resolve or unresolve the selected thread"},
{"d", "Open pull request dashboard"}, {"d", "Open pull request dashboard"},
{"enter / za", "Fold / expand thread"}, {"enter / za", "Fold / expand thread"},
{"b / esc", backAction}, {"b / esc", backAction},
{"r", "Refresh now"}, {"r", "Refresh now"},
} }
for _, item := range writeCapabilities(m.details, m.selectedThread()) {
state := item.reason
if item.authorized {
state = "ready; " + item.reason
}
bindings = append(bindings, helpBinding{"write", item.name + ": " + state})
}
bindings = append(bindings, bindings = append(bindings,
helpBinding{"?", "Close this help"}, helpBinding{"?", "Close this help"},
helpBinding{"q / ctrl-c", "Quit"}, helpBinding{"q / ctrl-c", "Quit"},
@@ -1477,7 +1757,7 @@ func applicableRulesetCount(rulesets []Ruleset) int {
type writeCapability struct { type writeCapability struct {
name, reason string name, reason string
authorized bool enabled bool
} }
func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability { func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
@@ -1491,32 +1771,41 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
} }
threadReason := "select a review thread" threadReason := "select a review thread"
canReply, canResolve := false, false canReply, canResolve := false, false
resolveName := "resolve thread"
if thread != nil { if thread != nil {
canReply = thread.ViewerCanReply canReply = thread.ViewerCanReply
canResolve = thread.ViewerCanResolve || thread.ViewerCanUnresolve if thread.IsResolved {
resolveName = "unresolve thread"
canResolve = thread.ViewerCanUnresolve
} else {
canResolve = thread.ViewerCanResolve
}
threadReason = "GitHub did not grant permission for this thread" threadReason = "GitHub did not grant permission for this thread"
} }
return []writeCapability{ return []writeCapability{
capability("reply", canReply, threadReason), capability("reply", canReply, threadReason, true),
capability("resolve / unresolve", canResolve, threadReason), capability(resolveName, canResolve, threadReason, true),
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission"), capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false),
capability("update branch", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission"), capability("update branch", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", false),
capability("auto-merge", pr.Permissions.CanEnableMerge, "auto-merge is unavailable for this PR"), capability("auto-merge", pr.Permissions.CanEnableMerge, "auto-merge is unavailable for this PR", false),
} }
} }
func capability(name string, allowed bool, denied string) writeCapability { func capability(name string, allowed bool, denied string, implemented bool) writeCapability {
if allowed { if !allowed {
return writeCapability{name: name, authorized: true, reason: "authorized; write UI not implemented"}
}
return writeCapability{name: name, reason: denied} return writeCapability{name: name, reason: denied}
} }
if !implemented {
return writeCapability{name: name, reason: "write action not implemented yet"}
}
return writeCapability{name: name, enabled: true, reason: "available"}
}
func writeCapabilityLines(pr PRDetails, thread *ReviewThread) []string { func writeCapabilityLines(pr PRDetails, thread *ReviewThread) []string {
var lines []string var lines []string
for _, item := range writeCapabilities(pr, thread) { for _, item := range writeCapabilities(pr, thread) {
marker := badStyle.Render("disabled") marker := badStyle.Render("disabled")
if item.authorized { if item.enabled {
marker = okStyle.Render("ready") marker = okStyle.Render("ready")
} }
lines = append(lines, pad(item.name, 21)+" "+marker+" "+dimStyle.Render(item.reason)) lines = append(lines, pad(item.name, 21)+" "+marker+" "+dimStyle.Render(item.reason))
@@ -1585,9 +1874,11 @@ func (m App) viewThreads() string {
right := m.threadDetail(rightWidth, contentHeight) right := m.threadDetail(rightWidth, contentHeight)
body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
} }
help := "? keys • h/l focus • j/k move/scroll • n/N new • d dashboard • b back • q quit" help := "? keys • h/l focus • j/k move/scroll • c reply • R resolve • d dashboard • b back • q quit"
if m.searching { if m.searching {
help = "path words • status:open • author:name • updated:true • enter apply • esc cancel" help = "path words • status:open • author:name • updated:true • enter apply • esc cancel"
} else if m.writeMode == writeReply {
help = "reply inline • enter newline • ctrl-s review • esc cancel"
} }
return m.frame(append(top, body), help) return m.frame(append(top, body), help)
} }
@@ -1769,6 +2060,37 @@ func (m App) detailLines(width int) []detailLine {
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.")})
} }
} }
if m.writeMode == writeReply && m.writeThreadID == thread.ID {
lines = append(lines, m.inlineReplyLines(width)...)
}
return lines
}
func (m App) inlineReplyLines(width int) []detailLine {
rail := warnStyle.Render("│ ")
lines := []detailLine{
{},
{rail: rail, anchor: "reply:header", text: titleStyle.Render("Reply draft")},
}
draft := m.replyDraft + "█"
textWidth := max(1, width-4)
lineIndex := 0
for _, sourceLine := range strings.Split(draft, "\n") {
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, textWidth, ""), textWidth, false)
for _, part := range strings.Split(wrapped, "\n") {
lines = append(lines, detailLine{
rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part,
})
lineIndex++
}
}
if m.err != nil {
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
}
lines = append(lines, detailLine{
rail: rail,
text: dimStyle.Render("enter newline • ctrl-s review • esc cancel"),
})
return lines return lines
} }

View File

@@ -16,6 +16,26 @@ type recordingService struct {
owner string owner string
repo string repo string
number int number int
writeThreadID string
writeBody string
writeResolved bool
}
func (s *recordingService) SetThreadResolved(
_ context.Context, threadID string, resolved bool,
) (ReviewThread, error) {
s.writeThreadID, s.writeResolved = threadID, resolved
return ReviewThread{
ID: threadID, IsResolved: resolved,
ViewerCanResolve: !resolved, ViewerCanUnresolve: resolved, ViewerCanReply: true,
}, nil
}
func (s *recordingService) ReplyToThread(
_ context.Context, threadID, body string,
) (ReviewComment, error) {
s.writeThreadID, s.writeBody = threadID, body
return ReviewComment{ID: "new-comment", Author: "me", Body: body}, nil
} }
func (s *recordingService) ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) { func (s *recordingService) ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) {
@@ -437,16 +457,106 @@ func TestDetailRefreshKeepsLogicalCommentAnchored(t *testing.T) {
func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) { func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) {
cached := writeCapabilities(PRDetails{FromCache: true}, nil) cached := writeCapabilities(PRDetails{FromCache: true}, nil)
if cached[0].reason != "offline cached snapshot" || cached[0].authorized { if cached[0].reason != "offline cached snapshot" || cached[0].enabled {
t.Fatalf("cached capability = %#v", cached[0]) t.Fatalf("cached capability = %#v", cached[0])
} }
thread := &ReviewThread{ViewerCanReply: true} thread := &ReviewThread{ViewerCanReply: true}
live := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanReact: true}}, thread) live := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanReact: true}}, thread)
if !live[0].authorized || live[1].authorized || !live[2].authorized { if !live[0].enabled || live[1].enabled || live[2].enabled {
t.Fatalf("live capabilities = %#v", live) t.Fatalf("live capabilities = %#v", live)
} }
} }
func TestReplyComposerConfirmsAndAddsReturnedComment(t *testing.T) {
service := &recordingService{}
m := NewApp(service, "o", "r", false, 50, time.Second)
m.screen, m.loading = threadScreen, false
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1},
Threads: []ReviewThread{{
ID: "thread", ViewerCanReply: true, Comments: []ReviewComment{{ID: "old"}},
}},
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")})
m = updated.(App)
if m.writeMode != writeReply {
t.Fatalf("reply key opened mode %d", m.writeMode)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")})
m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
m = updated.(App)
if m.writeMode != writeReplyConfirm {
t.Fatalf("ctrl-s opened mode %d", m.writeMode)
}
updated, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
m = updated.(App)
if command == nil || m.writeMode != writeReplyBusy {
t.Fatalf("reply confirmation produced mode=%d command=%v", m.writeMode, command)
}
updated, _ = m.Update(command())
m = updated.(App)
if service.writeThreadID != "thread" || service.writeBody != "hello" ||
len(m.details.Threads[0].Comments) != 2 ||
m.details.Threads[0].Comments[1].ID != "new-comment" {
t.Fatalf("reply was not applied: service=%#v model=%#v", service, m.details.Threads[0])
}
}
func TestReplyComposerRendersInlineWithCurrentThread(t *testing.T) {
service := &recordingService{}
m := NewApp(service, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1},
Threads: []ReviewThread{{
ID: "thread", Path: "main.go", Line: 12, ViewerCanReply: true,
Comments: []ReviewComment{{
ID: "existing", Author: "reviewer", Body: "Existing review context",
}},
}},
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")})
m = updated.(App)
plain := ansi.Strip(m.View())
for _, wanted := range []string{"Existing review context", "Reply draft", "ctrl-s review"} {
if !strings.Contains(plain, wanted) {
t.Fatalf("inline reply view is missing %q:\n%s", wanted, plain)
}
}
if m.focus != threadDetailPane {
t.Fatal("inline reply did not focus the thread detail pane")
}
}
func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) {
service := &recordingService{}
m := NewApp(service, "o", "r", false, 50, time.Second)
m.screen, m.loading = threadScreen, false
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1},
Threads: []ReviewThread{{
ID: "thread", ViewerCanResolve: true,
}},
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("R")})
m = updated.(App)
if m.writeMode != writeResolveConfirm || !m.resolveTarget {
t.Fatalf("resolve key produced mode=%d target=%t", m.writeMode, m.resolveTarget)
}
updated, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
m = updated.(App)
updated, _ = m.Update(command())
m = updated.(App)
if !service.writeResolved || !m.details.Threads[0].IsResolved ||
!m.details.Threads[0].ViewerCanUnresolve {
t.Fatalf("thread was not resolved: service=%#v thread=%#v", service, m.details.Threads[0])
}
}
func TestPollingMarksNewThreadCommentsUnread(t *testing.T) { func TestPollingMarksNewThreadCommentsUnread(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen m.screen = threadScreen
@@ -546,7 +656,7 @@ func TestHelpCanScrollInShortTerminal(t *testing.T) {
} }
} }
func TestHelpWrapsLongActionsAndExplainsFilterClear(t *testing.T) { func TestHelpWrapsLongActionsWithoutCapabilityStatus(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.width, m.height = threadScreen, 46, 20 m.screen, m.width, m.height = threadScreen, 46, 20
rows := m.helpRows(m.helpContentWidth()) rows := m.helpRows(m.helpContentWidth())
@@ -554,8 +664,10 @@ func TestHelpWrapsLongActionsAndExplainsFilterClear(t *testing.T) {
if !strings.Contains(plain, "Clear the active thread filter and show every thread") { if !strings.Contains(plain, "Clear the active thread filter and show every thread") {
t.Fatalf("F binding is not explained clearly:\n%s", plain) t.Fatalf("F binding is not explained clearly:\n%s", plain)
} }
if !strings.Contains(plain, "GitHub did not grant update permission") { if strings.Contains(plain, "write action") ||
t.Fatalf("long capability explanation was cut off:\n%s", plain) strings.Contains(plain, "GitHub did not grant") ||
strings.Contains(plain, "available") {
t.Fatalf("write capability status leaked into keybinding help:\n%s", plain)
} }
for index, row := range rows { for index, row := range rows {
if width := ansi.StringWidth(row); width > m.helpContentWidth() { if width := ansi.StringWidth(row); width > m.helpContentWidth() {