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

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
}