Add resolve and reply support
This commit is contained in:
21
README.md
21
README.md
@@ -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.
|
||||||
|
|||||||
7
TODO.md
7
TODO.md
@@ -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.
|
||||||
|
|||||||
20
cache.go
20
cache.go
@@ -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
104
github.go
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -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
362
tui.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
122
tui_test.go
122
tui_test.go
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user