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
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,
review state, checks, people, labels, milestone, activity, change statistics,
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` |
| `F` | Clear active thread filters |
| `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 |
| `g` / `G` | First / last item |
| `enter` / `l` | Open the selected PR dashboard or its review threads |
@@ -151,13 +153,20 @@ history and metadata.
| `r` | Refresh now |
| `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
The application is intentionally read-only. The dashboard and contextual help
show the write capability gate, including why each future write action is
unavailable. Read state persists beside the configuration, and recent PR data
is cached for offline fallback. Check contexts and annotations are paginated.
GitHub features which depend
The application can reply to review threads and resolve or unresolve them.
Other write operations remain disabled. The dashboard shows the capability
gate, including why each action is unavailable. Read state
persists beside the configuration, and recent PR data is cached for offline
fallback. Check contexts and annotations are paginated. GitHub features which depend
on server-side context, such as unfurling issue references or displaying
uploaded images, are represented textually in the terminal. See
[`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
- 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
against an older revision.
- Design confirmation and optimistic-update behavior for resolving threads,
submitting reviews, and applying suggestions.
- Design confirmation and optimistic-update behavior for submitting reviews
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
}
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 {
return fmt.Sprintf("prs:%s/%s:%d:%t", owner, repo, limit, showAll)
}

112
github.go
View File

@@ -20,6 +20,11 @@ type GitHubService interface {
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 {
endpoint 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 {
Login string `json:"login"`
Name string `json:"name"`
@@ -1047,6 +1084,63 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
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 {
item := ReviewThread{
ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide,
@@ -1068,17 +1162,21 @@ func convertReviewThread(thread githubReviewThread) ReviewThread {
item.DiffSide = thread.StartDiffSide
}
for _, comment := range thread.Comments.Nodes {
item.Comments = append(item.Comments, 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,
})
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,
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,
}
}
func actorLogin(actor *githubActor) string {
if actor == nil || actor.Login == "" {
return "[ghost]"

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) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request graphQLRequest

View File

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

362
tui.go
View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"hash/fnv"
"slices"
@@ -31,6 +32,30 @@ const (
type tickMsg 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 {
prs []PullRequest
err error
@@ -71,6 +96,10 @@ type App struct {
searchOrigin int
helpVisible bool
helpScroll int
writeMode writeMode
writeThreadID string
replyDraft string
resolveTarget bool
foldResolved bool
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) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@@ -328,6 +485,53 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
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)
@@ -335,6 +539,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
k := key.String()
if m.writeMode != writeNone {
return m.updateWriteInput(key)
}
if m.helpVisible {
switch k {
case "ctrl+c":
@@ -409,6 +616,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
switch k {
case "c":
if m.screen == threadScreen {
m.startReply()
}
case "R":
if m.screen == threadScreen {
m.startResolveToggle()
}
case "F":
if m.screen == threadScreen {
m.searchQuery = ""
@@ -933,6 +1148,9 @@ func (m App) View() string {
if m.width == 0 {
return "Loading…"
}
if m.writeMode != writeNone && m.writeMode != writeReply {
return m.viewWritePopup()
}
if m.helpVisible {
return m.viewHelp()
}
@@ -945,6 +1163,73 @@ func (m App) View() string {
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 {
key string
action string
@@ -999,18 +1284,13 @@ func (m App) helpBindings() []helpBinding {
{"/", "Fuzzy-search file paths and combine status:, author:, and updated:true filters"},
{"F", "Clear the active thread filter and show every thread"},
{"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"},
{"enter / za", "Fold / expand thread"},
{"b / esc", backAction},
{"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,
helpBinding{"?", "Close this help"},
helpBinding{"q / ctrl-c", "Quit"},
@@ -1477,7 +1757,7 @@ func applicableRulesetCount(rulesets []Ruleset) int {
type writeCapability struct {
name, reason string
authorized bool
enabled bool
}
func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
@@ -1491,32 +1771,41 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
}
threadReason := "select a review thread"
canReply, canResolve := false, false
resolveName := "resolve thread"
if thread != nil {
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"
}
return []writeCapability{
capability("reply", canReply, threadReason),
capability("resolve / unresolve", canResolve, threadReason),
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission"),
capability("update branch", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission"),
capability("auto-merge", pr.Permissions.CanEnableMerge, "auto-merge is unavailable for this PR"),
capability("reply", canReply, threadReason, true),
capability(resolveName, canResolve, threadReason, true),
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false),
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", false),
}
}
func capability(name string, allowed bool, denied string) writeCapability {
if allowed {
return writeCapability{name: name, authorized: true, reason: "authorized; write UI not implemented"}
func capability(name string, allowed bool, denied string, implemented bool) writeCapability {
if !allowed {
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 {
var lines []string
for _, item := range writeCapabilities(pr, thread) {
marker := badStyle.Render("disabled")
if item.authorized {
if item.enabled {
marker = okStyle.Render("ready")
}
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)
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 {
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)
}
@@ -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.")})
}
}
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
}

View File

@@ -13,9 +13,29 @@ import (
)
type recordingService struct {
owner string
repo string
number int
owner string
repo string
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) {
@@ -437,16 +457,106 @@ func TestDetailRefreshKeepsLogicalCommentAnchored(t *testing.T) {
func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) {
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])
}
thread := &ReviewThread{ViewerCanReply: true}
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)
}
}
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) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
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.screen, m.width, m.height = threadScreen, 46, 20
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") {
t.Fatalf("F binding is not explained clearly:\n%s", plain)
}
if !strings.Contains(plain, "GitHub did not grant update permission") {
t.Fatalf("long capability explanation was cut off:\n%s", plain)
if strings.Contains(plain, "write action") ||
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 {
if width := ansi.StringWidth(row); width > m.helpContentWidth() {