Compare commits
2 Commits
07e8bc2f5b
...
1d90e364ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d90e364ee | |||
| a82f5e7e9f |
19
README.md
19
README.md
@@ -70,7 +70,8 @@ When GitHub reports that the authenticated user has permission, diple can:
|
|||||||
|
|
||||||
- reply to review threads;
|
- reply to review threads;
|
||||||
- resolve and unresolve review threads;
|
- resolve and unresolve review threads;
|
||||||
- edit the PR title, Markdown description, and target branch;
|
- edit the PR title, Markdown description, target branch, requested reviewers,
|
||||||
|
and assignees;
|
||||||
- enable or disable auto-merge; and
|
- enable or disable auto-merge; and
|
||||||
- merge immediately when the PR is eligible.
|
- merge immediately when the PR is eligible.
|
||||||
|
|
||||||
@@ -78,8 +79,8 @@ The UI explains unavailable actions through its write-capability gate.
|
|||||||
Metadata and reply drafts are persisted locally so cancellation or a restart
|
Metadata and reply drafts are persisted locally so cancellation or a restart
|
||||||
does not silently discard work.
|
does not silently discard work.
|
||||||
|
|
||||||
Reactions are currently read-only. Assigning reviewers, assignees, labels, or
|
Reactions are currently read-only. Assigning labels or milestones is not
|
||||||
milestones is not implemented yet.
|
implemented yet.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -197,7 +198,13 @@ reference.
|
|||||||
The PR description editor defaults to Vim-style modal editing, including
|
The PR description editor defaults to Vim-style modal editing, including
|
||||||
Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard
|
Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard
|
||||||
yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a
|
yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a
|
||||||
non-modal editor. Target-branch completion uses `ctrl+n` and `ctrl+p`.
|
non-modal editor. Target-branch, reviewer, and assignee completion use
|
||||||
|
`ctrl+n` and `ctrl+p`; reviewer and assignee fields accept comma-separated
|
||||||
|
GitHub usernames. Current reviewers and assignees are prefilled and marked in
|
||||||
|
completion results. Reviewer suggestions prioritize recent contributors using
|
||||||
|
the latest 100 commits on the repository's default branch; this bounded window
|
||||||
|
is also shown in the editor. Every change is shown in the existing confirmation
|
||||||
|
screen before GitHub is updated.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -236,6 +243,7 @@ fold_resolved = true
|
|||||||
thread_list_width_percent = 33 # 20-60
|
thread_list_width_percent = 33 # 20-60
|
||||||
dashboard_mode = "hotkey" # "hotkey" or "intermediate"
|
dashboard_mode = "hotkey" # "hotkey" or "intermediate"
|
||||||
compact_reviews = true
|
compact_reviews = true
|
||||||
|
viewer_label = "login" # "login" or "you"
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
scroll = false
|
scroll = false
|
||||||
@@ -281,6 +289,9 @@ and thread viewer.
|
|||||||
`compact_reviews = true` summarizes the submitted-review history instead of
|
`compact_reviews = true` summarizes the submitted-review history instead of
|
||||||
showing every repeated `COMMENTED` event.
|
showing every repeated `COMMENTED` event.
|
||||||
|
|
||||||
|
`viewer_label = "login"` shows your GitHub username like every other author.
|
||||||
|
Set it to `"you"` to replace your username with `@you` throughout the UI.
|
||||||
|
|
||||||
Thread categories are:
|
Thread categories are:
|
||||||
|
|
||||||
- `unresolved`: current unresolved threads;
|
- `unresolved`: current unresolved threads;
|
||||||
|
|||||||
7
TODO.md
7
TODO.md
@@ -75,7 +75,7 @@ editing are already implemented.
|
|||||||
## Data completeness and compatibility
|
## Data completeness and compatibility
|
||||||
|
|
||||||
- Paginate or explicitly mark truncation for the remaining fixed-size
|
- Paginate or explicitly mark truncation for the remaining fixed-size
|
||||||
connections: assignees, labels, review requests, latest reviews, repository
|
connections: labels, review requests, latest reviews, repository
|
||||||
rulesets, and rules within a ruleset.
|
rulesets, and rules within a ruleset.
|
||||||
- Model pending reviews, minimized comments, deleted comments/users, edited
|
- Model pending reviews, minimized comments, deleted comments/users, edited
|
||||||
timestamps, and explicit reply relationships.
|
timestamps, and explicit reply relationships.
|
||||||
@@ -92,9 +92,8 @@ editing are already implemented.
|
|||||||
|
|
||||||
## Write roadmap
|
## Write roadmap
|
||||||
|
|
||||||
- Add fuzzy multi-select editors for requested reviewers, assignees, labels,
|
- Add fuzzy editors for labels and milestone with an explicit before/after
|
||||||
and milestone. Support adding, removing, and clearing values with an explicit
|
confirmation.
|
||||||
before/after confirmation.
|
|
||||||
- Add top-level PR conversation replies and editing/deleting the viewer's own
|
- Add top-level PR conversation replies and editing/deleting the viewer's own
|
||||||
comments. Fetch and enforce per-comment update/delete permissions.
|
comments. Fetch and enforce per-comment update/delete permissions.
|
||||||
- Add reaction add/remove actions while retaining the current read-only counts.
|
- Add reaction add/remove actions while retaining the current read-only counts.
|
||||||
|
|||||||
7
ai.go
7
ai.go
@@ -145,6 +145,7 @@ type AIPreview struct {
|
|||||||
chunks []string
|
chunks []string
|
||||||
details PRDetails
|
details PRDetails
|
||||||
threadID string
|
threadID string
|
||||||
|
message string
|
||||||
validLines map[string]map[int]bool
|
validLines map[string]map[int]bool
|
||||||
validDeleted map[string]map[int]bool
|
validDeleted map[string]map[int]bool
|
||||||
diffText map[string]string
|
diffText map[string]string
|
||||||
@@ -243,7 +244,7 @@ func (c *AIController) Prepare(ctx context.Context, details PRDetails, threadID,
|
|||||||
Included: included,
|
Included: included,
|
||||||
Redactions: redactions, HeadOID: details.HeadOID,
|
Redactions: redactions, HeadOID: details.HeadOID,
|
||||||
Model: firstNonEmpty(c.config.Model, status.Model), chunks: chunks,
|
Model: firstNonEmpty(c.config.Model, status.Model), chunks: chunks,
|
||||||
details: details, threadID: threadID,
|
details: details, threadID: threadID, message: message,
|
||||||
validLines: validLines,
|
validLines: validLines,
|
||||||
validDeleted: validDeleted,
|
validDeleted: validDeleted,
|
||||||
diffText: diffText,
|
diffText: diffText,
|
||||||
@@ -316,8 +317,8 @@ func (c *AIController) RunWithProgress(
|
|||||||
return AIResult{}, err
|
return AIResult{}, err
|
||||||
}
|
}
|
||||||
findings, comments := state.Apply(
|
findings, comments := state.Apply(
|
||||||
preview.details, combined, c.provider.Name(), model, preview.threadID, preview.validLines,
|
preview.details, combined, c.provider.Name(), model, preview.threadID, preview.message,
|
||||||
preview.validDeleted, preview.diffText,
|
preview.validLines, preview.validDeleted, preview.diffText,
|
||||||
)
|
)
|
||||||
if err := c.store.Save(preview.details, state); err != nil {
|
if err := c.store.Save(preview.details, state); err != nil {
|
||||||
return AIResult{}, err
|
return AIResult{}, err
|
||||||
|
|||||||
11
ai_diff.go
11
ai_diff.go
@@ -14,16 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (c *GitHubClient) PullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
func (c *GitHubClient) PullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
||||||
base := strings.TrimSuffix(c.endpoint, "/")
|
requestURL := c.restBaseURL() + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) +
|
||||||
switch {
|
|
||||||
case base == "https://api.github.com/graphql":
|
|
||||||
base = "https://api.github.com"
|
|
||||||
case strings.HasSuffix(base, "/api/graphql"):
|
|
||||||
base = strings.TrimSuffix(base, "/api/graphql") + "/api/v3"
|
|
||||||
default:
|
|
||||||
base = strings.TrimSuffix(base, "/graphql")
|
|
||||||
}
|
|
||||||
requestURL := base + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) +
|
|
||||||
"/pulls/" + strconv.Itoa(number)
|
"/pulls/" + strconv.Itoa(number)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
23
ai_store.go
23
ai_store.go
@@ -111,6 +111,12 @@ func (s *aiStoredState) Merge(pr PRDetails) PRDetails {
|
|||||||
}
|
}
|
||||||
for i := range result.Threads {
|
for i := range result.Threads {
|
||||||
if comments := s.Annotations[result.Threads[i].ID]; len(comments) > 0 {
|
if comments := s.Annotations[result.Threads[i].ID]; len(comments) > 0 {
|
||||||
|
comments = slices.Clone(comments)
|
||||||
|
for index := range comments {
|
||||||
|
if comments[index].Origin == reviewOriginLocalAIUser && pr.ViewerLogin != "" {
|
||||||
|
comments[index].Author = pr.ViewerLogin
|
||||||
|
}
|
||||||
|
}
|
||||||
result.Threads[i].Comments = append(result.Threads[i].Comments, comments...)
|
result.Threads[i].Comments = append(result.Threads[i].Comments, comments...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +124,7 @@ func (s *aiStoredState) Merge(pr PRDetails) PRDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *aiStoredState) Apply(
|
func (s *aiStoredState) Apply(
|
||||||
pr PRDetails, output aiOutput, provider, model, targetThread string,
|
pr PRDetails, output aiOutput, provider, model, targetThread, message string,
|
||||||
validLines map[string]map[int]bool,
|
validLines map[string]map[int]bool,
|
||||||
validDeleted map[string]map[int]bool,
|
validDeleted map[string]map[int]bool,
|
||||||
diffText map[string]string,
|
diffText map[string]string,
|
||||||
@@ -142,6 +148,9 @@ func (s *aiStoredState) Apply(
|
|||||||
for _, comment := range thread.Comments {
|
for _, comment := range thread.Comments {
|
||||||
existing[aiFingerprint(thread.Path, thread.StartLine, thread.Line, "", comment.Body)] = true
|
existing[aiFingerprint(thread.Path, thread.StartLine, thread.Line, "", comment.Body)] = true
|
||||||
existing[aiFingerprint(thread.ID, 0, 0, "", comment.Body)] = true
|
existing[aiFingerprint(thread.ID, 0, 0, "", comment.Body)] = true
|
||||||
|
if comment.Origin == reviewOriginLocalAIUser {
|
||||||
|
existing[aiFingerprint(thread.ID, 0, 0, "user", comment.Body)] = true
|
||||||
|
}
|
||||||
combined.WriteString(" ")
|
combined.WriteString(" ")
|
||||||
combined.WriteString(comment.Body)
|
combined.WriteString(comment.Body)
|
||||||
}
|
}
|
||||||
@@ -234,6 +243,18 @@ func (s *aiStoredState) Apply(
|
|||||||
if targetThread != "" {
|
if targetThread != "" {
|
||||||
validThreads = map[string]bool{targetThread: true}
|
validThreads = map[string]bool{targetThread: true}
|
||||||
}
|
}
|
||||||
|
message = safeAIText(message)
|
||||||
|
if targetThread != "" && strings.TrimSpace(message) != "" {
|
||||||
|
fingerprint := aiFingerprint(targetThread, 0, 0, "user", message)
|
||||||
|
if !existing[fingerprint] {
|
||||||
|
existing[fingerprint] = true
|
||||||
|
s.Annotations[targetThread] = append(s.Annotations[targetThread], ReviewComment{
|
||||||
|
ID: "local-ai-user-" + fingerprint,
|
||||||
|
Author: firstNonEmpty(pr.ViewerLogin, "you"), Body: message,
|
||||||
|
CreatedAt: time.Now(), Origin: reviewOriginLocalAIUser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, annotation := range output.ThreadComments {
|
for _, annotation := range output.ThreadComments {
|
||||||
if !validThreads[annotation.ThreadID] {
|
if !validThreads[annotation.ThreadID] {
|
||||||
continue
|
continue
|
||||||
|
|||||||
51
ai_test.go
51
ai_test.go
@@ -399,7 +399,7 @@ func TestExistingLocalAIFindingCanGainSuggestion(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
added, _ := state.Apply(
|
added, _ := state.Apply(
|
||||||
PRDetails{}, aiOutput{Findings: []aiFinding{finding}}, "fake", "model", "",
|
PRDetails{}, aiOutput{Findings: []aiFinding{finding}}, "fake", "model", "", "",
|
||||||
map[string]map[int]bool{"main.go": {2: true}}, nil, nil,
|
map[string]map[int]bool{"main.go": {2: true}}, nil, nil,
|
||||||
)
|
)
|
||||||
if added != 0 {
|
if added != 0 {
|
||||||
@@ -432,7 +432,9 @@ func TestAIStoreSkipsUnchangedWrites(t *testing.T) {
|
|||||||
func TestWithoutLocalAIDoesNotMutateVisibleDetails(t *testing.T) {
|
func TestWithoutLocalAIDoesNotMutateVisibleDetails(t *testing.T) {
|
||||||
remote := ReviewThread{
|
remote := ReviewThread{
|
||||||
ID: "remote", Comments: []ReviewComment{
|
ID: "remote", Comments: []ReviewComment{
|
||||||
{ID: "github"}, {ID: "local", Origin: reviewOriginLocalAI},
|
{ID: "github"},
|
||||||
|
{ID: "local", Origin: reviewOriginLocalAI},
|
||||||
|
{ID: "local-user", Origin: reviewOriginLocalAIUser},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
local := ReviewThread{ID: "local-thread", Origin: reviewOriginLocalAI}
|
local := ReviewThread{ID: "local-thread", Origin: reviewOriginLocalAI}
|
||||||
@@ -441,11 +443,54 @@ func TestWithoutLocalAIDoesNotMutateVisibleDetails(t *testing.T) {
|
|||||||
if len(clean.Threads) != 1 || len(clean.Threads[0].Comments) != 1 {
|
if len(clean.Threads) != 1 || len(clean.Threads[0].Comments) != 1 {
|
||||||
t.Fatalf("clean details = %#v", clean.Threads)
|
t.Fatalf("clean details = %#v", clean.Threads)
|
||||||
}
|
}
|
||||||
if len(pr.Threads) != 2 || len(pr.Threads[0].Comments) != 2 {
|
if len(pr.Threads) != 2 || len(pr.Threads[0].Comments) != 3 {
|
||||||
t.Fatal("filter mutated the visible PR details")
|
t.Fatal("filter mutated the visible PR details")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAIDiscussionStoresUserMessageBeforeProviderResponse(t *testing.T) {
|
||||||
|
state := &aiStoredState{
|
||||||
|
Version: 1, Annotations: make(map[string][]ReviewComment),
|
||||||
|
}
|
||||||
|
pr := PRDetails{Threads: []ReviewThread{{ID: "thread-1"}}}
|
||||||
|
output := aiOutput{ThreadComments: []struct {
|
||||||
|
ThreadID string `json:"thread_id"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}{{ThreadID: "thread-1", Body: "Provider response"}}}
|
||||||
|
|
||||||
|
_, added := state.Apply(
|
||||||
|
pr, output, "codex", "model", "thread-1", "User follow-up", nil, nil, nil,
|
||||||
|
)
|
||||||
|
comments := state.Annotations["thread-1"]
|
||||||
|
if added != 1 || len(comments) != 2 {
|
||||||
|
t.Fatalf("added=%d comments=%#v", added, comments)
|
||||||
|
}
|
||||||
|
if comments[0].Origin != reviewOriginLocalAIUser ||
|
||||||
|
comments[0].Author != "you" || comments[0].Body != "User follow-up" {
|
||||||
|
t.Fatalf("user message = %#v", comments[0])
|
||||||
|
}
|
||||||
|
if comments[1].Origin != reviewOriginLocalAI ||
|
||||||
|
comments[1].Body != "Provider response" {
|
||||||
|
t.Fatalf("provider response = %#v", comments[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIDiscussionUsesViewerGitHubLogin(t *testing.T) {
|
||||||
|
state := &aiStoredState{
|
||||||
|
Version: 1, Annotations: make(map[string][]ReviewComment),
|
||||||
|
}
|
||||||
|
pr := PRDetails{
|
||||||
|
ViewerLogin: "pablu",
|
||||||
|
Threads: []ReviewThread{{ID: "thread-1"}},
|
||||||
|
}
|
||||||
|
state.Apply(pr, aiOutput{}, "codex", "model", "thread-1", "Follow-up", nil, nil, nil)
|
||||||
|
comments := state.Annotations["thread-1"]
|
||||||
|
if len(comments) != 1 || comments[0].Author != "pablu" ||
|
||||||
|
comments[0].Origin != reviewOriginLocalAIUser {
|
||||||
|
t.Fatalf("local user comment = %#v", comments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReplyOnLocalAIThreadStartsInlineDiscussion(t *testing.T) {
|
func TestReplyOnLocalAIThreadStartsInlineDiscussion(t *testing.T) {
|
||||||
config := defaultAIConfig()
|
config := defaultAIConfig()
|
||||||
config.Enabled = true
|
config.Enabled = true
|
||||||
|
|||||||
36
ai_tui.go
36
ai_tui.go
@@ -342,7 +342,7 @@ func (m App) updateAI(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
||||||
m.aiInput += string(key.Runes)
|
m.aiInput += textInputKeyValue(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.scroll = m.detailMaxScroll()
|
m.scroll = m.detailMaxScroll()
|
||||||
@@ -391,7 +391,8 @@ func withoutLocalAI(pr PRDetails) PRDetails {
|
|||||||
func slicesDeleteLocalAIComments(comments []ReviewComment) []ReviewComment {
|
func slicesDeleteLocalAIComments(comments []ReviewComment) []ReviewComment {
|
||||||
result := comments[:0]
|
result := comments[:0]
|
||||||
for _, comment := range comments {
|
for _, comment := range comments {
|
||||||
if comment.Origin != reviewOriginLocalAI {
|
if comment.Origin != reviewOriginLocalAI &&
|
||||||
|
comment.Origin != reviewOriginLocalAIUser {
|
||||||
result = append(result, comment)
|
result = append(result, comment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,10 +442,9 @@ func (m App) viewAI() string {
|
|||||||
case aiDiscussion:
|
case aiDiscussion:
|
||||||
lines = append(lines, titleStyle.Render("Local AI discussion"), "",
|
lines = append(lines, titleStyle.Render("Local AI discussion"), "",
|
||||||
dimStyle.Render("This message stays local; only the configured model receives it."), "")
|
dimStyle.Render("This message stays local; only the configured model receives it."), "")
|
||||||
draft := m.aiInput + "│"
|
lines = append(lines, renderTextInput(
|
||||||
for _, source := range strings.Split(draft, "\n") {
|
m.aiInput, width-2, m.cursorOutput != nil,
|
||||||
lines = append(lines, strings.Split(ansi.Wordwrap(source, width-2, ""), "\n")...)
|
)...)
|
||||||
}
|
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
lines = append(lines, "", badStyle.Render(m.err.Error()))
|
lines = append(lines, "", badStyle.Render(m.err.Error()))
|
||||||
}
|
}
|
||||||
@@ -587,10 +587,14 @@ func renderAIProgressBar(width int, progress AIRunProgress, spinner int) string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func localAICommentBadge(comment ReviewComment) string {
|
func localAICommentBadge(comment ReviewComment) string {
|
||||||
if comment.Origin != reviewOriginLocalAI {
|
switch comment.Origin {
|
||||||
|
case reviewOriginLocalAI:
|
||||||
|
return " " + warnStyle.Render("[LOCAL AI · LOCAL ONLY]")
|
||||||
|
case reviewOriginLocalAIUser:
|
||||||
|
return " " + warnStyle.Render("[LOCAL ONLY]")
|
||||||
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return " " + warnStyle.Render("[LOCAL AI · LOCAL ONLY]")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m App) inlineAIDiscussionLines(width int) []detailLine {
|
func (m App) inlineAIDiscussionLines(width int) []detailLine {
|
||||||
@@ -603,17 +607,13 @@ func (m App) inlineAIDiscussionLines(width int) []detailLine {
|
|||||||
warnStyle.Render("[LOCAL ONLY]"),
|
warnStyle.Render("[LOCAL ONLY]"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
draft := m.aiInput + "│"
|
textWidth := max(1, width-5)
|
||||||
textWidth := max(1, width-4)
|
|
||||||
lineIndex := 0
|
lineIndex := 0
|
||||||
for _, sourceLine := range strings.Split(draft, "\n") {
|
for _, part := range renderTextInput(m.aiInput, textWidth, m.cursorOutput != nil) {
|
||||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, textWidth, ""), textWidth, false)
|
lines = append(lines, detailLine{
|
||||||
for _, part := range strings.Split(wrapped, "\n") {
|
rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part,
|
||||||
lines = append(lines, detailLine{
|
})
|
||||||
rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part,
|
lineIndex++
|
||||||
})
|
|
||||||
lineIndex++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
|
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
|
||||||
|
|||||||
@@ -56,11 +56,10 @@ func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) {
|
|||||||
},
|
},
|
||||||
BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true},
|
BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||||
}
|
}
|
||||||
command := m.startPREdit()
|
if command := m.startPREdit(); command == nil {
|
||||||
if command == nil {
|
|
||||||
t.Fatal("opening the editor did not request branches")
|
t.Fatal("opening the editor did not request branches")
|
||||||
}
|
}
|
||||||
updated, _ := m.Update(command())
|
updated, _ := m.Update(m.loadPREditBranches()())
|
||||||
m = updated.(App)
|
m = updated.(App)
|
||||||
m.prEditField = prEditBaseField
|
m.prEditField = prEditBaseField
|
||||||
m.prEditEditors[prEditBaseField] = newTextEditor("release", false)
|
m.prEditEditors[prEditBaseField] = newTextEditor("release", false)
|
||||||
@@ -79,7 +78,7 @@ func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) {
|
|||||||
|
|
||||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
|
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
m = updated.(App)
|
m = updated.(App)
|
||||||
if m.prEditField != prEditBodyField {
|
if m.prEditField != prEditReviewersField {
|
||||||
t.Fatalf("second tab did not advance: field=%d", m.prEditField)
|
t.Fatalf("second tab did not advance: field=%d", m.prEditField)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
cache.go
40
cache.go
@@ -164,6 +164,19 @@ func (c *CachedGitHubService) UpdatePullRequest(
|
|||||||
return writer.UpdatePullRequest(ctx, pullRequestID, update)
|
return writer.UpdatePullRequest(ctx, pullRequestID, update)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CachedGitHubService) UpdatePullRequestPeople(
|
||||||
|
ctx context.Context,
|
||||||
|
owner, repo string,
|
||||||
|
number int,
|
||||||
|
update PullRequestPeopleUpdate,
|
||||||
|
) (PullRequestPeople, error) {
|
||||||
|
writer, ok := c.remote.(GitHubPullRequestPeopleWriteService)
|
||||||
|
if !ok {
|
||||||
|
return PullRequestPeople{}, errors.New("GitHub service does not support updating pull request people")
|
||||||
|
}
|
||||||
|
return writer.UpdatePullRequestPeople(ctx, owner, repo, number, update)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CachedGitHubService) SetPullRequestAutoMerge(
|
func (c *CachedGitHubService) SetPullRequestAutoMerge(
|
||||||
ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool,
|
ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool,
|
||||||
) (*AutoMergeRequest, error) {
|
) (*AutoMergeRequest, error) {
|
||||||
@@ -209,6 +222,29 @@ func (c *CachedGitHubService) ListBranches(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CachedGitHubService) ListRepositoryUsers(
|
||||||
|
ctx context.Context, owner, repo string,
|
||||||
|
) ([]RepositoryUser, error) {
|
||||||
|
service, ok := c.remote.(GitHubRepositoryPeopleService)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("GitHub service does not support listing repository users")
|
||||||
|
}
|
||||||
|
users, err := service.ListRepositoryUsers(ctx, owner, repo)
|
||||||
|
if err == nil {
|
||||||
|
_ = c.write(c.repositoryUsersKey(owner, repo), users)
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
var cached cacheEnvelope[[]RepositoryUser]
|
||||||
|
if _, cacheErr := c.read(c.repositoryUsersKey(owner, repo), &cached); cacheErr == nil {
|
||||||
|
c.health.set(HealthComponent{
|
||||||
|
Name: "repository user cache", Level: healthWarning,
|
||||||
|
Summary: "using cached repository users", Detail: err.Error(), UpdatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
return cached.Value, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CachedGitHubService) EnrichPullRequest(
|
func (c *CachedGitHubService) EnrichPullRequest(
|
||||||
ctx context.Context, details PRDetails,
|
ctx context.Context, details PRDetails,
|
||||||
) PRDetailsEnrichment {
|
) PRDetailsEnrichment {
|
||||||
@@ -238,6 +274,10 @@ func (c *CachedGitHubService) branchesKey(owner, repo string) string {
|
|||||||
return fmt.Sprintf("branches:%s/%s", owner, repo)
|
return fmt.Sprintf("branches:%s/%s", owner, repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CachedGitHubService) repositoryUsersKey(owner, repo string) string {
|
||||||
|
return fmt.Sprintf("repository-users:%s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CachedGitHubService) file(key string) string {
|
func (c *CachedGitHubService) file(key string) string {
|
||||||
sum := sha256.Sum256([]byte(key))
|
sum := sha256.Sum256([]byte(key))
|
||||||
return filepath.Join(c.dir, hex.EncodeToString(sum[:])+".json")
|
return filepath.Join(c.dir, hex.EncodeToString(sum[:])+".json")
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type DisplayConfig struct {
|
|||||||
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
|
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
|
||||||
DashboardMode string `toml:"dashboard_mode"`
|
DashboardMode string `toml:"dashboard_mode"`
|
||||||
CompactReviews bool `toml:"compact_reviews"`
|
CompactReviews bool `toml:"compact_reviews"`
|
||||||
|
ViewerLabel string `toml:"viewer_label"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PathConfig struct {
|
type PathConfig struct {
|
||||||
@@ -105,6 +106,7 @@ func defaultConfig() Config {
|
|||||||
ThreadListWidthPercent: 33,
|
ThreadListWidthPercent: 33,
|
||||||
DashboardMode: "hotkey",
|
DashboardMode: "hotkey",
|
||||||
CompactReviews: true,
|
CompactReviews: true,
|
||||||
|
ViewerLabel: "login",
|
||||||
},
|
},
|
||||||
Paths: PathConfig{
|
Paths: PathConfig{
|
||||||
Scroll: false,
|
Scroll: false,
|
||||||
@@ -208,6 +210,11 @@ func validateConfig(config Config) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("display.dashboard_mode must be intermediate or hotkey")
|
return fmt.Errorf("display.dashboard_mode must be intermediate or hotkey")
|
||||||
}
|
}
|
||||||
|
switch config.Display.ViewerLabel {
|
||||||
|
case "login", "you":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("display.viewer_label must be login or you")
|
||||||
|
}
|
||||||
if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil {
|
if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ fold_resolved = false
|
|||||||
thread_list_width_percent = 45
|
thread_list_width_percent = 45
|
||||||
dashboard_mode = "hotkey"
|
dashboard_mode = "hotkey"
|
||||||
compact_reviews = false
|
compact_reviews = false
|
||||||
|
viewer_label = "you"
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
scroll = true
|
scroll = true
|
||||||
@@ -75,7 +76,7 @@ up = ["ctrl+k"]
|
|||||||
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
|
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
|
||||||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
|
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
|
||||||
got.Display.DashboardMode != "hotkey" ||
|
got.Display.DashboardMode != "hotkey" ||
|
||||||
got.Display.CompactReviews ||
|
got.Display.CompactReviews || got.Display.ViewerLabel != "you" ||
|
||||||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
|
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
|
||||||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
|
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
|
||||||
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
|
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
|
||||||
@@ -325,6 +326,14 @@ func TestValidateConfigRejectsInvalidDashboardMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateConfigRejectsInvalidViewerLabel(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
config.Display.ViewerLabel = "me"
|
||||||
|
if err := validateConfig(config); err == nil {
|
||||||
|
t.Fatal("unknown viewer label was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateConfigRejectsInvalidEditorMode(t *testing.T) {
|
func TestValidateConfigRejectsInvalidEditorMode(t *testing.T) {
|
||||||
config := defaultConfig()
|
config := defaultConfig()
|
||||||
config.Editing.Mode = "emacs"
|
config.Editing.Mode = "emacs"
|
||||||
|
|||||||
10
drafts.go
10
drafts.go
@@ -22,6 +22,9 @@ type savedDraft struct {
|
|||||||
Reply string `json:"reply,omitempty"`
|
Reply string `json:"reply,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
BaseRef string `json:"base_ref,omitempty"`
|
BaseRef string `json:"base_ref,omitempty"`
|
||||||
|
Reviewers string `json:"reviewers,omitempty"`
|
||||||
|
Assignees string `json:"assignees,omitempty"`
|
||||||
|
PeopleSet bool `json:"people_set,omitempty"`
|
||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
OriginalUpdatedAt time.Time `json:"original_updated_at,omitempty"`
|
OriginalUpdatedAt time.Time `json:"original_updated_at,omitempty"`
|
||||||
SavedAt time.Time `json:"saved_at"`
|
SavedAt time.Time `json:"saved_at"`
|
||||||
@@ -182,6 +185,10 @@ func (m *App) restorePREditDraft() {
|
|||||||
}
|
}
|
||||||
m.prEditEditors[prEditTitleField] = newTextEditor(draft.Title, false)
|
m.prEditEditors[prEditTitleField] = newTextEditor(draft.Title, false)
|
||||||
m.prEditEditors[prEditBaseField] = newTextEditor(draft.BaseRef, false)
|
m.prEditEditors[prEditBaseField] = newTextEditor(draft.BaseRef, false)
|
||||||
|
if draft.PeopleSet {
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor(draft.Reviewers, false)
|
||||||
|
m.prEditEditors[prEditAssigneesField] = newTextEditor(draft.Assignees, false)
|
||||||
|
}
|
||||||
m.prEditEditors[prEditBodyField] = newTextEditor(
|
m.prEditEditors[prEditBodyField] = newTextEditor(
|
||||||
normalizeLineEndings(draft.Body), m.editorMode == "vim",
|
normalizeLineEndings(draft.Body), m.editorMode == "vim",
|
||||||
)
|
)
|
||||||
@@ -201,6 +208,9 @@ func (m *App) queuePREditDraft() tea.Cmd {
|
|||||||
Kind: "pr-metadata", Owner: m.details.Owner, Repository: m.details.Repository,
|
Kind: "pr-metadata", Owner: m.details.Owner, Repository: m.details.Repository,
|
||||||
Number: m.details.Number, Title: m.prEditEditors[prEditTitleField].Text,
|
Number: m.details.Number, Title: m.prEditEditors[prEditTitleField].Text,
|
||||||
BaseRef: m.prEditEditors[prEditBaseField].Text,
|
BaseRef: m.prEditEditors[prEditBaseField].Text,
|
||||||
|
Reviewers: m.prEditEditors[prEditReviewersField].Text,
|
||||||
|
Assignees: m.prEditEditors[prEditAssigneesField].Text,
|
||||||
|
PeopleSet: true,
|
||||||
Body: m.prEditEditors[prEditBodyField].Text,
|
Body: m.prEditEditors[prEditBodyField].Text,
|
||||||
OriginalUpdatedAt: m.prEditOriginal.UpdatedAt,
|
OriginalUpdatedAt: m.prEditOriginal.UpdatedAt,
|
||||||
})
|
})
|
||||||
|
|||||||
109
github.go
109
github.go
@@ -30,6 +30,12 @@ type GitHubPullRequestWriteService interface {
|
|||||||
UpdatePullRequest(context.Context, string, PullRequestMetadata) (PullRequestMetadata, error)
|
UpdatePullRequest(context.Context, string, PullRequestMetadata) (PullRequestMetadata, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GitHubPullRequestPeopleWriteService interface {
|
||||||
|
UpdatePullRequestPeople(
|
||||||
|
context.Context, string, string, int, PullRequestPeopleUpdate,
|
||||||
|
) (PullRequestPeople, error)
|
||||||
|
}
|
||||||
|
|
||||||
type GitHubMergeService interface {
|
type GitHubMergeService interface {
|
||||||
SetPullRequestAutoMerge(context.Context, string, string, string, bool) (*AutoMergeRequest, error)
|
SetPullRequestAutoMerge(context.Context, string, string, string, bool) (*AutoMergeRequest, error)
|
||||||
MergePullRequest(context.Context, string, string, string) (PullRequestMergeResult, error)
|
MergePullRequest(context.Context, string, string, string) (PullRequestMergeResult, error)
|
||||||
@@ -39,6 +45,10 @@ type GitHubBranchService interface {
|
|||||||
ListBranches(context.Context, string, string) ([]RepositoryBranch, error)
|
ListBranches(context.Context, string, string) ([]RepositoryBranch, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GitHubRepositoryPeopleService interface {
|
||||||
|
ListRepositoryUsers(context.Context, string, string) ([]RepositoryUser, error)
|
||||||
|
}
|
||||||
|
|
||||||
type GitHubEnrichmentService interface {
|
type GitHubEnrichmentService interface {
|
||||||
EnrichPullRequest(context.Context, PRDetails) PRDetailsEnrichment
|
EnrichPullRequest(context.Context, PRDetails) PRDetailsEnrichment
|
||||||
}
|
}
|
||||||
@@ -433,6 +443,7 @@ func nullableCursor(cursor string) any {
|
|||||||
|
|
||||||
const detailsQuery = `
|
const detailsQuery = `
|
||||||
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
||||||
|
viewer { login }
|
||||||
repository(owner: $owner, name: $name) {
|
repository(owner: $owner, name: $name) {
|
||||||
url mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed
|
url mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed
|
||||||
viewerPermission
|
viewerPermission
|
||||||
@@ -463,7 +474,10 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
author { login }
|
author { login }
|
||||||
assignees(first: 20) { nodes { login } }
|
assignees(first: 100) {
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
nodes { id login name }
|
||||||
|
}
|
||||||
labels(first: 20) { nodes { name } }
|
labels(first: 20) { nodes { name } }
|
||||||
milestone { title }
|
milestone { title }
|
||||||
additions deletions changedFiles
|
additions deletions changedFiles
|
||||||
@@ -564,6 +578,18 @@ query TimelinePage($owner: String!, $name: String!, $number: Int!, $after: Strin
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
const assigneesPageQuery = `
|
||||||
|
query AssigneesPage($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
pullRequest(number: $number) {
|
||||||
|
assignees(first: 100, after: $after) {
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
nodes { id login name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
const checkContextsPageQuery = `
|
const checkContextsPageQuery = `
|
||||||
query CheckContextsPage($id: ID!, $after: String) {
|
query CheckContextsPage($id: ID!, $after: String) {
|
||||||
node(id: $id) {
|
node(id: $id) {
|
||||||
@@ -721,6 +747,11 @@ type githubPRCommentConnection struct {
|
|||||||
Nodes []githubPRComment `json:"nodes"`
|
Nodes []githubPRComment `json:"nodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type githubUserConnection struct {
|
||||||
|
PageInfo githubPageInfo `json:"pageInfo"`
|
||||||
|
Nodes []githubActor `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
type githubReviewSummary struct {
|
type githubReviewSummary struct {
|
||||||
ID, Body, State, URL string
|
ID, Body, State, URL string
|
||||||
SubmittedAt time.Time
|
SubmittedAt time.Time
|
||||||
@@ -818,10 +849,8 @@ type githubPullRequestDetails struct {
|
|||||||
Position, EstimatedTimeToMerge int
|
Position, EstimatedTimeToMerge int
|
||||||
EnqueuedAt time.Time
|
EnqueuedAt time.Time
|
||||||
}
|
}
|
||||||
Assignees struct {
|
Assignees githubUserConnection
|
||||||
Nodes []githubActor `json:"nodes"`
|
Labels struct {
|
||||||
}
|
|
||||||
Labels struct {
|
|
||||||
Nodes []struct {
|
Nodes []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"nodes"`
|
} `json:"nodes"`
|
||||||
@@ -953,6 +982,36 @@ func (c *GitHubClient) allConversationComments(
|
|||||||
return nodes, nil
|
return nodes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) allAssignees(
|
||||||
|
ctx context.Context, owner, name string, number int, connection githubUserConnection,
|
||||||
|
) ([]githubActor, error) {
|
||||||
|
nodes := append([]githubActor(nil), connection.Nodes...)
|
||||||
|
for pages := 0; connection.PageInfo.HasNextPage; pages++ {
|
||||||
|
if pages >= 100 {
|
||||||
|
return nil, errors.New("assignee pagination exceeded 100 pages")
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Repository *struct {
|
||||||
|
PullRequest *struct {
|
||||||
|
Assignees githubUserConnection `json:"assignees"`
|
||||||
|
} `json:"pullRequest"`
|
||||||
|
} `json:"repository"`
|
||||||
|
}
|
||||||
|
if err := c.query(ctx, assigneesPageQuery, map[string]any{
|
||||||
|
"owner": owner, "name": name, "number": number,
|
||||||
|
"after": connection.PageInfo.EndCursor,
|
||||||
|
}, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("load more assignees: %w", err)
|
||||||
|
}
|
||||||
|
if data.Repository == nil || data.Repository.PullRequest == nil {
|
||||||
|
return nil, errors.New("pull request disappeared while loading assignees")
|
||||||
|
}
|
||||||
|
connection = data.Repository.PullRequest.Assignees
|
||||||
|
nodes = append(nodes, connection.Nodes...)
|
||||||
|
}
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *GitHubClient) allReviewSummaries(
|
func (c *GitHubClient) allReviewSummaries(
|
||||||
ctx context.Context, owner, name string, number int, connection githubReviewSummaryConnection,
|
ctx context.Context, owner, name string, number int, connection githubReviewSummaryConnection,
|
||||||
) ([]githubReviewSummary, error) {
|
) ([]githubReviewSummary, error) {
|
||||||
@@ -1092,6 +1151,7 @@ func (c *GitHubClient) allCheckAnnotations(
|
|||||||
|
|
||||||
func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) {
|
func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) {
|
||||||
var data struct {
|
var data struct {
|
||||||
|
Viewer githubActor
|
||||||
Repository *struct {
|
Repository *struct {
|
||||||
URL string
|
URL string
|
||||||
ViewerPermission string `json:"viewerPermission"`
|
ViewerPermission string `json:"viewerPermission"`
|
||||||
@@ -1110,22 +1170,30 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
node := data.Repository.PullRequest
|
node := data.Repository.PullRequest
|
||||||
var (
|
var (
|
||||||
threadNodes []githubReviewThread
|
threadNodes []githubReviewThread
|
||||||
|
assigneeNodes []githubActor
|
||||||
conversationNodes []githubPRComment
|
conversationNodes []githubPRComment
|
||||||
reviewNodes []githubReviewSummary
|
reviewNodes []githubReviewSummary
|
||||||
timelineNodes []githubTimelineNode
|
timelineNodes []githubTimelineNode
|
||||||
checkNodes []githubCheckContext
|
checkNodes []githubCheckContext
|
||||||
threadErr error
|
threadErr error
|
||||||
|
assigneeErr error
|
||||||
conversationErr error
|
conversationErr error
|
||||||
reviewErr error
|
reviewErr error
|
||||||
timelineErr error
|
timelineErr error
|
||||||
checkErr error
|
checkErr error
|
||||||
wait sync.WaitGroup
|
wait sync.WaitGroup
|
||||||
)
|
)
|
||||||
wait.Add(4)
|
wait.Add(5)
|
||||||
go func() {
|
go func() {
|
||||||
defer wait.Done()
|
defer wait.Done()
|
||||||
threadNodes, threadErr = c.allReviewThreads(ctx, owner, name, number, node.ReviewThreads)
|
threadNodes, threadErr = c.allReviewThreads(ctx, owner, name, number, node.ReviewThreads)
|
||||||
}()
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
assigneeNodes, assigneeErr = c.allAssignees(
|
||||||
|
ctx, owner, name, number, node.Assignees,
|
||||||
|
)
|
||||||
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wait.Done()
|
defer wait.Done()
|
||||||
conversationNodes, conversationErr = c.allConversationComments(ctx, owner, name, number, node.Comments)
|
conversationNodes, conversationErr = c.allConversationComments(ctx, owner, name, number, node.Comments)
|
||||||
@@ -1150,6 +1218,9 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
if threadErr != nil {
|
if threadErr != nil {
|
||||||
threadNodes = append([]githubReviewThread(nil), node.ReviewThreads.Nodes...)
|
threadNodes = append([]githubReviewThread(nil), node.ReviewThreads.Nodes...)
|
||||||
}
|
}
|
||||||
|
if assigneeErr != nil {
|
||||||
|
assigneeNodes = append([]githubActor(nil), node.Assignees.Nodes...)
|
||||||
|
}
|
||||||
if conversationErr != nil {
|
if conversationErr != nil {
|
||||||
conversationNodes = append([]githubPRComment(nil), node.Comments.Nodes...)
|
conversationNodes = append([]githubPRComment(nil), node.Comments.Nodes...)
|
||||||
}
|
}
|
||||||
@@ -1171,9 +1242,10 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
ID: node.ID, Owner: owner, Repository: name, RepoWithOwner: owner + "/" + name,
|
ID: node.ID, Owner: owner, Repository: name, RepoWithOwner: owner + "/" + name,
|
||||||
Number: node.Number, Title: node.Title, URL: node.URL,
|
Number: node.Number, Title: node.Title, URL: node.URL,
|
||||||
Author: actorLogin(node.Author), IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt,
|
Author: actorLogin(node.Author), IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt,
|
||||||
ReviewCount: len(threadNodes),
|
ReviewCount: len(threadNodes), ViewerAuthored: actorLogin(node.Author) == data.Viewer.Login,
|
||||||
},
|
},
|
||||||
Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
|
ViewerLogin: data.Viewer.Login,
|
||||||
|
Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
|
||||||
HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
|
HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
|
||||||
State: node.State, Merged: node.Merged, MergedAt: node.MergedAt,
|
State: node.State, Merged: node.Merged, MergedAt: node.MergedAt,
|
||||||
RepositoryURL: data.Repository.URL,
|
RepositoryURL: data.Repository.URL,
|
||||||
@@ -1183,6 +1255,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
Permissions: ViewerPermissions{
|
Permissions: ViewerPermissions{
|
||||||
Repository: data.Repository.ViewerPermission,
|
Repository: data.Repository.ViewerPermission,
|
||||||
CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact,
|
CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact,
|
||||||
|
CanAssign: viewerCanAssign(data.Repository.ViewerPermission),
|
||||||
CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge,
|
CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge,
|
||||||
CanDisableMerge: node.ViewerCanDisableAutoMerge,
|
CanDisableMerge: node.ViewerCanDisableAutoMerge,
|
||||||
},
|
},
|
||||||
@@ -1208,7 +1281,8 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
}
|
}
|
||||||
for component, err := range map[string]error{
|
for component, err := range map[string]error{
|
||||||
"review threads": threadErr, "conversation": conversationErr,
|
"review threads": threadErr, "conversation": conversationErr,
|
||||||
"submitted reviews": reviewErr, "timeline": timelineErr, "checks": checkErr,
|
"submitted reviews": reviewErr, "assignees": assigneeErr,
|
||||||
|
"timeline": timelineErr, "checks": checkErr,
|
||||||
} {
|
} {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
details.DataIssues = append(details.DataIssues, DataIssue{
|
details.DataIssues = append(details.DataIssues, DataIssue{
|
||||||
@@ -1257,7 +1331,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
if node.Milestone != nil {
|
if node.Milestone != nil {
|
||||||
details.Milestone = node.Milestone.Title
|
details.Milestone = node.Milestone.Title
|
||||||
}
|
}
|
||||||
for _, assignee := range node.Assignees.Nodes {
|
for _, assignee := range assigneeNodes {
|
||||||
details.Assignees = append(details.Assignees, assignee.Login)
|
details.Assignees = append(details.Assignees, assignee.Login)
|
||||||
}
|
}
|
||||||
reviewers := map[string]string{}
|
reviewers := map[string]string{}
|
||||||
@@ -1266,6 +1340,11 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
if login != "" {
|
if login != "" {
|
||||||
reviewers[login] = "REVIEW_REQUESTED"
|
reviewers[login] = "REVIEW_REQUESTED"
|
||||||
}
|
}
|
||||||
|
if request.RequestedReviewer.Login != "" {
|
||||||
|
details.RequestedReviewers = append(
|
||||||
|
details.RequestedReviewers, request.RequestedReviewer.Login,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, review := range node.LatestReviews.Nodes {
|
for _, review := range node.LatestReviews.Nodes {
|
||||||
if review.Author != nil {
|
if review.Author != nil {
|
||||||
@@ -1276,6 +1355,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
details.Reviewers = append(details.Reviewers, Reviewer{Login: login, State: state})
|
details.Reviewers = append(details.Reviewers, Reviewer{Login: login, State: state})
|
||||||
}
|
}
|
||||||
sort.Slice(details.Reviewers, func(i, j int) bool { return details.Reviewers[i].Login < details.Reviewers[j].Login })
|
sort.Slice(details.Reviewers, func(i, j int) bool { return details.Reviewers[i].Login < details.Reviewers[j].Login })
|
||||||
|
sort.Strings(details.RequestedReviewers)
|
||||||
if len(node.Commits.Nodes) > 0 && node.Commits.Nodes[0].Commit.StatusCheckRollup != nil {
|
if len(node.Commits.Nodes) > 0 && node.Commits.Nodes[0].Commit.StatusCheckRollup != nil {
|
||||||
rollup := node.Commits.Nodes[0].Commit.StatusCheckRollup
|
rollup := node.Commits.Nodes[0].Commit.StatusCheckRollup
|
||||||
details.CheckState = rollup.State
|
details.CheckState = rollup.State
|
||||||
@@ -1643,6 +1723,15 @@ func actorLogin(actor *githubActor) string {
|
|||||||
return actor.Login
|
return actor.Login
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func viewerCanAssign(permission string) bool {
|
||||||
|
switch strings.ToUpper(permission) {
|
||||||
|
case "TRIAGE", "WRITE", "MAINTAIN", "ADMIN":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func intValue(value *int) int {
|
func intValue(value *int) int {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
328
github_people.go
Normal file
328
github_people.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const repositoryReviewersQuery = `
|
||||||
|
query RepositoryReviewers($owner: String!, $name: String!, $after: String) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
collaborators(first: 100, after: $after, affiliation: ALL) {
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
nodes { id login name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const repositoryAssigneesQuery = `
|
||||||
|
query RepositoryAssignees($owner: String!, $name: String!, $after: String) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
assignableUsers(first: 100, after: $after) {
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
nodes { id login name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const repositoryContributionsQuery = `
|
||||||
|
query RepositoryContributions($owner: String!, $name: String!) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
defaultBranchRef {
|
||||||
|
target {
|
||||||
|
... on Commit {
|
||||||
|
history(first: 100) {
|
||||||
|
nodes {
|
||||||
|
committedDate
|
||||||
|
additions
|
||||||
|
author { user { login } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
type repositoryUserNode struct {
|
||||||
|
ID, Login, Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type repositoryUserConnection struct {
|
||||||
|
PageInfo githubPageInfo
|
||||||
|
Nodes []repositoryUserNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type repositoryContribution struct {
|
||||||
|
CommittedDate time.Time
|
||||||
|
Additions int
|
||||||
|
Author struct {
|
||||||
|
User *githubActor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) ListRepositoryUsers(
|
||||||
|
ctx context.Context, owner, repo string,
|
||||||
|
) ([]RepositoryUser, error) {
|
||||||
|
reviewers, err := c.listRepositoryUserConnection(
|
||||||
|
ctx, repositoryReviewersQuery, "collaborators", owner, repo,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list eligible reviewers: %w", err)
|
||||||
|
}
|
||||||
|
assignees, err := c.listRepositoryUserConnection(
|
||||||
|
ctx, repositoryAssigneesQuery, "assignableUsers", owner, repo,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list eligible assignees: %w", err)
|
||||||
|
}
|
||||||
|
users := make(map[string]RepositoryUser, len(reviewers)+len(assignees))
|
||||||
|
for _, reviewer := range reviewers {
|
||||||
|
key := strings.ToLower(reviewer.Login)
|
||||||
|
users[key] = RepositoryUser{
|
||||||
|
ID: reviewer.ID, Login: reviewer.Login, Name: reviewer.Name, CanReview: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, assignee := range assignees {
|
||||||
|
key := strings.ToLower(assignee.Login)
|
||||||
|
user := users[key]
|
||||||
|
if user.ID == "" {
|
||||||
|
user.ID, user.Login, user.Name = assignee.ID, assignee.Login, assignee.Name
|
||||||
|
}
|
||||||
|
user.CanAssign = true
|
||||||
|
users[key] = user
|
||||||
|
}
|
||||||
|
if contributions, contributionErr := c.listRecentContributions(ctx, owner, repo); contributionErr == nil {
|
||||||
|
for _, contribution := range contributions {
|
||||||
|
if contribution.Author.User == nil || contribution.Author.User.Login == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(contribution.Author.User.Login)
|
||||||
|
user, exists := users[key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user.RecentCommits++
|
||||||
|
user.RecentAdditions += max(0, contribution.Additions)
|
||||||
|
if contribution.CommittedDate.After(user.LastContributionAt) {
|
||||||
|
user.LastContributionAt = contribution.CommittedDate
|
||||||
|
}
|
||||||
|
users[key] = user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := make([]RepositoryUser, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
result = append(result, user)
|
||||||
|
}
|
||||||
|
sortRepositoryUsers(result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) listRecentContributions(
|
||||||
|
ctx context.Context, owner, repo string,
|
||||||
|
) ([]repositoryContribution, error) {
|
||||||
|
var data struct {
|
||||||
|
Repository *struct {
|
||||||
|
DefaultBranchRef *struct {
|
||||||
|
Target *struct {
|
||||||
|
History struct {
|
||||||
|
Nodes []repositoryContribution
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.query(ctx, repositoryContributionsQuery, map[string]any{
|
||||||
|
"owner": owner, "name": repo,
|
||||||
|
}, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if data.Repository == nil {
|
||||||
|
return nil, errors.New("repository was not found")
|
||||||
|
}
|
||||||
|
if data.Repository.DefaultBranchRef == nil ||
|
||||||
|
data.Repository.DefaultBranchRef.Target == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return data.Repository.DefaultBranchRef.Target.History.Nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) listRepositoryUserConnection(
|
||||||
|
ctx context.Context, query, field, owner, repo string,
|
||||||
|
) ([]repositoryUserNode, error) {
|
||||||
|
var result []repositoryUserNode
|
||||||
|
cursor := ""
|
||||||
|
for {
|
||||||
|
var data struct {
|
||||||
|
Repository *struct {
|
||||||
|
Collaborators repositoryUserConnection
|
||||||
|
AssignableUsers repositoryUserConnection `json:"assignableUsers"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.query(ctx, query, map[string]any{
|
||||||
|
"owner": owner, "name": repo, "after": nullableCursor(cursor),
|
||||||
|
}, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if data.Repository == nil {
|
||||||
|
return nil, errors.New("repository was not found")
|
||||||
|
}
|
||||||
|
connection := data.Repository.Collaborators
|
||||||
|
if field == "assignableUsers" {
|
||||||
|
connection = data.Repository.AssignableUsers
|
||||||
|
}
|
||||||
|
result = append(result, connection.Nodes...)
|
||||||
|
if !connection.PageInfo.HasNextPage {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if connection.PageInfo.EndCursor == "" || connection.PageInfo.EndCursor == cursor {
|
||||||
|
return nil, errors.New("GitHub returned an empty user pagination cursor")
|
||||||
|
}
|
||||||
|
cursor = connection.PageInfo.EndCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortRepositoryUsers(users []RepositoryUser) {
|
||||||
|
slices.SortStableFunc(users, func(left, right RepositoryUser) int {
|
||||||
|
return strings.Compare(strings.ToLower(left.Login), strings.ToLower(right.Login))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) UpdatePullRequestPeople(
|
||||||
|
ctx context.Context,
|
||||||
|
owner, repo string,
|
||||||
|
number int,
|
||||||
|
update PullRequestPeopleUpdate,
|
||||||
|
) (PullRequestPeople, error) {
|
||||||
|
added, removed := loginDifference(update.Reviewers, update.CurrentReviewers),
|
||||||
|
loginDifference(update.CurrentReviewers, update.Reviewers)
|
||||||
|
result := PullRequestPeople{
|
||||||
|
Reviewers: append([]string(nil), update.CurrentReviewers...),
|
||||||
|
Assignees: append([]string(nil), update.CurrentAssignees...),
|
||||||
|
}
|
||||||
|
if len(added) > 0 {
|
||||||
|
if err := c.updateReviewRequests(ctx, http.MethodPost, owner, repo, number, added); err != nil {
|
||||||
|
return result, fmt.Errorf("add reviewers: %w", err)
|
||||||
|
}
|
||||||
|
result.Reviewers = normalizedLogins(append(result.Reviewers, added...))
|
||||||
|
}
|
||||||
|
if len(removed) > 0 {
|
||||||
|
if err := c.updateReviewRequests(ctx, http.MethodDelete, owner, repo, number, removed); err != nil {
|
||||||
|
return result, fmt.Errorf("remove reviewers: %w", err)
|
||||||
|
}
|
||||||
|
result.Reviewers = append([]string(nil), update.Reviewers...)
|
||||||
|
}
|
||||||
|
if !equalLoginSets(update.Assignees, update.CurrentAssignees) {
|
||||||
|
if err := c.replaceAssignees(ctx, owner, repo, number, update.Assignees); err != nil {
|
||||||
|
return result, fmt.Errorf("update assignees: %w", err)
|
||||||
|
}
|
||||||
|
result.Assignees = append([]string(nil), update.Assignees...)
|
||||||
|
}
|
||||||
|
result.Reviewers = append([]string(nil), update.Reviewers...)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalLoginSets(left, right []string) bool {
|
||||||
|
return len(loginDifference(left, right)) == 0 &&
|
||||||
|
len(loginDifference(right, left)) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginDifference(left, right []string) []string {
|
||||||
|
existing := make(map[string]bool, len(right))
|
||||||
|
for _, login := range right {
|
||||||
|
existing[strings.ToLower(login)] = true
|
||||||
|
}
|
||||||
|
var result []string
|
||||||
|
for _, login := range left {
|
||||||
|
if !existing[strings.ToLower(login)] {
|
||||||
|
result = append(result, login)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) updateReviewRequests(
|
||||||
|
ctx context.Context, method, owner, repo string, number int, reviewers []string,
|
||||||
|
) error {
|
||||||
|
return c.restJSON(
|
||||||
|
ctx, method,
|
||||||
|
"/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+
|
||||||
|
"/pulls/"+strconv.Itoa(number)+"/requested_reviewers",
|
||||||
|
map[string]any{"reviewers": reviewers}, nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) replaceAssignees(
|
||||||
|
ctx context.Context, owner, repo string, number int, assignees []string,
|
||||||
|
) error {
|
||||||
|
return c.restJSON(
|
||||||
|
ctx, http.MethodPatch,
|
||||||
|
"/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+
|
||||||
|
"/issues/"+strconv.Itoa(number),
|
||||||
|
map[string]any{"assignees": assignees}, nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) restJSON(
|
||||||
|
ctx context.Context, method, requestPath string, input, output any,
|
||||||
|
) error {
|
||||||
|
var body io.Reader
|
||||||
|
if input != nil {
|
||||||
|
encoded, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(encoded)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(
|
||||||
|
ctx, method, c.restBaseURL()+requestPath, body,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
request.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("User-Agent", "diple")
|
||||||
|
response, err := c.http.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||||
|
return fmt.Errorf(
|
||||||
|
"GitHub returned %s: %s",
|
||||||
|
response.Status, strings.TrimSpace(string(data)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if output == nil || response.StatusCode == http.StatusNoContent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(output); err != nil {
|
||||||
|
return fmt.Errorf("decode GitHub response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) restBaseURL() string {
|
||||||
|
base := strings.TrimSuffix(c.endpoint, "/")
|
||||||
|
switch {
|
||||||
|
case base == "https://api.github.com/graphql":
|
||||||
|
return "https://api.github.com"
|
||||||
|
case strings.HasSuffix(base, "/api/graphql"):
|
||||||
|
return strings.TrimSuffix(base, "/api/graphql") + "/api/v3"
|
||||||
|
default:
|
||||||
|
return strings.TrimSuffix(base, "/graphql")
|
||||||
|
}
|
||||||
|
}
|
||||||
179
github_people_test.go
Normal file
179
github_people_test.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListRepositoryUsersCombinesReviewerAndAssigneeEligibility(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request graphQLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(request.Query, "RepositoryReviewers"):
|
||||||
|
if request.Variables["after"] == nil {
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{"collaborators":{
|
||||||
|
"pageInfo":{"hasNextPage":true,"endCursor":"next"},
|
||||||
|
"nodes":[
|
||||||
|
{"id":"u1","login":"alice","name":"Alice"},
|
||||||
|
{"id":"u2","login":"bob","name":"Bob"}
|
||||||
|
]
|
||||||
|
}}}}`))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{"collaborators":{
|
||||||
|
"pageInfo":{"hasNextPage":false},
|
||||||
|
"nodes":[{"id":"u4","login":"dave","name":"Dave"}]
|
||||||
|
}}}}`))
|
||||||
|
}
|
||||||
|
case strings.Contains(request.Query, "RepositoryAssignees"):
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{"assignableUsers":{
|
||||||
|
"pageInfo":{"hasNextPage":false},
|
||||||
|
"nodes":[
|
||||||
|
{"id":"u2","login":"bob","name":"Bob"},
|
||||||
|
{"id":"u3","login":"carol","name":"Carol"}
|
||||||
|
]
|
||||||
|
}}}}`))
|
||||||
|
case strings.Contains(request.Query, "RepositoryContributions"):
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{"defaultBranchRef":{"target":{"history":{
|
||||||
|
"nodes":[
|
||||||
|
{"committedDate":"2026-07-28T12:00:00Z","additions":42,
|
||||||
|
"author":{"user":{"login":"alice"}}},
|
||||||
|
{"committedDate":"2026-07-27T12:00:00Z","additions":8,
|
||||||
|
"author":{"user":{"login":"alice"}}},
|
||||||
|
{"committedDate":"2026-06-01T12:00:00Z","additions":5,
|
||||||
|
"author":{"user":{"login":"bob"}}}
|
||||||
|
]
|
||||||
|
}}}}}}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected query: %s", request.Query)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "token")
|
||||||
|
users, err := client.ListRepositoryUsers(context.Background(), "o", "r")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(users) != 4 || users[0].Login != "alice" || !users[0].CanReview ||
|
||||||
|
users[0].CanAssign || users[1].Login != "bob" ||
|
||||||
|
!users[1].CanReview || !users[1].CanAssign ||
|
||||||
|
users[2].Login != "carol" || users[2].CanReview || !users[2].CanAssign ||
|
||||||
|
users[3].Login != "dave" || !users[3].CanReview {
|
||||||
|
t.Fatalf("repository users = %#v", users)
|
||||||
|
}
|
||||||
|
if users[0].RecentCommits != 2 || users[0].RecentAdditions != 50 ||
|
||||||
|
users[0].LastContributionAt.IsZero() || users[1].RecentCommits != 1 {
|
||||||
|
t.Fatalf("repository activity = %#v", users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllAssigneesPaginates(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request graphQLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(request.Query, "AssigneesPage") {
|
||||||
|
t.Fatalf("unexpected query: %s", request.Query)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"assignees":{
|
||||||
|
"pageInfo":{"hasNextPage":false},
|
||||||
|
"nodes":[{"id":"u2","login":"bob","name":"Bob"}]
|
||||||
|
}}}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "token")
|
||||||
|
users, err := client.allAssignees(
|
||||||
|
context.Background(), "o", "r", 1,
|
||||||
|
githubUserConnection{
|
||||||
|
PageInfo: githubPageInfo{HasNextPage: true, EndCursor: "next"},
|
||||||
|
Nodes: []githubActor{{Login: "alice"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(users) != 2 || users[0].Login != "alice" || users[1].Login != "bob" {
|
||||||
|
t.Fatalf("assignees = %#v", users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePullRequestPeoplePreservesUnchangedReviewers(t *testing.T) {
|
||||||
|
var requests []string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input struct {
|
||||||
|
Reviewers []string `json:"reviewers"`
|
||||||
|
Assignees []string `json:"assignees"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/requested_reviewers"):
|
||||||
|
requests = append(requests, r.Method+":"+strings.Join(input.Reviewers, ","))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/issues/7"):
|
||||||
|
requests = append(requests, r.Method+":"+strings.Join(input.Assignees, ","))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected REST path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL+"/api/graphql", "token")
|
||||||
|
people, err := client.UpdatePullRequestPeople(
|
||||||
|
context.Background(), "o", "r", 7,
|
||||||
|
PullRequestPeopleUpdate{
|
||||||
|
CurrentReviewers: []string{"keep", "remove"},
|
||||||
|
CurrentAssignees: []string{"alice"},
|
||||||
|
Reviewers: []string{"keep", "add"},
|
||||||
|
Assignees: []string{"alice", "bob"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"POST:add", "DELETE:remove", "PATCH:alice,bob"}
|
||||||
|
if !slices.Equal(requests, want) {
|
||||||
|
t.Fatalf("REST requests = %v, want %v", requests, want)
|
||||||
|
}
|
||||||
|
if !slices.Equal(people.Reviewers, []string{"keep", "add"}) ||
|
||||||
|
!slices.Equal(people.Assignees, []string{"alice", "bob"}) {
|
||||||
|
t.Fatalf("updated people = %#v", people)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePullRequestPeopleReportsSuccessfulPartialChanges(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodDelete {
|
||||||
|
http.Error(w, "cannot remove", http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL+"/api/graphql", "token")
|
||||||
|
people, err := client.UpdatePullRequestPeople(
|
||||||
|
context.Background(), "o", "r", 7,
|
||||||
|
PullRequestPeopleUpdate{
|
||||||
|
CurrentReviewers: []string{"keep", "remove"},
|
||||||
|
Reviewers: []string{"keep", "add"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "remove reviewers") {
|
||||||
|
t.Fatalf("partial update error = %v", err)
|
||||||
|
}
|
||||||
|
if !equalLoginSets(people.Reviewers, []string{"keep", "remove", "add"}) {
|
||||||
|
t.Fatalf("partial reviewer state = %#v", people.Reviewers)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -454,7 +454,7 @@ func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing
|
|||||||
"nodes":[{"id":"review-2","body":"approved","state":"APPROVED","submittedAt":"2026-01-03T00:00:00Z"}]
|
"nodes":[{"id":"review-2","body":"approved","state":"APPROVED","submittedAt":"2026-01-03T00:00:00Z"}]
|
||||||
}}}}}`))
|
}}}}}`))
|
||||||
default:
|
default:
|
||||||
_, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE","pullRequest":{
|
_, _ = w.Write([]byte(`{"data":{"viewer":{"login":"current-user"},"repository":{"viewerPermission":"WRITE","pullRequest":{
|
||||||
"id":"pr","number":1,"title":"PR","url":"u","createdAt":"2026-01-01T00:00:00Z",
|
"id":"pr","number":1,"title":"PR","url":"u","createdAt":"2026-01-01T00:00:00Z",
|
||||||
"updatedAt":"2026-01-01T00:00:00Z","author":{"login":"alice"},
|
"updatedAt":"2026-01-01T00:00:00Z","author":{"login":"alice"},
|
||||||
"assignees":{"nodes":[]},"labels":{"nodes":[]},"reviewRequests":{"nodes":[]},
|
"assignees":{"nodes":[]},"labels":{"nodes":[]},"reviewRequests":{"nodes":[]},
|
||||||
@@ -485,6 +485,9 @@ func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing
|
|||||||
if !got.Permissions.CanResolveAny || got.Permissions.Repository != "WRITE" {
|
if !got.Permissions.CanResolveAny || got.Permissions.Repository != "WRITE" {
|
||||||
t.Fatalf("permissions = %#v", got.Permissions)
|
t.Fatalf("permissions = %#v", got.Permissions)
|
||||||
}
|
}
|
||||||
|
if got.ViewerLogin != "current-user" {
|
||||||
|
t.Fatalf("viewer login = %q", got.ViewerLogin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
||||||
|
|||||||
@@ -354,6 +354,18 @@ func (k KeyBindings) canonicalPREditKey(key string, field int, confirming bool)
|
|||||||
return "y"
|
return "y"
|
||||||
case keyMatches(key, k.General.Reject), keyMatches(key, k.Input.Cancel):
|
case keyMatches(key, k.General.Reject), keyMatches(key, k.Input.Cancel):
|
||||||
return "esc"
|
return "esc"
|
||||||
|
case keyMatches(key, k.Navigation.Down):
|
||||||
|
return "down"
|
||||||
|
case keyMatches(key, k.Navigation.Up):
|
||||||
|
return "up"
|
||||||
|
case keyMatches(key, k.Navigation.PageDown):
|
||||||
|
return "ctrl+d"
|
||||||
|
case keyMatches(key, k.Navigation.PageUp):
|
||||||
|
return "ctrl+u"
|
||||||
|
case keyMatches(key, k.Navigation.First):
|
||||||
|
return "g"
|
||||||
|
case keyMatches(key, k.Navigation.Last):
|
||||||
|
return "G"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
5
main.go
5
main.go
@@ -179,6 +179,7 @@ func main() {
|
|||||||
ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
|
ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
|
||||||
DashboardMode: config.Display.DashboardMode,
|
DashboardMode: config.Display.DashboardMode,
|
||||||
CompactReviews: config.Display.CompactReviews,
|
CompactReviews: config.Display.CompactReviews,
|
||||||
|
ViewerLabel: config.Display.ViewerLabel,
|
||||||
ReadState: loadReadState(statePath),
|
ReadState: loadReadState(statePath),
|
||||||
Drafts: loadDraftStore(draftPath),
|
Drafts: loadDraftStore(draftPath),
|
||||||
PathScroll: config.Paths.Scroll,
|
PathScroll: config.Paths.Scroll,
|
||||||
@@ -224,3 +225,7 @@ var _ GitHubWriteService = (*GitHubClient)(nil)
|
|||||||
var _ GitHubWriteService = (*CachedGitHubService)(nil)
|
var _ GitHubWriteService = (*CachedGitHubService)(nil)
|
||||||
var _ GitHubPullRequestWriteService = (*GitHubClient)(nil)
|
var _ GitHubPullRequestWriteService = (*GitHubClient)(nil)
|
||||||
var _ GitHubPullRequestWriteService = (*CachedGitHubService)(nil)
|
var _ GitHubPullRequestWriteService = (*CachedGitHubService)(nil)
|
||||||
|
var _ GitHubPullRequestPeopleWriteService = (*GitHubClient)(nil)
|
||||||
|
var _ GitHubPullRequestPeopleWriteService = (*CachedGitHubService)(nil)
|
||||||
|
var _ GitHubRepositoryPeopleService = (*GitHubClient)(nil)
|
||||||
|
var _ GitHubRepositoryPeopleService = (*CachedGitHubService)(nil)
|
||||||
|
|||||||
183
pr_editor.go
183
pr_editor.go
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
prEditTitleField = iota
|
prEditTitleField = iota
|
||||||
prEditBaseField
|
prEditBaseField
|
||||||
|
prEditReviewersField
|
||||||
|
prEditAssigneesField
|
||||||
prEditBodyField
|
prEditBodyField
|
||||||
prEditFieldCount
|
prEditFieldCount
|
||||||
)
|
)
|
||||||
@@ -27,6 +31,12 @@ func (m *App) startPREdit() tea.Cmd {
|
|||||||
m.prEditField = prEditBodyField
|
m.prEditField = prEditBodyField
|
||||||
m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, false)
|
m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, false)
|
||||||
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, false)
|
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, false)
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor(
|
||||||
|
strings.Join(m.details.RequestedReviewers, ", "), false,
|
||||||
|
)
|
||||||
|
m.prEditEditors[prEditAssigneesField] = newTextEditor(
|
||||||
|
strings.Join(m.details.Assignees, ", "), false,
|
||||||
|
)
|
||||||
m.prEditEditors[prEditBodyField] = newTextEditor(
|
m.prEditEditors[prEditBodyField] = newTextEditor(
|
||||||
normalizeLineEndings(m.details.Body),
|
normalizeLineEndings(m.details.Body),
|
||||||
m.editorMode == "vim",
|
m.editorMode == "vim",
|
||||||
@@ -42,11 +52,15 @@ func (m *App) startPREdit() tea.Cmd {
|
|||||||
m.prEditBranchesLoading = false
|
m.prEditBranchesLoading = false
|
||||||
m.prEditBranchesError = ""
|
m.prEditBranchesError = ""
|
||||||
m.prEditBranchIndex = 0
|
m.prEditBranchIndex = 0
|
||||||
|
m.prEditUsers = nil
|
||||||
|
m.prEditUsersLoading = false
|
||||||
|
m.prEditUsersError = ""
|
||||||
|
m.prEditUserIndex = 0
|
||||||
m.scroll = 0
|
m.scroll = 0
|
||||||
m.err = m.prEditEditors[m.prEditField].err
|
m.err = m.prEditEditors[m.prEditField].err
|
||||||
m.prEditEditors[m.prEditField].err = nil
|
m.prEditEditors[m.prEditField].err = nil
|
||||||
m.ensurePREditCursorVisible()
|
m.ensurePREditCursorVisible()
|
||||||
return m.loadPREditBranches()
|
return tea.Batch(m.loadPREditBranches(), m.loadPREditUsers())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *App) loadPREditBranches() tea.Cmd {
|
func (m *App) loadPREditBranches() tea.Cmd {
|
||||||
@@ -65,6 +79,22 @@ func (m *App) loadPREditBranches() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *App) loadPREditUsers() tea.Cmd {
|
||||||
|
service, ok := m.service.(GitHubRepositoryPeopleService)
|
||||||
|
if !ok {
|
||||||
|
m.prEditUsersError = "configured GitHub service cannot list repository users"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.prEditUsersLoading = true
|
||||||
|
owner, repo := m.details.Owner, m.details.Repository
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
users, err := service.ListRepositoryUsers(ctx, owner, repo)
|
||||||
|
return repositoryUsersLoadedMsg{owner: owner, repo: repo, users: users, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m App) pullRequestUpdateUnavailable() string {
|
func (m App) pullRequestUpdateUnavailable() string {
|
||||||
if m.loading {
|
if m.loading {
|
||||||
return "pull request update unavailable while PR data is refreshing"
|
return "pull request update unavailable while PR data is refreshing"
|
||||||
@@ -75,6 +105,9 @@ func (m App) pullRequestUpdateUnavailable() string {
|
|||||||
if _, ok := m.service.(GitHubPullRequestWriteService); !ok {
|
if _, ok := m.service.(GitHubPullRequestWriteService); !ok {
|
||||||
return "configured GitHub service does not support pull request updates"
|
return "configured GitHub service does not support pull request updates"
|
||||||
}
|
}
|
||||||
|
if _, ok := m.service.(GitHubPullRequestPeopleWriteService); !ok {
|
||||||
|
return "configured GitHub service does not support reviewer and assignee updates"
|
||||||
|
}
|
||||||
if m.details.ID == "" {
|
if m.details.ID == "" {
|
||||||
return "pull request details are not loaded"
|
return "pull request details are not loaded"
|
||||||
}
|
}
|
||||||
@@ -118,6 +151,20 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "n", "esc":
|
case "n", "esc":
|
||||||
m.writeMode = writePREdit
|
m.writeMode = writePREdit
|
||||||
m.ensurePREditCursorVisible()
|
m.ensurePREditCursorVisible()
|
||||||
|
case "down":
|
||||||
|
m.helpScroll = min(m.helpScroll+1, m.prEditConfirmationMaxScroll())
|
||||||
|
case "up":
|
||||||
|
m.helpScroll = max(0, m.helpScroll-1)
|
||||||
|
case "ctrl+d":
|
||||||
|
m.helpScroll = min(
|
||||||
|
m.helpScroll+max(1, m.height/2), m.prEditConfirmationMaxScroll(),
|
||||||
|
)
|
||||||
|
case "ctrl+u":
|
||||||
|
m.helpScroll = max(0, m.helpScroll-max(1, m.height/2))
|
||||||
|
case "g":
|
||||||
|
m.helpScroll = 0
|
||||||
|
case "G":
|
||||||
|
m.helpScroll = m.prEditConfirmationMaxScroll()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -134,11 +181,16 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
} else {
|
} else {
|
||||||
m.writeMode = writePREditConfirm
|
m.writeMode = writePREditConfirm
|
||||||
|
m.helpScroll = 0
|
||||||
m.err = nil
|
m.err = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case "tab":
|
case "tab":
|
||||||
if m.prEditField != prEditBaseField || !m.completeBranchSuggestion() {
|
completed := m.prEditField == prEditBaseField && m.completeBranchSuggestion()
|
||||||
|
if isPREditPeopleField(m.prEditField) {
|
||||||
|
completed = m.completeUserSuggestion()
|
||||||
|
}
|
||||||
|
if !completed {
|
||||||
m.movePREditField(1)
|
m.movePREditField(1)
|
||||||
}
|
}
|
||||||
case "shift+tab":
|
case "shift+tab":
|
||||||
@@ -146,10 +198,14 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "ctrl+n":
|
case "ctrl+n":
|
||||||
if m.prEditField == prEditBaseField {
|
if m.prEditField == prEditBaseField {
|
||||||
m.moveBranchSuggestion(1)
|
m.moveBranchSuggestion(1)
|
||||||
|
} else if isPREditPeopleField(m.prEditField) {
|
||||||
|
m.moveUserSuggestion(1)
|
||||||
}
|
}
|
||||||
case "ctrl+p":
|
case "ctrl+p":
|
||||||
if m.prEditField == prEditBaseField {
|
if m.prEditField == prEditBaseField {
|
||||||
m.moveBranchSuggestion(-1)
|
m.moveBranchSuggestion(-1)
|
||||||
|
} else if isPREditPeopleField(m.prEditField) {
|
||||||
|
m.moveUserSuggestion(-1)
|
||||||
}
|
}
|
||||||
case "ctrl+d", "ctrl+u":
|
case "ctrl+d", "ctrl+u":
|
||||||
if m.prEditField == prEditBodyField {
|
if m.prEditField == prEditBodyField {
|
||||||
@@ -165,6 +221,9 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.prEditField == prEditBaseField && m.completeBranchSuggestion() {
|
if m.prEditField == prEditBaseField && m.completeBranchSuggestion() {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if isPREditPeopleField(m.prEditField) && m.completeUserSuggestion() {
|
||||||
|
break
|
||||||
|
}
|
||||||
if m.prEditField != prEditBodyField {
|
if m.prEditField != prEditBodyField {
|
||||||
m.movePREditField(1)
|
m.movePREditField(1)
|
||||||
} else {
|
} else {
|
||||||
@@ -204,6 +263,9 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.prEditField == prEditBaseField && editor.Text != before {
|
if m.prEditField == prEditBaseField && editor.Text != before {
|
||||||
m.prEditBranchIndex = 0
|
m.prEditBranchIndex = 0
|
||||||
}
|
}
|
||||||
|
if isPREditPeopleField(m.prEditField) && editor.Text != before {
|
||||||
|
m.prEditUserIndex = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.err = nil
|
m.err = nil
|
||||||
m.ensurePREditCursorVisible()
|
m.ensurePREditCursorVisible()
|
||||||
@@ -235,13 +297,44 @@ func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) {
|
|||||||
|
|
||||||
func (m App) submitPREdit() tea.Cmd {
|
func (m App) submitPREdit() tea.Cmd {
|
||||||
writer := m.service.(GitHubPullRequestWriteService)
|
writer := m.service.(GitHubPullRequestWriteService)
|
||||||
|
peopleWriter := m.service.(GitHubPullRequestPeopleWriteService)
|
||||||
id := m.details.ID
|
id := m.details.ID
|
||||||
update := m.prEditMetadata()
|
update := m.prEditMetadata()
|
||||||
|
owner, repo, number := m.details.Owner, m.details.Repository, m.details.Number
|
||||||
|
peopleUpdate := PullRequestPeopleUpdate{
|
||||||
|
CurrentReviewers: slices.Clone(m.details.RequestedReviewers),
|
||||||
|
CurrentAssignees: slices.Clone(m.details.Assignees),
|
||||||
|
Reviewers: slices.Clone(update.Reviewers), Assignees: slices.Clone(update.Assignees),
|
||||||
|
}
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
metadata, err := writer.UpdatePullRequest(ctx, id, update)
|
result := pullRequestUpdatedMsg{}
|
||||||
return pullRequestUpdatedMsg{metadata: metadata, err: err}
|
peopleChanged := !slices.Equal(update.Reviewers, m.prEditOriginal.Reviewers) ||
|
||||||
|
!slices.Equal(update.Assignees, m.prEditOriginal.Assignees)
|
||||||
|
if peopleChanged {
|
||||||
|
result.people, result.err = peopleWriter.UpdatePullRequestPeople(
|
||||||
|
ctx, owner, repo, number, peopleUpdate,
|
||||||
|
)
|
||||||
|
if result.err != nil {
|
||||||
|
result.peopleSaved =
|
||||||
|
!equalLoginSets(result.people.Reviewers, peopleUpdate.CurrentReviewers) ||
|
||||||
|
!equalLoginSets(result.people.Assignees, peopleUpdate.CurrentAssignees)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.peopleSaved = true
|
||||||
|
}
|
||||||
|
if !samePRMetadataCore(update, m.prEditOriginal) {
|
||||||
|
result.metadata, result.err = writer.UpdatePullRequest(ctx, id, update)
|
||||||
|
if result.err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.metadata = m.prEditOriginal
|
||||||
|
}
|
||||||
|
result.metadata.Reviewers = slices.Clone(update.Reviewers)
|
||||||
|
result.metadata.Assignees = slices.Clone(update.Assignees)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +358,11 @@ func (m App) validatePREdit() error {
|
|||||||
return fmt.Errorf("target branch %q is not an available repository branch", update.BaseRef)
|
return fmt.Errorf("target branch %q is not an available repository branch", update.BaseRef)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := m.validatePREditUsers(update); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if samePRMetadata(update, m.prEditOriginal) {
|
if samePRMetadata(update, m.prEditOriginal) {
|
||||||
return errors.New("title, target branch, and description are unchanged")
|
return errors.New("pull request fields are unchanged")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -278,6 +374,8 @@ func (m App) prEditIsStale() bool {
|
|||||||
func (m App) currentPRMetadata() PullRequestMetadata {
|
func (m App) currentPRMetadata() PullRequestMetadata {
|
||||||
return PullRequestMetadata{
|
return PullRequestMetadata{
|
||||||
Title: m.details.Title, Body: m.details.Body, BaseRef: m.details.BaseRef,
|
Title: m.details.Title, Body: m.details.Body, BaseRef: m.details.BaseRef,
|
||||||
|
Reviewers: normalizedLogins(m.details.RequestedReviewers),
|
||||||
|
Assignees: normalizedLogins(m.details.Assignees),
|
||||||
Mergeable: m.details.Mergeable, MergeState: m.details.MergeState,
|
Mergeable: m.details.Mergeable, MergeState: m.details.MergeState,
|
||||||
UpdatedAt: m.details.UpdatedAt,
|
UpdatedAt: m.details.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -292,24 +390,68 @@ func (m App) prEditMetadata() PullRequestMetadata {
|
|||||||
body = m.prEditOriginal.Body
|
body = m.prEditOriginal.Body
|
||||||
}
|
}
|
||||||
return PullRequestMetadata{
|
return PullRequestMetadata{
|
||||||
Title: strings.TrimSpace(m.prEditEditors[prEditTitleField].Text),
|
Title: strings.TrimSpace(m.prEditEditors[prEditTitleField].Text),
|
||||||
Body: body,
|
Body: body,
|
||||||
BaseRef: strings.TrimSpace(m.prEditEditors[prEditBaseField].Text),
|
BaseRef: strings.TrimSpace(m.prEditEditors[prEditBaseField].Text),
|
||||||
|
Reviewers: parseLoginList(m.prEditEditors[prEditReviewersField].Text),
|
||||||
|
Assignees: parseLoginList(m.prEditEditors[prEditAssigneesField].Text),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func samePRMetadata(left, right PullRequestMetadata) bool {
|
func samePRMetadata(left, right PullRequestMetadata) bool {
|
||||||
|
return samePRMetadataCore(left, right) &&
|
||||||
|
slices.Equal(left.Reviewers, right.Reviewers) &&
|
||||||
|
slices.Equal(left.Assignees, right.Assignees)
|
||||||
|
}
|
||||||
|
|
||||||
|
func samePRMetadataCore(left, right PullRequestMetadata) bool {
|
||||||
return left.Title == right.Title && left.Body == right.Body && left.BaseRef == right.BaseRef
|
return left.Title == right.Title && left.Body == right.Body && left.BaseRef == right.BaseRef
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *App) applyPREditPeople(people PullRequestPeople) {
|
||||||
|
oldRequested := make(map[string]bool, len(m.details.RequestedReviewers))
|
||||||
|
for _, login := range m.details.RequestedReviewers {
|
||||||
|
oldRequested[strings.ToLower(login)] = true
|
||||||
|
}
|
||||||
|
desired := make(map[string]bool, len(people.Reviewers))
|
||||||
|
for _, login := range people.Reviewers {
|
||||||
|
desired[strings.ToLower(login)] = true
|
||||||
|
}
|
||||||
|
filtered := m.details.Reviewers[:0]
|
||||||
|
known := make(map[string]bool)
|
||||||
|
for _, reviewer := range m.details.Reviewers {
|
||||||
|
key := strings.ToLower(reviewer.Login)
|
||||||
|
if oldRequested[key] && reviewer.State == "REVIEW_REQUESTED" && !desired[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, reviewer)
|
||||||
|
known[key] = true
|
||||||
|
}
|
||||||
|
for _, login := range people.Reviewers {
|
||||||
|
if !known[strings.ToLower(login)] {
|
||||||
|
filtered = append(filtered, Reviewer{Login: login, State: "REVIEW_REQUESTED"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(filtered, func(i, j int) bool {
|
||||||
|
return strings.ToLower(filtered[i].Login) < strings.ToLower(filtered[j].Login)
|
||||||
|
})
|
||||||
|
m.details.Reviewers = filtered
|
||||||
|
m.details.RequestedReviewers = slices.Clone(people.Reviewers)
|
||||||
|
m.details.Assignees = slices.Clone(people.Assignees)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *App) clearPREdit() {
|
func (m *App) clearPREdit() {
|
||||||
m.prEditField = 0
|
m.prEditField = 0
|
||||||
m.prEditEditors = [3]textEditor{}
|
m.prEditEditors = [prEditFieldCount]textEditor{}
|
||||||
m.prEditOriginal = PullRequestMetadata{}
|
m.prEditOriginal = PullRequestMetadata{}
|
||||||
m.prEditBranches = nil
|
m.prEditBranches = nil
|
||||||
m.prEditBranchesLoading = false
|
m.prEditBranchesLoading = false
|
||||||
m.prEditBranchesError = ""
|
m.prEditBranchesError = ""
|
||||||
m.prEditBranchIndex = 0
|
m.prEditBranchIndex = 0
|
||||||
|
m.prEditUsers = nil
|
||||||
|
m.prEditUsersLoading = false
|
||||||
|
m.prEditUsersError = ""
|
||||||
|
m.prEditUserIndex = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *App) movePREditField(delta int) {
|
func (m *App) movePREditField(delta int) {
|
||||||
@@ -387,6 +529,8 @@ func (m App) dashboardEditLayout() ([]string, int) {
|
|||||||
}
|
}
|
||||||
appendField("title", prEditTitleField)
|
appendField("title", prEditTitleField)
|
||||||
appendField("target branch", prEditBaseField)
|
appendField("target branch", prEditBaseField)
|
||||||
|
appendField("reviewers", prEditReviewersField)
|
||||||
|
appendField("assignees", prEditAssigneesField)
|
||||||
appendField("description", prEditBodyField)
|
appendField("description", prEditBodyField)
|
||||||
return lines, cursorLine
|
return lines, cursorLine
|
||||||
}
|
}
|
||||||
@@ -422,6 +566,9 @@ func (m App) prEditFieldLines(label string, field, width int) []string {
|
|||||||
if active && field == prEditBaseField {
|
if active && field == prEditBaseField {
|
||||||
lines = append(lines, m.branchCompletionLines(max(1, width-2))...)
|
lines = append(lines, m.branchCompletionLines(max(1, width-2))...)
|
||||||
}
|
}
|
||||||
|
if active && isPREditPeopleField(field) {
|
||||||
|
lines = append(lines, m.userCompletionLines(max(1, width-2))...)
|
||||||
|
}
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +616,20 @@ func (m App) prEditConfirmationLines(width int) []string {
|
|||||||
len([]rune(m.prEditOriginal.Body)), len([]rune(update.Body)),
|
len([]rune(m.prEditOriginal.Body)), len([]rune(update.Body)),
|
||||||
), "")
|
), "")
|
||||||
}
|
}
|
||||||
|
if !slices.Equal(update.Reviewers, m.prEditOriginal.Reviewers) {
|
||||||
|
lines = append(lines,
|
||||||
|
dimStyle.Render("reviewers"),
|
||||||
|
loginChangeSummary(m.prEditOriginal.Reviewers, update.Reviewers),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !slices.Equal(update.Assignees, m.prEditOriginal.Assignees) {
|
||||||
|
lines = append(lines,
|
||||||
|
dimStyle.Render("assignees"),
|
||||||
|
loginChangeSummary(m.prEditOriginal.Assignees, update.Assignees),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
lines = append(lines, warnStyle.Render(fmt.Sprintf(
|
lines = append(lines, warnStyle.Render(fmt.Sprintf(
|
||||||
"%s submit • %s continue editing",
|
"%s submit • %s continue editing",
|
||||||
primaryKeyLabel(m.keybindings.General.Confirm),
|
primaryKeyLabel(m.keybindings.General.Confirm),
|
||||||
@@ -476,3 +637,7 @@ func (m App) prEditConfirmationLines(width int) []string {
|
|||||||
)))
|
)))
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m App) prEditConfirmationMaxScroll() int {
|
||||||
|
return max(0, len(m.prEditConfirmationLines(max(1, min(74, m.width-6))))-max(3, m.height-4))
|
||||||
|
}
|
||||||
|
|||||||
316
tui.go
316
tui.go
@@ -65,8 +65,10 @@ type threadRepliedMsg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pullRequestUpdatedMsg struct {
|
type pullRequestUpdatedMsg struct {
|
||||||
metadata PullRequestMetadata
|
metadata PullRequestMetadata
|
||||||
err error
|
people PullRequestPeople
|
||||||
|
peopleSaved bool
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type autoMergeUpdatedMsg struct {
|
type autoMergeUpdatedMsg struct {
|
||||||
@@ -103,6 +105,12 @@ type branchesLoadedMsg struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type repositoryUsersLoadedMsg struct {
|
||||||
|
owner, repo string
|
||||||
|
users []RepositoryUser
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
type detailsEnrichedMsg struct {
|
type detailsEnrichedMsg struct {
|
||||||
enrichment PRDetailsEnrichment
|
enrichment PRDetailsEnrichment
|
||||||
requestID uint64
|
requestID uint64
|
||||||
@@ -146,12 +154,16 @@ type App struct {
|
|||||||
autoMergeTarget bool
|
autoMergeTarget bool
|
||||||
mergeMethod string
|
mergeMethod string
|
||||||
prEditField int
|
prEditField int
|
||||||
prEditEditors [3]textEditor
|
prEditEditors [prEditFieldCount]textEditor
|
||||||
prEditOriginal PullRequestMetadata
|
prEditOriginal PullRequestMetadata
|
||||||
prEditBranches []RepositoryBranch
|
prEditBranches []RepositoryBranch
|
||||||
prEditBranchesLoading bool
|
prEditBranchesLoading bool
|
||||||
prEditBranchesError string
|
prEditBranchesError string
|
||||||
prEditBranchIndex int
|
prEditBranchIndex int
|
||||||
|
prEditUsers []RepositoryUser
|
||||||
|
prEditUsersLoading bool
|
||||||
|
prEditUsersError string
|
||||||
|
prEditUserIndex int
|
||||||
cursorOutput *terminalCursorOutput
|
cursorOutput *terminalCursorOutput
|
||||||
|
|
||||||
foldResolved bool
|
foldResolved bool
|
||||||
@@ -164,6 +176,7 @@ type App struct {
|
|||||||
dashboardMode string
|
dashboardMode string
|
||||||
dashboardReturn screen
|
dashboardReturn screen
|
||||||
compactReviews bool
|
compactReviews bool
|
||||||
|
viewerLabel string
|
||||||
editorMode string
|
editorMode string
|
||||||
keybindings KeyBindings
|
keybindings KeyBindings
|
||||||
readState *readStateStore
|
readState *readStateStore
|
||||||
@@ -197,6 +210,7 @@ type AppSettings struct {
|
|||||||
ThreadWithinStatus string
|
ThreadWithinStatus string
|
||||||
DashboardMode string
|
DashboardMode string
|
||||||
CompactReviews bool
|
CompactReviews bool
|
||||||
|
ViewerLabel string
|
||||||
EditorMode string
|
EditorMode string
|
||||||
KeyBindings KeyBindings
|
KeyBindings KeyBindings
|
||||||
ReadState *readStateStore
|
ReadState *readStateStore
|
||||||
@@ -214,6 +228,7 @@ func defaultAppSettings() AppSettings {
|
|||||||
ThreadWithinStatus: "file",
|
ThreadWithinStatus: "file",
|
||||||
DashboardMode: "hotkey",
|
DashboardMode: "hotkey",
|
||||||
CompactReviews: true,
|
CompactReviews: true,
|
||||||
|
ViewerLabel: "login",
|
||||||
EditorMode: "vim",
|
EditorMode: "vim",
|
||||||
KeyBindings: defaultKeyBindings(),
|
KeyBindings: defaultKeyBindings(),
|
||||||
}
|
}
|
||||||
@@ -246,6 +261,7 @@ func NewAppWithSettings(
|
|||||||
healthReturn: prScreen,
|
healthReturn: prScreen,
|
||||||
requests: &requestCoordinator{},
|
requests: &requestCoordinator{},
|
||||||
compactReviews: settings.CompactReviews,
|
compactReviews: settings.CompactReviews,
|
||||||
|
viewerLabel: firstNonEmpty(settings.ViewerLabel, "login"),
|
||||||
editorMode: settings.EditorMode,
|
editorMode: settings.EditorMode,
|
||||||
keybindings: settings.KeyBindings,
|
keybindings: settings.KeyBindings,
|
||||||
readState: state,
|
readState: state,
|
||||||
@@ -568,7 +584,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.err = nil
|
m.err = nil
|
||||||
default:
|
default:
|
||||||
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
||||||
m.replyDraft += string(key.Runes)
|
m.replyDraft += textInputKeyValue(key)
|
||||||
m.err = nil
|
m.err = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -867,6 +883,22 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.prEditBranchesError = ""
|
m.prEditBranchesError = ""
|
||||||
m.prEditBranchIndex = 0
|
m.prEditBranchIndex = 0
|
||||||
m.ensurePREditCursorVisible()
|
m.ensurePREditCursorVisible()
|
||||||
|
case repositoryUsersLoadedMsg:
|
||||||
|
if m.writeMode != writePREdit ||
|
||||||
|
msg.owner != m.details.Owner || msg.repo != m.details.Repository {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.prEditUsersLoading = false
|
||||||
|
if msg.err != nil {
|
||||||
|
m.prEditUsersError = msg.err.Error()
|
||||||
|
m.recordHealth("repository user recommendations", healthWarning, msg.err.Error())
|
||||||
|
m.ensurePREditCursorVisible()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.prEditUsers = msg.users
|
||||||
|
m.prEditUsersError = ""
|
||||||
|
m.prEditUserIndex = 0
|
||||||
|
m.ensurePREditCursorVisible()
|
||||||
case threadResolvedMsg:
|
case threadResolvedMsg:
|
||||||
m.writeMode = writeNone
|
m.writeMode = writeNone
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
@@ -956,6 +988,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case pullRequestUpdatedMsg:
|
case pullRequestUpdatedMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.writeMode = writePREdit
|
m.writeMode = writePREdit
|
||||||
|
if msg.peopleSaved {
|
||||||
|
m.applyPREditPeople(msg.people)
|
||||||
|
m.prEditOriginal.Reviewers = slices.Clone(msg.people.Reviewers)
|
||||||
|
m.prEditOriginal.Assignees = slices.Clone(msg.people.Assignees)
|
||||||
|
}
|
||||||
m.err = fmt.Errorf("update pull request: %w", msg.err)
|
m.err = fmt.Errorf("update pull request: %w", msg.err)
|
||||||
m.recordHealth("PR metadata update", healthError, msg.err.Error())
|
m.recordHealth("PR metadata update", healthError, msg.err.Error())
|
||||||
m.scroll = 0
|
m.scroll = 0
|
||||||
@@ -965,6 +1002,10 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.details.Title = msg.metadata.Title
|
m.details.Title = msg.metadata.Title
|
||||||
m.details.Body = msg.metadata.Body
|
m.details.Body = msg.metadata.Body
|
||||||
m.details.BaseRef = msg.metadata.BaseRef
|
m.details.BaseRef = msg.metadata.BaseRef
|
||||||
|
m.applyPREditPeople(PullRequestPeople{
|
||||||
|
Reviewers: msg.metadata.Reviewers,
|
||||||
|
Assignees: msg.metadata.Assignees,
|
||||||
|
})
|
||||||
m.details.Mergeable = msg.metadata.Mergeable
|
m.details.Mergeable = msg.metadata.Mergeable
|
||||||
m.details.MergeState = msg.metadata.MergeState
|
m.details.MergeState = msg.metadata.MergeState
|
||||||
m.details.UpdatedAt = msg.metadata.UpdatedAt
|
m.details.UpdatedAt = msg.metadata.UpdatedAt
|
||||||
@@ -1061,7 +1102,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.scroll = 0
|
m.scroll = 0
|
||||||
default:
|
default:
|
||||||
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
||||||
m.searchQuery += string(key.Runes)
|
m.searchQuery += textInputKeyValue(key)
|
||||||
m.selectBestSearchMatch()
|
m.selectBestSearchMatch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1504,7 +1545,7 @@ func (m App) matchingThreadIndices() []int {
|
|||||||
if filter.updated && !m.updatedThreads[thread.ID] {
|
if filter.updated && !m.updatedThreads[thread.ID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if filter.author != "" && !threadHasAuthor(thread, filter.author) {
|
if filter.author != "" && !m.threadHasAuthor(thread, filter.author) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if score, ok := fuzzyPathScore(thread.Path, filter.path); ok {
|
if score, ok := fuzzyPathScore(thread.Path, filter.path); ok {
|
||||||
@@ -1559,9 +1600,10 @@ func parseThreadFilter(query string) threadFilter {
|
|||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
func threadHasAuthor(thread ReviewThread, author string) bool {
|
func (m App) threadHasAuthor(thread ReviewThread, author string) bool {
|
||||||
for _, comment := range thread.Comments {
|
for _, comment := range thread.Comments {
|
||||||
if strings.Contains(strings.ToLower(comment.Author), author) {
|
if strings.Contains(strings.ToLower(comment.Author), author) ||
|
||||||
|
strings.Contains(strings.ToLower(m.displayAuthor(comment.Author)), author) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1708,10 +1750,7 @@ func (m App) dashboardMaxScroll() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m App) detailPaneSize() (int, int) {
|
func (m App) detailPaneSize() (int, int) {
|
||||||
topLines := 3
|
topLines := m.threadTopLineCount()
|
||||||
if m.details.ThreadsTruncated {
|
|
||||||
topLines++
|
|
||||||
}
|
|
||||||
height := max(3, m.height-topLines-1)
|
height := max(3, m.height-topLines-1)
|
||||||
if m.width < 70 || m.listHidden {
|
if m.width < 70 || m.listHidden {
|
||||||
return max(3, m.width), height
|
return max(3, m.width), height
|
||||||
@@ -1720,6 +1759,17 @@ func (m App) detailPaneSize() (int, int) {
|
|||||||
return max(20, m.width-leftWidth-1), height
|
return max(20, m.width-leftWidth-1), height
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m App) threadTopLineCount() int {
|
||||||
|
topLines := 3
|
||||||
|
if m.details.FromCache {
|
||||||
|
topLines++
|
||||||
|
}
|
||||||
|
if m.details.ThreadsTruncated {
|
||||||
|
topLines++
|
||||||
|
}
|
||||||
|
return topLines
|
||||||
|
}
|
||||||
|
|
||||||
func (m App) threadListWidth() int {
|
func (m App) threadListWidth() int {
|
||||||
percent := m.threadListWidthPercent
|
percent := m.threadListWidthPercent
|
||||||
if percent == 0 {
|
if percent == 0 {
|
||||||
@@ -1735,7 +1785,7 @@ func (m App) detailViewportHeight() int {
|
|||||||
|
|
||||||
func (m App) detailMaxScroll() int {
|
func (m App) detailMaxScroll() int {
|
||||||
width, _ := m.detailPaneSize()
|
width, _ := m.detailPaneSize()
|
||||||
return max(0, len(m.detailLines(width))-m.detailViewportHeight())
|
return max(0, len(m.renderedDetailLines(width))-m.detailViewportHeight())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m App) View() string {
|
func (m App) View() string {
|
||||||
@@ -1783,11 +1833,9 @@ func (m App) viewWritePopup() string {
|
|||||||
titleStyle.Render("Reply to " + location),
|
titleStyle.Render("Reply to " + location),
|
||||||
"",
|
"",
|
||||||
}
|
}
|
||||||
draft := m.replyDraft + "█"
|
lines = append(lines, renderTextInput(
|
||||||
for _, sourceLine := range strings.Split(draft, "\n") {
|
m.replyDraft, width-2, m.cursorOutput != nil,
|
||||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width-2, ""), width-2, false)
|
)...)
|
||||||
lines = append(lines, strings.Split(wrapped, "\n")...)
|
|
||||||
}
|
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
lines = append(lines, "", badStyle.Render(m.err.Error()))
|
lines = append(lines, "", badStyle.Render(m.err.Error()))
|
||||||
}
|
}
|
||||||
@@ -1874,7 +1922,12 @@ func (m App) viewWritePopup() string {
|
|||||||
}
|
}
|
||||||
maxLines := max(3, m.height-4)
|
maxLines := max(3, m.height-4)
|
||||||
if len(lines) > maxLines {
|
if len(lines) > maxLines {
|
||||||
lines = lines[len(lines)-maxLines:]
|
if m.writeMode == writePREditConfirm {
|
||||||
|
start := clamp(m.helpScroll, 0, len(lines)-maxLines)
|
||||||
|
lines = lines[start : start+maxLines]
|
||||||
|
} else {
|
||||||
|
lines = lines[len(lines)-maxLines:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for index := range lines {
|
for index := range lines {
|
||||||
lines[index] = ansi.Truncate(lines[index], width, "")
|
lines[index] = ansi.Truncate(lines[index], width, "")
|
||||||
@@ -1905,8 +1958,8 @@ func (m App) helpBindings() []helpBinding {
|
|||||||
{keyLabel(m.keybindings.Input.Submit), "Review pull request metadata changes"},
|
{keyLabel(m.keybindings.Input.Submit), "Review pull request metadata changes"},
|
||||||
{keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"},
|
{keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"},
|
||||||
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"},
|
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"},
|
||||||
{combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next target-branch completion"},
|
{combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next branch or user completion"},
|
||||||
{combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected target branch"},
|
{combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected branch or user"},
|
||||||
}
|
}
|
||||||
if m.prEditEditors[prEditBodyField].Modal {
|
if m.prEditEditors[prEditBodyField].Modal {
|
||||||
bindings = append(bindings,
|
bindings = append(bindings,
|
||||||
@@ -2011,7 +2064,7 @@ func (m App) helpBindings() []helpBinding {
|
|||||||
{keyLabel(m.keybindings.Navigation.Up), "Scroll description up"},
|
{keyLabel(m.keybindings.Navigation.Up), "Scroll description up"},
|
||||||
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"},
|
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"},
|
||||||
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
|
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
|
||||||
{keyLabel(m.keybindings.Views.Edit), "Edit title, target branch, and description"},
|
{keyLabel(m.keybindings.Views.Edit), "Edit title, branch, reviewers, assignees, and description"},
|
||||||
{keyLabel(m.keybindings.Views.AutoMerge), "Enable or disable auto-merge"},
|
{keyLabel(m.keybindings.Views.AutoMerge), "Enable or disable auto-merge"},
|
||||||
{keyLabel(m.keybindings.Views.MergeNow), "Merge the pull request now when all requirements are met"},
|
{keyLabel(m.keybindings.Views.MergeNow), "Merge the pull request now when all requirements are met"},
|
||||||
{keyLabel(m.keybindings.Views.AI), "Open the local AI review menu"},
|
{keyLabel(m.keybindings.Views.AI), "Open the local AI review menu"},
|
||||||
@@ -2569,17 +2622,17 @@ func (m App) dashboardLines() []string {
|
|||||||
}
|
}
|
||||||
lines = append(lines,
|
lines = append(lines,
|
||||||
"",
|
"",
|
||||||
dashboardMetadata("author", authorStyle(pr.Author).Render("@"+pr.Author)),
|
dashboardMetadata("author", m.authorText(pr.Author)),
|
||||||
dashboardMetadata("branches", pr.HeadRef+" → "+pr.BaseRef),
|
dashboardMetadata("branches", pr.HeadRef+" → "+pr.BaseRef),
|
||||||
dashboardMetadata("review", reviewAndMergeState(pr)),
|
dashboardMetadata("review", reviewAndMergeState(pr)),
|
||||||
dashboardMetadata("checks", coloredState(pr.CheckState)),
|
dashboardMetadata("checks", coloredState(pr.CheckState)),
|
||||||
dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")),
|
dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")),
|
||||||
dashboardMetadata("auto-merge", autoMergeStateText(pr)),
|
dashboardMetadata("auto-merge", m.autoMergeStateText(pr)),
|
||||||
)
|
)
|
||||||
lines = append(lines, dashboardMetadataLines("conflicts", conflictStateText(pr), width)...)
|
lines = append(lines, dashboardMetadataLines("conflicts", conflictStateText(pr), width)...)
|
||||||
lines = append(lines,
|
lines = append(lines,
|
||||||
dashboardMetadata("assignees", handlesText(pr.Assignees)),
|
dashboardMetadata("assignees", m.handlesText(pr.Assignees)),
|
||||||
dashboardMetadata("reviewers", reviewersText(pr.Reviewers)),
|
dashboardMetadata("reviewers", m.reviewersText(pr.Reviewers)),
|
||||||
dashboardMetadata("labels", labels),
|
dashboardMetadata("labels", labels),
|
||||||
dashboardMetadata("milestone", milestone),
|
dashboardMetadata("milestone", milestone),
|
||||||
dashboardMetadata("activity", fmt.Sprintf(
|
dashboardMetadata("activity", fmt.Sprintf(
|
||||||
@@ -2654,10 +2707,10 @@ func (m App) dashboardLines() []string {
|
|||||||
when := event.CreatedAt.Local().Format("2006-01-02 15:04")
|
when := event.CreatedAt.Local().Format("2006-01-02 15:04")
|
||||||
if event.Kind == "force-push" {
|
if event.Kind == "force-push" {
|
||||||
lines = append(lines, warnStyle.Render("force-push")+" "+shortOID(event.BeforeOID)+" → "+
|
lines = append(lines, warnStyle.Render("force-push")+" "+shortOID(event.BeforeOID)+" → "+
|
||||||
shortOID(event.AfterOID)+" "+authorStyle(event.Author).Render("@"+event.Author)+" "+dimStyle.Render(when))
|
shortOID(event.AfterOID)+" "+m.authorText(event.Author)+" "+dimStyle.Render(when))
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, shortOID(event.OID)+" "+truncate(event.Title, max(10, width-35))+
|
lines = append(lines, shortOID(event.OID)+" "+truncate(event.Title, max(10, width-35))+
|
||||||
" "+authorStyle(event.Author).Render("@"+event.Author)+" "+dimStyle.Render(when))
|
" "+m.authorText(event.Author)+" "+dimStyle.Render(when))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Applicable rulesets (%d)", applicableRulesetCount(pr.Rulesets))), "")
|
lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Applicable rulesets (%d)", applicableRulesetCount(pr.Rulesets))), "")
|
||||||
@@ -2680,13 +2733,13 @@ func (m App) dashboardLines() []string {
|
|||||||
lines = append(lines, dimStyle.Render("No submitted reviews."))
|
lines = append(lines, dimStyle.Render("No submitted reviews."))
|
||||||
}
|
}
|
||||||
if m.compactReviews {
|
if m.compactReviews {
|
||||||
lines = append(lines, compactReviewLines(pr.Reviews, width)...)
|
lines = append(lines, m.compactReviewLines(pr.Reviews, width)...)
|
||||||
if len(pr.Reviews) > 0 {
|
if len(pr.Reviews) > 0 {
|
||||||
lines = append(lines, "")
|
lines = append(lines, "")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, review := range pr.Reviews {
|
for _, review := range pr.Reviews {
|
||||||
header := authorStyle(review.Author).Render("@"+review.Author) + " " +
|
header := m.authorText(review.Author) + " " +
|
||||||
coloredState(review.State)
|
coloredState(review.State)
|
||||||
if !review.SubmittedAt.IsZero() {
|
if !review.SubmittedAt.IsZero() {
|
||||||
header += " " + dimStyle.Render(review.SubmittedAt.Local().Format("2006-01-02 15:04"))
|
header += " " + dimStyle.Render(review.SubmittedAt.Local().Format("2006-01-02 15:04"))
|
||||||
@@ -2706,7 +2759,7 @@ func (m App) dashboardLines() []string {
|
|||||||
lines = append(lines, dimStyle.Render("No PR conversation comments."))
|
lines = append(lines, dimStyle.Render("No PR conversation comments."))
|
||||||
}
|
}
|
||||||
for _, comment := range pr.Conversation {
|
for _, comment := range pr.Conversation {
|
||||||
header := authorStyle(comment.Author).Render("@" + comment.Author)
|
header := m.authorText(comment.Author)
|
||||||
if !comment.CreatedAt.IsZero() {
|
if !comment.CreatedAt.IsZero() {
|
||||||
header += " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04"))
|
header += " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
@@ -2717,7 +2770,7 @@ func (m App) dashboardLines() []string {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
func compactReviewLines(reviews []ReviewSummary, width int) []string {
|
func (m App) compactReviewLines(reviews []ReviewSummary, width int) []string {
|
||||||
if len(reviews) == 0 {
|
if len(reviews) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2751,13 +2804,13 @@ func compactReviewLines(reviews []ReviewSummary, width int) []string {
|
|||||||
}
|
}
|
||||||
for _, author := range authors {
|
for _, author := range authors {
|
||||||
summary = append(summary,
|
summary = append(summary,
|
||||||
authorStyle(author).Render("@"+author)+dimStyle.Render(fmt.Sprintf(" ×%d", authorCounts[author])),
|
m.authorText(author)+dimStyle.Render(fmt.Sprintf(" ×%d", authorCounts[author])),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
lines := []string{ansi.Truncate(strings.Join(summary, dimStyle.Render(" • ")), width, "…")}
|
lines := []string{ansi.Truncate(strings.Join(summary, dimStyle.Render(" • ")), width, "…")}
|
||||||
for _, review := range reviews {
|
for _, review := range reviews {
|
||||||
if body := compactReviewBody(review.Body, width); body != "" {
|
if body := compactReviewBody(review.Body, width); body != "" {
|
||||||
line := authorStyle(review.Author).Render("@"+review.Author) + " " +
|
line := m.authorText(review.Author) + " " +
|
||||||
coloredState(review.State) + dimStyle.Render(" — ") + body
|
coloredState(review.State) + dimStyle.Render(" — ") + body
|
||||||
lines = append(lines, ansi.Truncate(line, width, "…"))
|
lines = append(lines, ansi.Truncate(line, width, "…"))
|
||||||
}
|
}
|
||||||
@@ -2961,7 +3014,7 @@ func (m App) viewThreads() string {
|
|||||||
pr := m.details
|
pr := m.details
|
||||||
header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12))))
|
header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12))))
|
||||||
meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr))
|
meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr))
|
||||||
people := "assignees: " + handlesText(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers)
|
people := "assignees: " + m.handlesText(pr.Assignees) + " reviewers: " + m.reviewersText(pr.Reviewers)
|
||||||
top := []string{header, meta, people}
|
top := []string{header, meta, people}
|
||||||
if pr.FromCache {
|
if pr.FromCache {
|
||||||
top = append(top, warnStyle.Render(
|
top = append(top, warnStyle.Render(
|
||||||
@@ -3023,7 +3076,12 @@ func (m App) viewThreads() string {
|
|||||||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return m.frame(append(top, body), help)
|
m.positionThreadInputHardwareCursor()
|
||||||
|
view := m.frame(append(top, body), help)
|
||||||
|
if m.cursorOutput != nil {
|
||||||
|
view += m.cursorOutput.FrameMarker()
|
||||||
|
}
|
||||||
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m App) threadList(width, height int) string {
|
func (m App) threadList(width, height int) string {
|
||||||
@@ -3034,7 +3092,8 @@ func (m App) threadList(width, height int) string {
|
|||||||
if m.searching {
|
if m.searching {
|
||||||
queryWidth := max(1, innerWidth-len("Filter: ")-1)
|
queryWidth := max(1, innerWidth-len("Filter: ")-1)
|
||||||
query := ansi.Truncate(m.searchQuery, queryWidth, "…")
|
query := ansi.Truncate(m.searchQuery, queryWidth, "…")
|
||||||
lines = append(lines, titleStyle.Render("Filter: ")+query+"█")
|
lines = append(lines, titleStyle.Render("Filter: ")+query+
|
||||||
|
inputCursorFallback(m.cursorOutput != nil))
|
||||||
}
|
}
|
||||||
if m.loading && len(m.details.Threads) == 0 {
|
if m.loading && len(m.details.Threads) == 0 {
|
||||||
lines = append(lines, "Loading…")
|
lines = append(lines, "Loading…")
|
||||||
@@ -3085,7 +3144,7 @@ func (m App) threadDetail(width, height int) string {
|
|||||||
if len(m.details.Threads) == 0 {
|
if len(m.details.Threads) == 0 {
|
||||||
return renderPane([]string{"No review threads."}, width, height, m.focus == threadDetailPane)
|
return renderPane([]string{"No review threads."}, width, height, m.focus == threadDetailPane)
|
||||||
}
|
}
|
||||||
lines := m.detailLines(width)
|
lines := m.renderedDetailLines(width)
|
||||||
viewportHeight := max(1, height-2)
|
viewportHeight := max(1, height-2)
|
||||||
maxScroll := max(0, len(lines)-viewportHeight)
|
maxScroll := max(0, len(lines)-viewportHeight)
|
||||||
scroll := min(m.scroll, maxScroll)
|
scroll := min(m.scroll, maxScroll)
|
||||||
@@ -3099,7 +3158,7 @@ func (m App) threadDetail(width, height int) string {
|
|||||||
if line.suggestionChange != 0 {
|
if line.suggestionChange != 0 {
|
||||||
renderedLine = suggestionHighlight(line.fixed, line.text, contentWidth, line.suggestionChange)
|
renderedLine = suggestionHighlight(line.fixed, line.text, contentWidth, line.suggestionChange)
|
||||||
} else {
|
} else {
|
||||||
renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "…")
|
renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "")
|
||||||
}
|
}
|
||||||
renderedLine = line.rail + renderedLine
|
renderedLine = line.rail + renderedLine
|
||||||
if line.selected {
|
if line.selected {
|
||||||
@@ -3176,7 +3235,7 @@ func (m App) detailLines(width int) []detailLine {
|
|||||||
lines = append(lines, detailLine{}, detailLine{
|
lines = append(lines, detailLine{}, detailLine{
|
||||||
rail: rail,
|
rail: rail,
|
||||||
anchor: "comment:" + comment.ID + ":header",
|
anchor: "comment:" + comment.ID + ":header",
|
||||||
text: authorStyle(comment.Author).Render("@"+comment.Author) +
|
text: m.commentAuthorText(comment) +
|
||||||
localAICommentBadge(comment) + " " +
|
localAICommentBadge(comment) + " " +
|
||||||
dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")),
|
dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")),
|
||||||
})
|
})
|
||||||
@@ -3233,6 +3292,32 @@ func (m App) detailLines(width int) []detailLine {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m App) renderedDetailLines(width int) []detailLine {
|
||||||
|
lines := m.detailLines(width)
|
||||||
|
innerWidth := max(1, width-2)
|
||||||
|
rendered := make([]detailLine, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
contentWidth := max(1, innerWidth-ansi.StringWidth(line.rail))
|
||||||
|
content := line.fixed + line.text
|
||||||
|
if line.suggestionChange != 0 || ansi.StringWidth(content) <= contentWidth {
|
||||||
|
rendered = append(rendered, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for index, wrapped := range strings.Split(
|
||||||
|
ansi.Hardwrap(content, contentWidth, true), "\n",
|
||||||
|
) {
|
||||||
|
continuation := line
|
||||||
|
continuation.fixed = ""
|
||||||
|
continuation.text = wrapped
|
||||||
|
if index > 0 && continuation.anchor != "" {
|
||||||
|
continuation.anchor += fmt.Sprintf(":wrap:%d", index)
|
||||||
|
}
|
||||||
|
rendered = append(rendered, continuation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
func renderReactionSummary(reactions []ReactionSummary, width int) []string {
|
func renderReactionSummary(reactions []ReactionSummary, width int) []string {
|
||||||
var badges []string
|
var badges []string
|
||||||
for _, reaction := range reactions {
|
for _, reaction := range reactions {
|
||||||
@@ -3300,17 +3385,13 @@ func (m App) inlineReplyLines(width int) []detailLine {
|
|||||||
{},
|
{},
|
||||||
{rail: rail, anchor: "reply:header", text: titleStyle.Render("Reply draft")},
|
{rail: rail, anchor: "reply:header", text: titleStyle.Render("Reply draft")},
|
||||||
}
|
}
|
||||||
draft := m.replyDraft + "█"
|
textWidth := max(1, width-5)
|
||||||
textWidth := max(1, width-4)
|
|
||||||
lineIndex := 0
|
lineIndex := 0
|
||||||
for _, sourceLine := range strings.Split(draft, "\n") {
|
for _, part := range renderTextInput(m.replyDraft, textWidth, m.cursorOutput != nil) {
|
||||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, textWidth, ""), textWidth, false)
|
lines = append(lines, detailLine{
|
||||||
for _, part := range strings.Split(wrapped, "\n") {
|
rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part,
|
||||||
lines = append(lines, detailLine{
|
})
|
||||||
rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part,
|
lineIndex++
|
||||||
})
|
|
||||||
lineIndex++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
|
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
|
||||||
@@ -3327,6 +3408,96 @@ func (m App) inlineReplyLines(width int) []detailLine {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inputCursorFallback(hardwareCursor bool) string {
|
||||||
|
if hardwareCursor {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "\x1b[4m \x1b[24m"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTextInput(value string, width int, hardwareCursor bool) []string {
|
||||||
|
const cursorSentinel = "\ue000"
|
||||||
|
if hardwareCursor {
|
||||||
|
value += cursorSentinel
|
||||||
|
} else {
|
||||||
|
value += inputCursorFallback(false)
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for _, sourceLine := range strings.Split(value, "\n") {
|
||||||
|
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width, ""), width, false)
|
||||||
|
if hardwareCursor {
|
||||||
|
wrapped = strings.TrimSuffix(wrapped, cursorSentinel)
|
||||||
|
}
|
||||||
|
lines = append(lines, strings.Split(wrapped, "\n")...)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func textInputKeyValue(key tea.KeyMsg) string {
|
||||||
|
if key.Type == tea.KeySpace {
|
||||||
|
return " "
|
||||||
|
}
|
||||||
|
return string(key.Runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) positionThreadInputHardwareCursor() {
|
||||||
|
if m.cursorOutput == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
topLines := m.threadTopLineCount()
|
||||||
|
if m.searching {
|
||||||
|
if m.width >= 70 && m.listHidden {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
paneWidth := m.width
|
||||||
|
if m.width >= 70 {
|
||||||
|
paneWidth = m.threadListWidth()
|
||||||
|
}
|
||||||
|
queryWidth := max(1, max(1, paneWidth-2)-len("Filter: ")-1)
|
||||||
|
query := ansi.Truncate(m.searchQuery, queryWidth, "…")
|
||||||
|
m.cursorOutput.SetCursor(
|
||||||
|
true,
|
||||||
|
2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query),
|
||||||
|
topLines+3,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.writeMode != writeReply && m.aiMode != aiDiscussion {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
width, height := m.detailPaneSize()
|
||||||
|
lines := m.renderedDetailLines(width)
|
||||||
|
prefix := "reply:body:"
|
||||||
|
if m.aiMode == aiDiscussion {
|
||||||
|
prefix = "ai-discussion:body:"
|
||||||
|
}
|
||||||
|
cursorLine := -1
|
||||||
|
for index := range lines {
|
||||||
|
if strings.HasPrefix(lines[index].anchor, prefix) {
|
||||||
|
cursorLine = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cursorLine < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewportHeight := max(1, height-2)
|
||||||
|
scroll := min(m.scroll, max(0, len(lines)-viewportHeight))
|
||||||
|
screenLine := cursorLine - scroll
|
||||||
|
if screenLine < 0 || screenLine >= viewportHeight {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
paneStart := 0
|
||||||
|
if m.width >= 70 && !m.listHidden {
|
||||||
|
paneStart = m.threadListWidth() + 1
|
||||||
|
}
|
||||||
|
line := lines[cursorLine]
|
||||||
|
m.cursorOutput.SetCursor(
|
||||||
|
true,
|
||||||
|
paneStart+2+ansi.StringWidth(line.rail)+ansi.StringWidth(line.fixed+line.text),
|
||||||
|
topLines+2+screenLine,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func latestForcePush(events []TimelineEvent) time.Time {
|
func latestForcePush(events []TimelineEvent) time.Time {
|
||||||
var latest time.Time
|
var latest time.Time
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
@@ -3339,7 +3510,7 @@ func latestForcePush(events []TimelineEvent) time.Time {
|
|||||||
|
|
||||||
func (m App) detailScrollAnchor() string {
|
func (m App) detailScrollAnchor() string {
|
||||||
width, _ := m.detailPaneSize()
|
width, _ := m.detailPaneSize()
|
||||||
lines := m.detailLines(width)
|
lines := m.renderedDetailLines(width)
|
||||||
for index := min(m.scroll, len(lines)-1); index >= 0; index-- {
|
for index := min(m.scroll, len(lines)-1); index >= 0; index-- {
|
||||||
if lines[index].anchor != "" {
|
if lines[index].anchor != "" {
|
||||||
return lines[index].anchor
|
return lines[index].anchor
|
||||||
@@ -3350,7 +3521,7 @@ func (m App) detailScrollAnchor() string {
|
|||||||
|
|
||||||
func (m *App) restoreDetailAnchor(anchor string) {
|
func (m *App) restoreDetailAnchor(anchor string) {
|
||||||
width, _ := m.detailPaneSize()
|
width, _ := m.detailPaneSize()
|
||||||
for index, line := range m.detailLines(width) {
|
for index, line := range m.renderedDetailLines(width) {
|
||||||
if line.anchor == anchor {
|
if line.anchor == anchor {
|
||||||
m.scroll = min(index, m.detailMaxScroll())
|
m.scroll = min(index, m.detailMaxScroll())
|
||||||
return
|
return
|
||||||
@@ -3550,6 +3721,35 @@ func authorStyle(login string) lipgloss.Style {
|
|||||||
return lipgloss.NewStyle().Bold(true).Foreground(authorColor(login))
|
return lipgloss.NewStyle().Bold(true).Foreground(authorColor(login))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m App) viewerLogin() string {
|
||||||
|
if m.details.ViewerLogin != "" {
|
||||||
|
return m.details.ViewerLogin
|
||||||
|
}
|
||||||
|
if m.details.ViewerAuthored {
|
||||||
|
return m.details.Author
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) displayAuthor(login string) string {
|
||||||
|
if m.viewerLabel == "you" && strings.EqualFold(login, m.viewerLogin()) {
|
||||||
|
return "you"
|
||||||
|
}
|
||||||
|
return login
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) authorText(login string) string {
|
||||||
|
return authorStyle(login).Render("@" + m.displayAuthor(login))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) commentAuthorText(comment ReviewComment) string {
|
||||||
|
login := comment.Author
|
||||||
|
if comment.Origin == reviewOriginLocalAIUser && m.viewerLogin() != "" {
|
||||||
|
login = m.viewerLogin()
|
||||||
|
}
|
||||||
|
return m.authorText(login)
|
||||||
|
}
|
||||||
|
|
||||||
func authorColor(login string) lipgloss.Color {
|
func authorColor(login string) lipgloss.Color {
|
||||||
hash := fnv.New32a()
|
hash := fnv.New32a()
|
||||||
_, _ = hash.Write([]byte(strings.ToLower(login)))
|
_, _ = hash.Write([]byte(strings.ToLower(login)))
|
||||||
@@ -3652,7 +3852,7 @@ func conflictStateText(pr PRDetails) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func autoMergeStateText(pr PRDetails) string {
|
func (m App) autoMergeStateText(pr PRDetails) string {
|
||||||
if pr.Merged {
|
if pr.Merged {
|
||||||
return okStyle.Render("merged")
|
return okStyle.Render("merged")
|
||||||
}
|
}
|
||||||
@@ -3661,7 +3861,7 @@ func autoMergeStateText(pr PRDetails) string {
|
|||||||
}
|
}
|
||||||
text := okStyle.Render("enabled") + " " + strings.ToLower(pr.AutoMerge.MergeMethod)
|
text := okStyle.Render("enabled") + " " + strings.ToLower(pr.AutoMerge.MergeMethod)
|
||||||
if pr.AutoMerge.EnabledBy != "" {
|
if pr.AutoMerge.EnabledBy != "" {
|
||||||
text += " by " + authorStyle(pr.AutoMerge.EnabledBy).Render("@"+pr.AutoMerge.EnabledBy)
|
text += " by " + m.authorText(pr.AutoMerge.EnabledBy)
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
@@ -3684,26 +3884,26 @@ func coloredState(state string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reviewersText(reviewers []Reviewer) string {
|
func (m App) reviewersText(reviewers []Reviewer) string {
|
||||||
if len(reviewers) == 0 {
|
if len(reviewers) == 0 {
|
||||||
return "none"
|
return "none"
|
||||||
}
|
}
|
||||||
items := make([]string, 0, len(reviewers))
|
items := make([]string, 0, len(reviewers))
|
||||||
for _, reviewer := range reviewers {
|
for _, reviewer := range reviewers {
|
||||||
items = append(items,
|
items = append(items,
|
||||||
authorStyle(reviewer.Login).Render("@"+reviewer.Login)+
|
m.authorText(reviewer.Login)+
|
||||||
dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"),
|
dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return strings.Join(items, ", ")
|
return strings.Join(items, ", ")
|
||||||
}
|
}
|
||||||
func handlesText(items []string) string {
|
func (m App) handlesText(items []string) string {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return "none"
|
return "none"
|
||||||
}
|
}
|
||||||
handles := make([]string, 0, len(items))
|
handles := make([]string, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
handles = append(handles, authorStyle(item).Render("@"+item))
|
handles = append(handles, m.authorText(item))
|
||||||
}
|
}
|
||||||
return strings.Join(handles, ", ")
|
return strings.Join(handles, ", ")
|
||||||
}
|
}
|
||||||
|
|||||||
165
tui_test.go
165
tui_test.go
@@ -31,6 +31,10 @@ type recordingPRService struct {
|
|||||||
updateErr error
|
updateErr error
|
||||||
branches []RepositoryBranch
|
branches []RepositoryBranch
|
||||||
branchErr error
|
branchErr error
|
||||||
|
users []RepositoryUser
|
||||||
|
userErr error
|
||||||
|
people PullRequestPeopleUpdate
|
||||||
|
peopleErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
type recordingMergeService struct {
|
type recordingMergeService struct {
|
||||||
@@ -77,6 +81,25 @@ func (s *recordingPRService) ListBranches(
|
|||||||
return append([]RepositoryBranch(nil), s.branches...), s.branchErr
|
return append([]RepositoryBranch(nil), s.branches...), s.branchErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *recordingPRService) ListRepositoryUsers(
|
||||||
|
_ context.Context, _, _ string,
|
||||||
|
) ([]RepositoryUser, error) {
|
||||||
|
return append([]RepositoryUser(nil), s.users...), s.userErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingPRService) UpdatePullRequestPeople(
|
||||||
|
_ context.Context, _, _ string, _ int, update PullRequestPeopleUpdate,
|
||||||
|
) (PullRequestPeople, error) {
|
||||||
|
s.people = update
|
||||||
|
if s.peopleErr != nil {
|
||||||
|
return PullRequestPeople{}, s.peopleErr
|
||||||
|
}
|
||||||
|
return PullRequestPeople{
|
||||||
|
Reviewers: append([]string(nil), update.Reviewers...),
|
||||||
|
Assignees: append([]string(nil), update.Assignees...),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *recordingService) SetThreadResolved(
|
func (s *recordingService) SetThreadResolved(
|
||||||
_ context.Context, threadID string, resolved bool,
|
_ context.Context, threadID string, resolved bool,
|
||||||
) (ReviewThread, error) {
|
) (ReviewThread, error) {
|
||||||
@@ -674,6 +697,8 @@ func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) {
|
|||||||
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
|
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
|
||||||
send(tea.KeyMsg{Type: tea.KeyEsc})
|
send(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
|
||||||
|
send(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||||
|
send(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||||
send(tea.KeyMsg{Type: tea.KeyShiftTab})
|
send(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||||
send(tea.KeyMsg{Type: tea.KeyHome})
|
send(tea.KeyMsg{Type: tea.KeyHome})
|
||||||
for range len("main") {
|
for range len("main") {
|
||||||
@@ -901,7 +926,7 @@ func TestDashboardPositionsHardwareCursorAtInsertBoundary(t *testing.T) {
|
|||||||
m.startPREdit()
|
m.startPREdit()
|
||||||
m.prEditEditors[prEditBodyField].Cursor = 4
|
m.prEditEditors[prEditBodyField].Cursor = 4
|
||||||
m.prEditEditors[prEditBodyField].Mode = textEditorInsert
|
m.prEditEditors[prEditBodyField].Mode = textEditorInsert
|
||||||
m.positionPREditHardwareCursor(0, m.dashboardViewportHeight())
|
m.positionPREditHardwareCursor(m.scroll, m.dashboardViewportHeight())
|
||||||
|
|
||||||
m.cursorOutput.mu.Lock()
|
m.cursorOutput.mu.Lock()
|
||||||
visible, column, row := m.cursorOutput.visible, m.cursorOutput.column, m.cursorOutput.row
|
visible, column, row := m.cursorOutput.visible, m.cursorOutput.column, m.cursorOutput.row
|
||||||
@@ -1013,6 +1038,65 @@ func TestDashboardHardwareCursorMovementChangesZeroWidthFrameMarker(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestThreadInputsUseHardwareCursor(t *testing.T) {
|
||||||
|
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
|
m.cursorOutput = newTerminalCursorOutput(file)
|
||||||
|
m.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Title"},
|
||||||
|
Threads: []ReviewThread{{
|
||||||
|
ID: "thread-1", Path: "main.go", Line: 1,
|
||||||
|
Comments: []ReviewComment{{ID: "comment-1", Author: "alice", Body: "Review"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.searching, m.searchQuery = true, "main"
|
||||||
|
_ = m.viewThreads()
|
||||||
|
m.cursorOutput.mu.Lock()
|
||||||
|
searchVisible, searchColumn := m.cursorOutput.visible, m.cursorOutput.column
|
||||||
|
m.cursorOutput.mu.Unlock()
|
||||||
|
if !searchVisible || searchColumn <= 0 {
|
||||||
|
t.Fatalf("search cursor visible=%v column=%d", searchVisible, searchColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.searching = false
|
||||||
|
m.aiMode, m.writeThreadID, m.aiInput = aiDiscussion, "thread-1", "Question"
|
||||||
|
m.focus = threadDetailPane
|
||||||
|
m.scroll = m.detailMaxScroll()
|
||||||
|
_ = m.viewThreads()
|
||||||
|
m.cursorOutput.mu.Lock()
|
||||||
|
discussionVisible, beforeSpaceColumn := m.cursorOutput.visible, m.cursorOutput.column
|
||||||
|
m.cursorOutput.mu.Unlock()
|
||||||
|
if !discussionVisible || beforeSpaceColumn <= searchColumn {
|
||||||
|
t.Fatalf(
|
||||||
|
"discussion cursor visible=%v column=%d search column=%d",
|
||||||
|
discussionVisible, beforeSpaceColumn, searchColumn,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
|
m = updated.(App)
|
||||||
|
view := m.viewThreads()
|
||||||
|
m.cursorOutput.mu.Lock()
|
||||||
|
afterSpaceColumn := m.cursorOutput.column
|
||||||
|
m.cursorOutput.mu.Unlock()
|
||||||
|
if !handled || m.aiInput != "Question " || afterSpaceColumn != beforeSpaceColumn+1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"handled=%v input=%q cursor before=%d after=%d",
|
||||||
|
handled, m.aiInput, beforeSpaceColumn, afterSpaceColumn,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if strings.Contains(ansi.Strip(view), "█") ||
|
||||||
|
strings.Contains(ansi.Strip(view), "Question│") {
|
||||||
|
t.Fatalf("thread input still contains a painted cursor: %q", ansi.Strip(view))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDashboardEditorNormalizesMixedLineEndingsWithoutCreatingAnEdit(t *testing.T) {
|
func TestDashboardEditorNormalizesMixedLineEndingsWithoutCreatingAnEdit(t *testing.T) {
|
||||||
const remoteBody = "first\nsecond\r\nthird\r"
|
const remoteBody = "first\nsecond\r\nthird\r"
|
||||||
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
@@ -1103,6 +1187,38 @@ func TestReplyComposerRendersInlineWithCurrentThread(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLongThreadCommentRemainsReachableByScrolling(t *testing.T) {
|
||||||
|
m := NewApp(nil, "o", "r", false, 50, time.Second)
|
||||||
|
m.screen, m.loading, m.width, m.height = threadScreen, false, 60, 12
|
||||||
|
m.listHidden, m.focus = true, threadDetailPane
|
||||||
|
body := strings.Repeat("abcdefghij", 40) + "FINALMARKER"
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Title"},
|
||||||
|
Threads: []ReviewThread{{
|
||||||
|
ID: "thread", Path: "main.go", Line: 12,
|
||||||
|
Comments: []ReviewComment{{
|
||||||
|
ID: "comment", Author: "reviewer", Body: body,
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
width, _ := m.detailPaneSize()
|
||||||
|
lines := m.renderedDetailLines(width)
|
||||||
|
for _, line := range lines {
|
||||||
|
available := max(1, width-2-ansi.StringWidth(line.rail))
|
||||||
|
if got := ansi.StringWidth(line.fixed + line.text); got > available {
|
||||||
|
t.Fatalf("detail line width=%d available=%d text=%q", got, available, ansi.Strip(line.text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.detailMaxScroll() == 0 {
|
||||||
|
t.Fatal("long comment did not produce scrollable detail rows")
|
||||||
|
}
|
||||||
|
m.scroll = m.detailMaxScroll()
|
||||||
|
if view := ansi.Strip(m.viewThreads()); !strings.Contains(view, "FINALMARKER") {
|
||||||
|
t.Fatalf("final comment content is not reachable at maximum scroll:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) {
|
func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) {
|
||||||
service := &recordingService{}
|
service := &recordingService{}
|
||||||
m := NewApp(service, "o", "r", false, 50, time.Second)
|
m := NewApp(service, "o", "r", false, 50, time.Second)
|
||||||
@@ -1494,12 +1610,29 @@ func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(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.searching, m.searchQuery = threadScreen, true, "ng"
|
m.screen, m.searching, m.searchQuery = threadScreen, true, "ng"
|
||||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}})
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
if got := updated.(App).searchQuery; got != "ng " {
|
if got := updated.(App).searchQuery; got != "ng " {
|
||||||
t.Fatalf("space key produced search query %q", got)
|
t.Fatalf("space key produced search query %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) {
|
||||||
|
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
||||||
|
m.writeMode, m.replyDraft = writeReply, "reply"
|
||||||
|
updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
|
m = updated.(App)
|
||||||
|
if m.replyDraft != "reply " {
|
||||||
|
t.Fatalf("space key produced reply draft %q", m.replyDraft)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "question"
|
||||||
|
updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
|
m = updated.(App)
|
||||||
|
if !handled || m.aiInput != "question " {
|
||||||
|
t.Fatalf("handled=%v space key produced AI input %q", handled, m.aiInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileSearchCanJumpOrCancel(t *testing.T) {
|
func TestFileSearchCanJumpOrCancel(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
|
||||||
@@ -1809,8 +1942,9 @@ func TestDetailsLoadUsesSelectedPRRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPeopleMetadataUsesColoredHandles(t *testing.T) {
|
func TestPeopleMetadataUsesColoredHandles(t *testing.T) {
|
||||||
assignees := handlesText([]string{"alice"})
|
m := App{}
|
||||||
reviewers := reviewersText([]Reviewer{{Login: "bob", State: "APPROVED"}})
|
assignees := m.handlesText([]string{"alice"})
|
||||||
|
reviewers := m.reviewersText([]Reviewer{{Login: "bob", State: "APPROVED"}})
|
||||||
if ansi.Strip(assignees) != "@alice" || ansi.Strip(reviewers) != "@bob (approved)" {
|
if ansi.Strip(assignees) != "@alice" || ansi.Strip(reviewers) != "@bob (approved)" {
|
||||||
t.Fatalf("people metadata = %q / %q", ansi.Strip(assignees), ansi.Strip(reviewers))
|
t.Fatalf("people metadata = %q / %q", ansi.Strip(assignees), ansi.Strip(reviewers))
|
||||||
}
|
}
|
||||||
@@ -1820,6 +1954,29 @@ func TestPeopleMetadataUsesColoredHandles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestViewerLabelCanReplaceGitHubLoginWithYou(t *testing.T) {
|
||||||
|
m := App{
|
||||||
|
viewerLabel: "you",
|
||||||
|
details: PRDetails{
|
||||||
|
ViewerLogin: "pablu",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := ansi.Strip(m.authorText("pablu")); got != "@you" {
|
||||||
|
t.Fatalf("viewer author = %q", got)
|
||||||
|
}
|
||||||
|
if got := ansi.Strip(m.authorText("reviewer")); got != "@reviewer" {
|
||||||
|
t.Fatalf("other author = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.viewerLabel = "login"
|
||||||
|
localUser := ReviewComment{
|
||||||
|
Author: "you", Origin: reviewOriginLocalAIUser,
|
||||||
|
}
|
||||||
|
if got := ansi.Strip(m.commentAuthorText(localUser)); got != "@pablu" {
|
||||||
|
t.Fatalf("local AI user author = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDashboardFrameDoesNotChangeWhenBackgroundRefreshStarts(t *testing.T) {
|
func TestDashboardFrameDoesNotChangeWhenBackgroundRefreshStarts(t *testing.T) {
|
||||||
m := NewApp(&recordingService{}, "", "", false, 50, time.Minute)
|
m := NewApp(&recordingService{}, "", "", false, 50, time.Minute)
|
||||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 100, 30
|
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 100, 30
|
||||||
|
|||||||
33
types.go
33
types.go
@@ -21,6 +21,7 @@ type PullRequest struct {
|
|||||||
|
|
||||||
type PRDetails struct {
|
type PRDetails struct {
|
||||||
PullRequest
|
PullRequest
|
||||||
|
ViewerLogin string
|
||||||
Body string
|
Body string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
BaseRef string
|
BaseRef string
|
||||||
@@ -36,6 +37,7 @@ type PRDetails struct {
|
|||||||
ConflictFileError string
|
ConflictFileError string
|
||||||
Assignees []string
|
Assignees []string
|
||||||
Reviewers []Reviewer
|
Reviewers []Reviewer
|
||||||
|
RequestedReviewers []string
|
||||||
Labels []string
|
Labels []string
|
||||||
Milestone string
|
Milestone string
|
||||||
Additions int
|
Additions int
|
||||||
@@ -82,6 +84,8 @@ type PullRequestMetadata struct {
|
|||||||
Title string
|
Title string
|
||||||
Body string
|
Body string
|
||||||
BaseRef string
|
BaseRef string
|
||||||
|
Reviewers []string
|
||||||
|
Assignees []string
|
||||||
Mergeable string
|
Mergeable string
|
||||||
MergeState string
|
MergeState string
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
@@ -104,6 +108,29 @@ type RepositoryBranch struct {
|
|||||||
IsDefault bool
|
IsDefault bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RepositoryUser struct {
|
||||||
|
ID string
|
||||||
|
Login string
|
||||||
|
Name string
|
||||||
|
CanReview bool
|
||||||
|
CanAssign bool
|
||||||
|
RecentCommits int
|
||||||
|
RecentAdditions int
|
||||||
|
LastContributionAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PullRequestPeopleUpdate struct {
|
||||||
|
CurrentReviewers []string
|
||||||
|
CurrentAssignees []string
|
||||||
|
Reviewers []string
|
||||||
|
Assignees []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PullRequestPeople struct {
|
||||||
|
Reviewers []string
|
||||||
|
Assignees []string
|
||||||
|
}
|
||||||
|
|
||||||
type Check struct {
|
type Check struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
@@ -168,6 +195,7 @@ type ReviewSummary struct {
|
|||||||
type ViewerPermissions struct {
|
type ViewerPermissions struct {
|
||||||
Repository string
|
Repository string
|
||||||
CanUpdatePR bool
|
CanUpdatePR bool
|
||||||
|
CanAssign bool
|
||||||
CanResolveAny bool
|
CanResolveAny bool
|
||||||
CanUnresolveAny bool
|
CanUnresolveAny bool
|
||||||
CanReplyAny bool
|
CanReplyAny bool
|
||||||
@@ -235,7 +263,10 @@ type ReviewComment struct {
|
|||||||
Model string
|
Model string
|
||||||
}
|
}
|
||||||
|
|
||||||
const reviewOriginLocalAI = "local-ai"
|
const (
|
||||||
|
reviewOriginLocalAI = "local-ai"
|
||||||
|
reviewOriginLocalAIUser = "local-ai-user"
|
||||||
|
)
|
||||||
|
|
||||||
type ReactionSummary struct {
|
type ReactionSummary struct {
|
||||||
Content string
|
Content string
|
||||||
|
|||||||
360
user_completion.go
Normal file
360
user_completion.go
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userSuggestion struct {
|
||||||
|
user RepositoryUser
|
||||||
|
score int
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPREditPeopleField(field int) bool {
|
||||||
|
return field == prEditReviewersField || field == prEditAssigneesField
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLoginList(value string) []string {
|
||||||
|
return normalizedLogins(strings.FieldsFunc(value, func(value rune) bool {
|
||||||
|
return value == ',' || value == '\n'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedLogins(logins []string) []string {
|
||||||
|
unique := make(map[string]string, len(logins))
|
||||||
|
for _, login := range logins {
|
||||||
|
login = strings.TrimSpace(strings.TrimPrefix(login, "@"))
|
||||||
|
if login == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(login)
|
||||||
|
if _, exists := unique[key]; !exists {
|
||||||
|
unique[key] = login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(unique))
|
||||||
|
for _, login := range unique {
|
||||||
|
result = append(result, login)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return strings.ToLower(result[i]) < strings.ToLower(result[j])
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentLoginQuery(value string) string {
|
||||||
|
if index := strings.LastIndex(value, ","); index >= 0 {
|
||||||
|
value = value[index+1:]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(value, "@"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedLoginPrefix(value string) string {
|
||||||
|
if index := strings.LastIndex(value, ","); index >= 0 {
|
||||||
|
return strings.TrimSpace(value[:index+1]) + " "
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) userSuggestions() []userSuggestion {
|
||||||
|
field := m.prEditField
|
||||||
|
query := strings.ToLower(currentLoginQuery(m.prEditEditors[field].Text))
|
||||||
|
selected := parseLoginList(selectedLoginPrefix(m.prEditEditors[field].Text))
|
||||||
|
selectedSet := make(map[string]bool, len(selected))
|
||||||
|
for _, login := range selected {
|
||||||
|
selectedSet[strings.ToLower(login)] = true
|
||||||
|
}
|
||||||
|
var suggestions []userSuggestion
|
||||||
|
current := m.prEditOriginal.Assignees
|
||||||
|
if field == prEditReviewersField {
|
||||||
|
current = m.prEditOriginal.Reviewers
|
||||||
|
}
|
||||||
|
currentSet := make(map[string]bool, len(current))
|
||||||
|
for _, login := range current {
|
||||||
|
currentSet[strings.ToLower(login)] = true
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for _, user := range m.prEditUsers {
|
||||||
|
if field == prEditReviewersField {
|
||||||
|
if !user.CanReview || strings.EqualFold(user.Login, m.details.Author) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if !user.CanAssign {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if selectedSet[strings.ToLower(user.Login)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := 0
|
||||||
|
if query != "" {
|
||||||
|
loginScore, loginMatches := fuzzyTermScore(
|
||||||
|
[]rune(strings.ToLower(user.Login)), []rune(query),
|
||||||
|
)
|
||||||
|
nameScore, nameMatches := fuzzyTermScore(
|
||||||
|
[]rune(strings.ToLower(user.Name)), []rune(query),
|
||||||
|
)
|
||||||
|
if !loginMatches && !nameMatches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score = max(loginScore, nameScore)
|
||||||
|
if strings.HasPrefix(strings.ToLower(user.Login), query) {
|
||||||
|
score += 30_000
|
||||||
|
}
|
||||||
|
if strings.EqualFold(user.Login, query) {
|
||||||
|
score += 50_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.EqualFold(user.Login, m.viewerLogin()) {
|
||||||
|
score += 2_000
|
||||||
|
}
|
||||||
|
if field == prEditReviewersField {
|
||||||
|
score += repositoryActivityScore(user, now)
|
||||||
|
}
|
||||||
|
if currentSet[strings.ToLower(user.Login)] {
|
||||||
|
score += 50_000
|
||||||
|
}
|
||||||
|
suggestions = append(suggestions, userSuggestion{user: user, score: score})
|
||||||
|
}
|
||||||
|
sort.SliceStable(suggestions, func(i, j int) bool {
|
||||||
|
if suggestions[i].score != suggestions[j].score {
|
||||||
|
return suggestions[i].score > suggestions[j].score
|
||||||
|
}
|
||||||
|
return strings.ToLower(suggestions[i].user.Login) <
|
||||||
|
strings.ToLower(suggestions[j].user.Login)
|
||||||
|
})
|
||||||
|
const maximumVisibleSuggestions = 6
|
||||||
|
if len(suggestions) > maximumVisibleSuggestions {
|
||||||
|
suggestions = suggestions[:maximumVisibleSuggestions]
|
||||||
|
}
|
||||||
|
return suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
func repositoryActivityScore(user RepositoryUser, now time.Time) int {
|
||||||
|
if user.LastContributionAt.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
age := now.Sub(user.LastContributionAt)
|
||||||
|
if age < 0 {
|
||||||
|
age = 0
|
||||||
|
}
|
||||||
|
recency := 500
|
||||||
|
switch {
|
||||||
|
case age <= 14*24*time.Hour:
|
||||||
|
recency = 30_000
|
||||||
|
case age <= 30*24*time.Hour:
|
||||||
|
recency = 24_000
|
||||||
|
case age <= 90*24*time.Hour:
|
||||||
|
recency = 16_000
|
||||||
|
case age <= 180*24*time.Hour:
|
||||||
|
recency = 9_000
|
||||||
|
case age <= 365*24*time.Hour:
|
||||||
|
recency = 4_000
|
||||||
|
}
|
||||||
|
return recency + min(user.RecentCommits, 100)*100 +
|
||||||
|
min(user.RecentAdditions, 10_000)/10
|
||||||
|
}
|
||||||
|
|
||||||
|
func repositoryActivityLabel(user RepositoryUser, now time.Time) string {
|
||||||
|
if user.LastContributionAt.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
age := now.Sub(user.LastContributionAt)
|
||||||
|
switch {
|
||||||
|
case age < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%d recent commits • active today", user.RecentCommits)
|
||||||
|
case age < 30*24*time.Hour:
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%d recent commits • active %dd ago",
|
||||||
|
user.RecentCommits, max(1, int(age/(24*time.Hour))),
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%d recent commits • active %dmo ago",
|
||||||
|
user.RecentCommits, max(1, int(age/(30*24*time.Hour))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *App) moveUserSuggestion(delta int) {
|
||||||
|
suggestions := m.userSuggestions()
|
||||||
|
if len(suggestions) == 0 {
|
||||||
|
m.prEditUserIndex = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.prEditUserIndex = (m.prEditUserIndex + delta + len(suggestions)) % len(suggestions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *App) completeUserSuggestion() bool {
|
||||||
|
suggestions := m.userSuggestions()
|
||||||
|
if len(suggestions) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
index := clamp(m.prEditUserIndex, 0, len(suggestions)-1)
|
||||||
|
login := suggestions[index].user.Login
|
||||||
|
editor := &m.prEditEditors[m.prEditField]
|
||||||
|
completed := selectedLoginPrefix(editor.Text) + login
|
||||||
|
if editor.Text == completed {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
editor.Text = completed
|
||||||
|
editor.Cursor = len([]rune(completed))
|
||||||
|
m.prEditUserIndex = 0
|
||||||
|
m.err = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) userCompletionLines(width int) []string {
|
||||||
|
width = max(1, width)
|
||||||
|
if m.prEditUsersLoading {
|
||||||
|
return []string{dimStyle.Render(" loading eligible repository users…")}
|
||||||
|
}
|
||||||
|
if m.prEditUsersError != "" {
|
||||||
|
message := " user recommendations unavailable: " + m.prEditUsersError
|
||||||
|
wrapped := ansi.Hardwrap(ansi.Wordwrap(message, width, ""), width, false)
|
||||||
|
var lines []string
|
||||||
|
for _, line := range strings.Split(wrapped, "\n") {
|
||||||
|
lines = append(lines, warnStyle.Render(line))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
suggestions := m.userSuggestions()
|
||||||
|
if len(suggestions) == 0 {
|
||||||
|
return []string{dimStyle.Render(" no matching eligible users")}
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
if m.prEditField == prEditReviewersField {
|
||||||
|
lines = append(lines, dimStyle.Render(
|
||||||
|
" ranked by latest 100 default-branch commits",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
dimStyle.Render(fmt.Sprintf(
|
||||||
|
" comma separates users • %s choose • %s complete",
|
||||||
|
primaryCombinedKeyLabel(
|
||||||
|
m.keybindings.Input.PreviousCompletion,
|
||||||
|
m.keybindings.Input.NextCompletion,
|
||||||
|
),
|
||||||
|
primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
current := m.prEditOriginal.Assignees
|
||||||
|
if m.prEditField == prEditReviewersField {
|
||||||
|
current = m.prEditOriginal.Reviewers
|
||||||
|
}
|
||||||
|
currentSet := make(map[string]bool, len(current))
|
||||||
|
for _, login := range current {
|
||||||
|
currentSet[strings.ToLower(login)] = true
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for index, suggestion := range suggestions {
|
||||||
|
prefix := " "
|
||||||
|
if index == clamp(m.prEditUserIndex, 0, len(suggestions)-1) {
|
||||||
|
prefix = " ▶ "
|
||||||
|
}
|
||||||
|
login := "@" + m.displayAuthor(suggestion.user.Login)
|
||||||
|
suffixParts := []string{}
|
||||||
|
if currentSet[strings.ToLower(suggestion.user.Login)] {
|
||||||
|
suffixParts = append(suffixParts, "current")
|
||||||
|
}
|
||||||
|
if m.prEditField == prEditReviewersField {
|
||||||
|
if activity := repositoryActivityLabel(suggestion.user, now); activity != "" {
|
||||||
|
suffixParts = append(suffixParts, activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if suggestion.user.Name != "" {
|
||||||
|
suffixParts = append(suffixParts, suggestion.user.Name)
|
||||||
|
}
|
||||||
|
suffix := strings.Join(suffixParts, " • ")
|
||||||
|
available := max(1, width-ansi.StringWidth(prefix)-ansi.StringWidth(suffix)-2)
|
||||||
|
login = ansi.Truncate(login, available, "…")
|
||||||
|
spacing := strings.Repeat(" ", max(1, available-ansi.StringWidth(login)+1))
|
||||||
|
line := prefix + login + spacing + dimStyle.Render(suffix)
|
||||||
|
if strings.HasPrefix(prefix, " ▶") {
|
||||||
|
line = titleStyle.Render(prefix+login) + spacing + dimStyle.Render(suffix)
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) validatePREditUsers(update PullRequestMetadata) error {
|
||||||
|
if !slices.Equal(update.Assignees, m.prEditOriginal.Assignees) &&
|
||||||
|
!m.details.Permissions.CanAssign {
|
||||||
|
return fmt.Errorf("GitHub did not grant assignee permission for this pull request")
|
||||||
|
}
|
||||||
|
if !slices.Equal(update.Assignees, m.prEditOriginal.Assignees) {
|
||||||
|
for _, issue := range m.details.DataIssues {
|
||||||
|
if issue.Component == "assignees" {
|
||||||
|
return fmt.Errorf("cannot update assignees because the complete current list is unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eligibleReviewers := make(map[string]bool)
|
||||||
|
eligibleAssignees := make(map[string]bool)
|
||||||
|
for _, user := range m.prEditUsers {
|
||||||
|
eligibleReviewers[strings.ToLower(user.Login)] =
|
||||||
|
user.CanReview && !strings.EqualFold(user.Login, m.details.Author)
|
||||||
|
eligibleAssignees[strings.ToLower(user.Login)] = user.CanAssign
|
||||||
|
}
|
||||||
|
if err := validateLoginAdditions(
|
||||||
|
"reviewer", update.Reviewers, m.prEditOriginal.Reviewers,
|
||||||
|
eligibleReviewers, m.prEditUsersError,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateLoginAdditions(
|
||||||
|
"assignee", update.Assignees, m.prEditOriginal.Assignees,
|
||||||
|
eligibleAssignees, m.prEditUsersError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLoginAdditions(
|
||||||
|
role string,
|
||||||
|
desired, current []string,
|
||||||
|
eligible map[string]bool,
|
||||||
|
loadError string,
|
||||||
|
) error {
|
||||||
|
currentSet := make(map[string]bool, len(current))
|
||||||
|
for _, login := range current {
|
||||||
|
currentSet[strings.ToLower(login)] = true
|
||||||
|
}
|
||||||
|
for _, login := range desired {
|
||||||
|
key := strings.ToLower(login)
|
||||||
|
if currentSet[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if loadError != "" {
|
||||||
|
return fmt.Errorf("cannot add %s @%s: eligible users are unavailable", role, login)
|
||||||
|
}
|
||||||
|
if !eligible[key] {
|
||||||
|
return fmt.Errorf("@%s is not an eligible repository %s", login, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginChangeSummary(before, after []string) string {
|
||||||
|
added := loginDifference(after, before)
|
||||||
|
removed := loginDifference(before, after)
|
||||||
|
var changes []string
|
||||||
|
if len(added) > 0 {
|
||||||
|
changes = append(changes, "add "+strings.Join(prefixLogins(added), ", "))
|
||||||
|
}
|
||||||
|
if len(removed) > 0 {
|
||||||
|
changes = append(changes, "remove "+strings.Join(prefixLogins(removed), ", "))
|
||||||
|
}
|
||||||
|
return strings.Join(changes, " • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefixLogins(logins []string) []string {
|
||||||
|
result := make([]string, len(logins))
|
||||||
|
for index, login := range logins {
|
||||||
|
result[index] = "@" + login
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
154
user_completion_test.go
Normal file
154
user_completion_test.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReviewerCompletionSupportsMultipleEligibleUsers(t *testing.T) {
|
||||||
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
|
m.width = 80
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{Author: "author"},
|
||||||
|
ViewerLogin: "current",
|
||||||
|
}
|
||||||
|
m.prEditField = prEditReviewersField
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("alice, bo", false)
|
||||||
|
m.prEditUsers = []RepositoryUser{
|
||||||
|
{Login: "alice", Name: "Alice", CanReview: true},
|
||||||
|
{Login: "bob", Name: "Bob", CanReview: true},
|
||||||
|
{Login: "author", Name: "Author", CanReview: true},
|
||||||
|
{Login: "carol", Name: "Carol", CanAssign: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions := m.userSuggestions()
|
||||||
|
if len(suggestions) != 1 || suggestions[0].user.Login != "bob" {
|
||||||
|
t.Fatalf("reviewer suggestions = %#v", suggestions)
|
||||||
|
}
|
||||||
|
if !m.completeUserSuggestion() ||
|
||||||
|
m.prEditEditors[prEditReviewersField].Text != "alice, bob" {
|
||||||
|
t.Fatalf("completed reviewers = %q", m.prEditEditors[prEditReviewersField].Text)
|
||||||
|
}
|
||||||
|
view := ansi.Strip(strings.Join(
|
||||||
|
m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n",
|
||||||
|
))
|
||||||
|
if !strings.Contains(view, "comma separates users") {
|
||||||
|
t.Fatalf("reviewer completion help missing:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewerSuggestionsPreferRecentRepositoryActivity(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
|
m.details = PRDetails{PullRequest: PullRequest{Author: "author"}}
|
||||||
|
m.prEditField = prEditReviewersField
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("", false)
|
||||||
|
m.prEditUsers = []RepositoryUser{
|
||||||
|
{
|
||||||
|
Login: "old-contributor", CanReview: true, RecentCommits: 30,
|
||||||
|
LastContributionAt: now.AddDate(-2, 0, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Login: "active-contributor", CanReview: true, RecentCommits: 3,
|
||||||
|
RecentAdditions: 50, LastContributionAt: now.Add(-24 * time.Hour),
|
||||||
|
},
|
||||||
|
{Login: "never-contributed", CanReview: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions := m.userSuggestions()
|
||||||
|
if len(suggestions) != 3 || suggestions[0].user.Login != "active-contributor" {
|
||||||
|
t.Fatalf("activity-ranked reviewers = %#v", suggestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPREditStartsWithCurrentRequestedReviewers(t *testing.T) {
|
||||||
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
|
m.loading = false
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{
|
||||||
|
ID: "pr", Owner: "o", Repository: "r", Title: "Title",
|
||||||
|
},
|
||||||
|
BaseRef: "main", RequestedReviewers: []string{"alice", "bob"},
|
||||||
|
Assignees: []string{"carol"},
|
||||||
|
Permissions: ViewerPermissions{CanUpdatePR: true, CanAssign: true},
|
||||||
|
}
|
||||||
|
m.startPREdit()
|
||||||
|
if got := m.prEditEditors[prEditReviewersField].Text; got != "alice, bob" {
|
||||||
|
t.Fatalf("reviewer field = %q", got)
|
||||||
|
}
|
||||||
|
if got := m.prEditEditors[prEditAssigneesField].Text; got != "carol" {
|
||||||
|
t.Fatalf("assignee field = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeopleOnlyPREditSkipsCoreMetadataMutation(t *testing.T) {
|
||||||
|
service := &recordingPRService{}
|
||||||
|
m := NewApp(service, "o", "r", false, 50, time.Second)
|
||||||
|
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{
|
||||||
|
ID: "pr", Owner: "o", Repository: "r", Number: 4,
|
||||||
|
Title: "Title", Author: "author",
|
||||||
|
},
|
||||||
|
BaseRef: "main", RequestedReviewers: []string{"alice"},
|
||||||
|
Permissions: ViewerPermissions{CanUpdatePR: true, CanAssign: true},
|
||||||
|
}
|
||||||
|
m.startPREdit()
|
||||||
|
m.prEditUsers = []RepositoryUser{
|
||||||
|
{Login: "alice", CanReview: true},
|
||||||
|
{Login: "bob", CanReview: true, CanAssign: true},
|
||||||
|
}
|
||||||
|
m.prEditUsersLoading = false
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("alice, bob", false)
|
||||||
|
m.prEditEditors[prEditAssigneesField] = newTextEditor("bob", false)
|
||||||
|
if err := m.validatePREdit(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.writeMode = writePREditConfirm
|
||||||
|
updated, command := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
|
||||||
|
m = updated.(App)
|
||||||
|
if command == nil {
|
||||||
|
t.Fatal("people update did not create a command")
|
||||||
|
}
|
||||||
|
msg := command().(pullRequestUpdatedMsg)
|
||||||
|
if msg.err != nil {
|
||||||
|
t.Fatal(msg.err)
|
||||||
|
}
|
||||||
|
if service.updateID != "" {
|
||||||
|
t.Fatalf("people-only edit rewrote core metadata for %q", service.updateID)
|
||||||
|
}
|
||||||
|
if !slices.Equal(service.people.Reviewers, []string{"alice", "bob"}) ||
|
||||||
|
!slices.Equal(service.people.Assignees, []string{"bob"}) {
|
||||||
|
t.Fatalf("people update = %#v", service.people)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyingUserReviewersPreservesTeamRequestsAndCompletedReviews(t *testing.T) {
|
||||||
|
m := App{details: PRDetails{
|
||||||
|
RequestedReviewers: []string{"old-user"},
|
||||||
|
Reviewers: []Reviewer{
|
||||||
|
{Login: "old-user", State: "REVIEW_REQUESTED"},
|
||||||
|
{Login: "backend-team", State: "REVIEW_REQUESTED"},
|
||||||
|
{Login: "approved-user", State: "APPROVED"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
m.applyPREditPeople(PullRequestPeople{
|
||||||
|
Reviewers: []string{"new-user"},
|
||||||
|
Assignees: []string{"assignee"},
|
||||||
|
})
|
||||||
|
got := make(map[string]string)
|
||||||
|
for _, reviewer := range m.details.Reviewers {
|
||||||
|
got[reviewer.Login] = reviewer.State
|
||||||
|
}
|
||||||
|
if len(got) != 3 || got["backend-team"] != "REVIEW_REQUESTED" ||
|
||||||
|
got["approved-user"] != "APPROVED" ||
|
||||||
|
got["new-user"] != "REVIEW_REQUESTED" {
|
||||||
|
t.Fatalf("reviewers after update = %#v", m.details.Reviewers)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user