diff --git a/README.md b/README.md index fdf6b7b..e827337 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/TODO.md b/TODO.md index 54f362c..e0b0eb6 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/cache.go b/cache.go index 7dd7a14..1e0c266 100644 --- a/cache.go +++ b/cache.go @@ -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) } diff --git a/github.go b/github.go index 08cddf5..a1dd75d 100644 --- a/github.go +++ b/github.go @@ -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]" diff --git a/github_test.go b/github_test.go index d5b3856..15f1d9f 100644 --- a/github_test.go +++ b/github_test.go @@ -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 diff --git a/main.go b/main.go index 5c2d7a1..e544bbe 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/tui.go b/tui.go index 5d251ba..be79f4a 100644 --- a/tui.go +++ b/tui.go @@ -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 } diff --git a/tui_test.go b/tui_test.go index a272ed6..ba07a03 100644 --- a/tui_test.go +++ b/tui_test.go @@ -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() {