From e045bd39b2fa767cc681617a7a7373e01278f6b6 Mon Sep 17 00:00:00 2001 From: pablu Date: Thu, 30 Jul 2026 11:26:21 +0200 Subject: [PATCH] fix: reviewers not correctly shown on dashboard edit screen --- README.md | 21 ++++- pr_editor.go | 202 +++++++++++++++++++++++++++++++++++++++- text_editor.go | 42 ++++++++- theme.go | 12 +++ theme_test.go | 6 ++ user_completion.go | 82 +++++++++++++++- user_completion_test.go | 199 ++++++++++++++++++++++++++++++++++++++- 7 files changed, 549 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3f7b089..424d79d 100644 --- a/README.md +++ b/README.md @@ -200,11 +200,22 @@ Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a 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. +GitHub usernames. Pending individual review requests and assignees are +prefilled and marked in completion results. Reviewers who already submitted a +review, and requested teams, appear first as protected subdued tokens in the +reviewer field. Their handles retain a darker version of their deterministic +user color, while their brackets and review state use the theme's dim color. +GitHub only permits changing pending review requests. +Protected reviewers cannot receive cursor focus or be deleted, and are excluded +from reviewer completion. Newly entered names gain a visual `@` prefix and +their deterministic user color as soon as they exactly match an eligible +reviewer. At that point the suggestions reset to the remaining eligible users; +pressing Space commits the current reviewer and starts the next entry. Reviewers +already present in the field are excluded from those suggestions. 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 diff --git a/pr_editor.go b/pr_editor.go index eff3431..b99cecf 100644 --- a/pr_editor.go +++ b/pr_editor.go @@ -168,6 +168,15 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + if key.Type == tea.KeySpace && m.prEditField == prEditReviewersField { + if m.startNextReviewer() { + m.ensurePREditCursorVisible() + return m, m.queuePREditDraft() + } + // GitHub usernames cannot contain spaces. Ignore a space until the + // current entry is an exact eligible reviewer. + return m, nil + } switch k { case "ctrl+s": @@ -280,7 +289,7 @@ func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) { if m.cursorOutput == nil { return } - editor := m.prEditEditors[m.prEditField] + editor := m.prEditDisplayEditor(m.prEditField, m.prEditEditorWidth()) if editor.Mode != textEditorInsert { return } @@ -524,7 +533,9 @@ func (m App) dashboardEditLayout() ([]string, int) { start := len(lines) lines = append(lines, m.prEditFieldLines(label, field, width)...) if m.prEditField == field { - cursorLine = start + 1 + editorCursorVisualLine(m.prEditEditors[field], max(1, width-4)) + cursorLine = start + 1 + editorCursorVisualLine( + m.prEditDisplayEditor(field, max(1, width-4)), max(1, width-4), + ) } } appendField("title", prEditTitleField) @@ -538,6 +549,9 @@ func (m App) dashboardEditLayout() ([]string, int) { func (m App) prEditFieldLines(label string, field, width int) []string { active := m.prEditField == field editor := m.prEditEditors[field] + if field == prEditReviewersField { + label += " (pending requests editable)" + } prefix := " " if active { prefix = "▶ " @@ -551,7 +565,7 @@ func (m App) prEditFieldLines(label string, field, width int) []string { labelLine = titleStyle.Render(prefix + label) } textWidth := max(1, width-4) - rendered := renderTextEditor(editor, textWidth, active) + rendered := renderTextEditor(m.prEditDisplayEditor(field, textWidth), textWidth, active) lines := []string{labelLine} for _, line := range rendered { if line.active { @@ -572,6 +586,188 @@ func (m App) prEditFieldLines(label string, field, width int) []string { return lines } +func (m App) prEditDisplayEditor(field, width int) textEditor { + editor := m.prEditEditors[field] + if field != prEditReviewersField { + return editor + } + editor, editableStyles := m.prEditEligibleReviewerDisplay(editor) + prefix, protectedStyles := m.prEditReadOnlyReviewerPrefix(width) + if prefix == "" { + editor.protectedStyles = editableStyles + return editor + } + offset := len([]rune(prefix)) + editor.Text = prefix + editor.Text + editor.Cursor += offset + editor.visualAnchor += offset + editor.protectedPrefix = offset + editor.protectedStyles = append(protectedStyles, shiftedEditorStyles(editableStyles, offset)...) + return editor +} + +func (m App) prEditEligibleReviewerDisplay(editor textEditor) (textEditor, []editorProtectedStyle) { + type eligibleToken struct { + start, end int + login string + insertAt bool + } + eligible := make(map[string]string, len(m.prEditUsers)) + for _, user := range m.prEditUsers { + if user.CanReview && !strings.EqualFold(user.Login, m.details.Author) { + eligible[strings.ToLower(user.Login)] = user.Login + } + } + runes := []rune(editor.Text) + var tokens []eligibleToken + for segmentStart := 0; segmentStart <= len(runes); { + segmentEnd := segmentStart + for segmentEnd < len(runes) && runes[segmentEnd] != ',' { + segmentEnd++ + } + start, end := segmentStart, segmentEnd + for start < end && (runes[start] == ' ' || runes[start] == '\t') { + start++ + } + for end > start && (runes[end-1] == ' ' || runes[end-1] == '\t') { + end-- + } + hasAt := start < end && runes[start] == '@' + loginStart := start + if hasAt { + loginStart++ + } + login := string(runes[loginStart:end]) + if canonical, ok := eligible[strings.ToLower(login)]; ok && login != "" { + tokens = append(tokens, eligibleToken{ + start: start, end: end, login: canonical, insertAt: !hasAt, + }) + } + if segmentEnd == len(runes) { + break + } + segmentStart = segmentEnd + 1 + } + if len(tokens) == 0 { + return editor, nil + } + + insertions := make(map[int]bool) + for _, token := range tokens { + if token.insertAt { + insertions[token.start] = true + } + } + displayRunes := make([]rune, 0, len(runes)+len(insertions)) + for index, value := range runes { + if insertions[index] { + displayRunes = append(displayRunes, '@') + } + displayRunes = append(displayRunes, value) + } + if insertions[len(runes)] { + displayRunes = append(displayRunes, '@') + } + mappedPosition := func(position int) int { + mapped := position + for insertion := range insertions { + if insertion <= position { + mapped++ + } + } + return mapped + } + var styles []editorProtectedStyle + for _, token := range tokens { + start := mappedPosition(token.start) + if token.insertAt { + start-- + } + styles = append(styles, editorProtectedStyle{ + start: start, + end: start + 1 + len([]rune(token.login)), + color: string(authorColor(token.login)), + }) + } + editor.Text = string(displayRunes) + editor.Cursor = mappedPosition(editor.Cursor) + editor.visualAnchor = mappedPosition(editor.visualAnchor) + return editor, styles +} + +func shiftedEditorStyles(styles []editorProtectedStyle, offset int) []editorProtectedStyle { + shifted := make([]editorProtectedStyle, len(styles)) + for index, style := range styles { + style.start += offset + style.end += offset + shifted[index] = style + } + return shifted +} + +func (m App) prEditReadOnlyReviewerPrefix(width int) (string, []editorProtectedStyle) { + editable := parseLoginList(m.prEditEditors[prEditReviewersField].Text) + editableSet := make(map[string]bool, len(editable)) + for _, login := range editable { + editableSet[strings.ToLower(login)] = true + } + requestedSet := make(map[string]bool, len(m.details.RequestedReviewers)) + for _, login := range m.details.RequestedReviewers { + requestedSet[strings.ToLower(login)] = true + } + var tokens []string + var readOnlyReviewers []Reviewer + for _, reviewer := range m.details.Reviewers { + key := strings.ToLower(reviewer.Login) + if editableSet[key] || + (requestedSet[key] && reviewer.State == "REVIEW_REQUESTED") { + continue + } + state := strings.ToLower(strings.ReplaceAll(reviewer.State, "_", " ")) + if state == "" { + state = "reviewed" + } + tokens = append(tokens, "[@"+reviewer.Login+" · "+state+"]") + readOnlyReviewers = append(readOnlyReviewers, reviewer) + } + if len(tokens) == 0 { + return "", nil + } + width = max(1, width) + var prefix strings.Builder + var styles []editorProtectedStyle + lineWidth := 0 + runeOffset := 0 + for index, token := range tokens { + tokenWidth := ansi.StringWidth(token) + if lineWidth > 0 && lineWidth+1+tokenWidth > width { + prefix.WriteByte('\n') + lineWidth = 0 + runeOffset++ + } + if lineWidth > 0 { + prefix.WriteByte(' ') + lineWidth++ + runeOffset++ + } + prefix.WriteString(token) + login := readOnlyReviewers[index].Login + styles = append(styles, editorProtectedStyle{ + start: runeOffset + 1, + end: runeOffset + 2 + len([]rune(login)), + color: string(darkenColor(authorColor(login))), + }) + lineWidth += tokenWidth + runeOffset += len([]rune(token)) + } + if lineWidth+2 >= width { + prefix.WriteByte('\n') + } else { + prefix.WriteString(" ") + } + return prefix.String(), styles +} + func (m *App) ensurePREditCursorVisible() { if m.writeMode != writePREdit { return diff --git a/text_editor.go b/text_editor.go index ecc1f5d..baa5904 100644 --- a/text_editor.go +++ b/text_editor.go @@ -25,6 +25,11 @@ type textFind struct { valid bool } +type editorProtectedStyle struct { + start, end int + color string +} + // textEditor owns buffer and motion state independently of any particular // screen. Inputs can opt into modal behavior without duplicating cursor logic. type textEditor struct { @@ -41,6 +46,8 @@ type textEditor struct { err error hardwareCursor bool highlightMarkdown bool + protectedPrefix int + protectedStyles []editorProtectedStyle keys KeyBindings } @@ -838,7 +845,7 @@ func renderTextEditor(editor textEditor, width int, active bool) []editorRendere rendered := renderEditorVisualLine( line, cursor, editor.Mode, selectionStart, selectionEnd, active && hasSelection, active && onVisualLine, editor.hardwareCursor, - markdownStyles, width, + markdownStyles, width, editor.protectedPrefix, editor.protectedStyles, ) if active && onVisualLine { rendered = pad(rendered, width) @@ -908,7 +915,8 @@ func renderEditorVisualLine( selectionStart, selectionEnd int, hasSelection, showCursor, hardwareCursor bool, markdownStyles []editorMarkdownStyle, - width int, + width, protectedPrefix int, + protectedStyles []editorProtectedStyle, ) string { const ( reverseStart = "\x1b[7m" @@ -919,9 +927,32 @@ func renderEditorVisualLine( runes := []rune(line.text) var rendered strings.Builder selected := false + protectedColor := "" markdownStyle := editorMarkdownPlain for offset, value := range runes { position := line.start + offset + nextProtectedColor := "" + if position < protectedPrefix { + nextProtectedColor = editorMarkdownTheme.Dim + } + for _, style := range protectedStyles { + if position >= style.start && position < style.end { + nextProtectedColor = style.color + break + } + } + if nextProtectedColor != protectedColor { + if colorEnabled && nextProtectedColor != "" { + rendered.WriteString(foregroundSequence(nextProtectedColor)) + } else if colorEnabled && protectedColor != "" { + if showCursor { + rendered.WriteString(foregroundSequence(editorMarkdownTheme.EditorForeground)) + } else { + rendered.WriteString("\x1b[39m") + } + } + protectedColor = nextProtectedColor + } nextMarkdownStyle := editorMarkdownPlain if position < len(markdownStyles) { nextMarkdownStyle = markdownStyles[position] @@ -979,6 +1010,13 @@ func renderEditorVisualLine( if markdownStyle != editorMarkdownPlain { rendered.WriteString(editorMarkdownStyleEnd(showCursor)) } + if protectedColor != "" && colorEnabled { + if showCursor { + rendered.WriteString(foregroundSequence(editorMarkdownTheme.EditorForeground)) + } else { + rendered.WriteString("\x1b[39m") + } + } if showCursor && cursor == line.end { switch mode { case textEditorInsert: diff --git a/theme.go b/theme.go index 816b8f0..400b2dc 100644 --- a/theme.go +++ b/theme.go @@ -231,6 +231,18 @@ func foregroundSequence(color string) string { ) } +func darkenColor(color lipgloss.Color) lipgloss.Color { + value, err := strconv.ParseUint(strings.TrimPrefix(string(color), "#"), 16, 24) + if err != nil { + return color + } + const numerator, denominator = uint64(3), uint64(4) + red := ((value >> 16) & 0xff) * numerator / denominator + green := ((value >> 8) & 0xff) * numerator / denominator + blue := (value & 0xff) * numerator / denominator + return lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", red, green, blue)) +} + func builtinThemePalettes() map[string]themePalette { dark := palette( "dark", "#F0B72F", "#777777", "#D7DAE8", "#FFFFFF", "#3B4261", diff --git a/theme_test.go b/theme_test.go index f9e4706..c20fee5 100644 --- a/theme_test.go +++ b/theme_test.go @@ -41,6 +41,12 @@ func TestNoColorThemeDisablesSyntaxColors(t *testing.T) { } } +func TestDarkenColorRetainsHue(t *testing.T) { + if got := darkenColor(lipgloss.Color("#4080C0")); got != lipgloss.Color("#306090") { + t.Fatalf("darkened color = %q, want #306090", got) + } +} + func TestBuiltinThemesApply(t *testing.T) { defer applyTheme("dark") names := []string{ diff --git a/user_completion.go b/user_completion.go index d206751..170926e 100644 --- a/user_completion.go +++ b/user_completion.go @@ -61,10 +61,47 @@ func selectedLoginPrefix(value string) string { return "" } +func (m App) reviewerInputContext(value string) ( + prefix, query string, + selected []string, + currentComplete bool, +) { + prefix = selectedLoginPrefix(value) + query = currentLoginQuery(value) + selected = parseLoginList(prefix) + if canonical, ok := m.eligibleReviewerLogin(query); ok { + selected = normalizedLogins(append(selected, canonical)) + query = "" + currentComplete = true + } + return prefix, query, selected, currentComplete +} + +func (m App) eligibleReviewerLogin(value string) (string, bool) { + value = strings.TrimSpace(strings.TrimPrefix(value, "@")) + if value == "" { + return "", false + } + for _, user := range m.prEditUsers { + if user.CanReview && + !strings.EqualFold(user.Login, m.details.Author) && + strings.EqualFold(user.Login, value) { + return user.Login, true + } + } + return "", false +} + func (m App) userSuggestions() []userSuggestion { field := m.prEditField query := strings.ToLower(currentLoginQuery(m.prEditEditors[field].Text)) selected := parseLoginList(selectedLoginPrefix(m.prEditEditors[field].Text)) + if field == prEditReviewersField { + _, reviewerQuery, reviewerSelected, _ := + m.reviewerInputContext(m.prEditEditors[field].Text) + query = strings.ToLower(reviewerQuery) + selected = reviewerSelected + } selectedSet := make(map[string]bool, len(selected)) for _, login := range selected { selectedSet[strings.ToLower(login)] = true @@ -78,10 +115,18 @@ func (m App) userSuggestions() []userSuggestion { for _, login := range current { currentSet[strings.ToLower(login)] = true } + existingReviewers := make(map[string]bool, len(m.details.Reviewers)) + if field == prEditReviewersField { + for _, reviewer := range m.details.Reviewers { + existingReviewers[strings.ToLower(reviewer.Login)] = true + } + } now := time.Now() for _, user := range m.prEditUsers { if field == prEditReviewersField { - if !user.CanReview || strings.EqualFold(user.Login, m.details.Author) { + if !user.CanReview || + strings.EqualFold(user.Login, m.details.Author) || + existingReviewers[strings.ToLower(user.Login)] { continue } } else if !user.CanAssign { @@ -197,7 +242,14 @@ func (m *App) completeUserSuggestion() bool { index := clamp(m.prEditUserIndex, 0, len(suggestions)-1) login := suggestions[index].user.Login editor := &m.prEditEditors[m.prEditField] - completed := selectedLoginPrefix(editor.Text) + login + prefix := selectedLoginPrefix(editor.Text) + if m.prEditField == prEditReviewersField { + _, _, _, currentComplete := m.reviewerInputContext(editor.Text) + if currentComplete { + return false + } + } + completed := prefix + login if editor.Text == completed { return false } @@ -208,6 +260,25 @@ func (m *App) completeUserSuggestion() bool { return true } +func (m *App) startNextReviewer() bool { + if m.prEditField != prEditReviewersField { + return false + } + editor := &m.prEditEditors[prEditReviewersField] + if editor.Cursor != len([]rune(editor.Text)) { + return false + } + _, _, _, currentComplete := m.reviewerInputContext(editor.Text) + if !currentComplete { + return false + } + editor.Text = strings.TrimRight(editor.Text, " \t") + ", " + editor.Cursor = len([]rune(editor.Text)) + m.prEditUserIndex = 0 + m.err = nil + return true +} + func (m App) userCompletionLines(width int) []string { width = max(1, width) if m.prEditUsersLoading { @@ -232,9 +303,14 @@ func (m App) userCompletionLines(width int) []string { " ranked by latest 100 default-branch commits", )) } + separatorHelp := "comma separates users" + if m.prEditField == prEditReviewersField { + separatorHelp = "space starts next reviewer" + } lines = append(lines, dimStyle.Render(fmt.Sprintf( - " comma separates users • %s choose • %s complete", + " %s • %s choose • %s complete", + separatorHelp, primaryCombinedKeyLabel( m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion, diff --git a/user_completion_test.go b/user_completion_test.go index 0d3b7f5..54139ae 100644 --- a/user_completion_test.go +++ b/user_completion_test.go @@ -34,11 +34,101 @@ func TestReviewerCompletionSupportsMultipleEligibleUsers(t *testing.T) { m.prEditEditors[prEditReviewersField].Text != "alice, bob" { t.Fatalf("completed reviewers = %q", m.prEditEditors[prEditReviewersField].Text) } + displayEditor := m.prEditDisplayEditor(prEditReviewersField, 76) + if displayEditor.Text != "@alice, @bob" { + t.Fatalf("completed reviewer display = %q", displayEditor.Text) + } + if displayEditor.Cursor != len([]rune(displayEditor.Text)) { + t.Fatalf("completed reviewer display cursor = %d", displayEditor.Cursor) + } 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) + if !strings.Contains(view, "@alice, @bob") || + !strings.Contains(view, "no matching eligible users") { + t.Fatalf("completed reviewer field is inconsistent:\n%s", view) + } +} + +func TestReviewerInputCommitsMultipleEligibleUsersWithSpace(t *testing.T) { + m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) + m.writeMode = writePREdit + m.prEditField = prEditReviewersField + m.details = PRDetails{PullRequest: PullRequest{Author: "author"}} + m.prEditUsers = []RepositoryUser{ + {Login: "alice", CanReview: true}, + {Login: "bob", CanReview: true}, + {Login: "carol", CanReview: true}, + } + m.prEditEditors[prEditReviewersField] = newTextEditor("bob", false) + + suggestions := m.userSuggestions() + if len(suggestions) != 2 || + suggestions[0].user.Login != "alice" || + suggestions[1].user.Login != "carol" { + t.Fatalf("suggestions after complete reviewer = %#v", suggestions) + } + view := ansi.Strip(strings.Join( + m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n", + )) + if !strings.Contains(view, "space starts next reviewer") { + t.Fatalf("multi-reviewer completion help missing:\n%s", view) + } + updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeySpace}) + m = updated.(App) + if got := m.prEditEditors[prEditReviewersField].Text; got != "bob, " { + t.Fatalf("space after complete reviewer produced %q", got) + } + if !m.completeUserSuggestion() { + t.Fatal("next reviewer suggestion was not completed") + } + if got := m.prEditEditors[prEditReviewersField].Text; got != "bob, alice" { + t.Fatalf("multiple reviewer input = %q", got) + } + suggestions = m.userSuggestions() + if len(suggestions) != 1 || suggestions[0].user.Login != "carol" { + t.Fatalf("already selected reviewers remained in suggestions: %#v", suggestions) + } + display := m.prEditDisplayEditor(prEditReviewersField, 76) + if display.Text != "@bob, @alice" { + t.Fatalf("multiple reviewer display = %q", display.Text) + } + + m.prEditEditors[prEditReviewersField] = newTextEditor("bo", false) + updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeySpace}) + m = updated.(App) + if got := m.prEditEditors[prEditReviewersField].Text; got != "bo" { + t.Fatalf("space after incomplete reviewer produced %q", got) + } +} + +func TestReviewerDisplayColorsOnlyCompleteEligibleNames(t *testing.T) { + m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) + m.details = PRDetails{PullRequest: PullRequest{Author: "author"}} + m.prEditUsers = []RepositoryUser{ + {Login: "bob", CanReview: true}, + {Login: "assignee-only", CanAssign: true}, + {Login: "author", CanReview: true}, + } + m.prEditEditors[prEditReviewersField] = newTextEditor("bo, assignee-only, author", false) + + display := m.prEditDisplayEditor(prEditReviewersField, 76) + if display.Text != "bo, assignee-only, author" || len(display.protectedStyles) != 0 { + t.Fatalf("partial or ineligible reviewers were decorated: %#v", display) + } + + m.prEditEditors[prEditReviewersField] = newTextEditor("bob, @BOB", false) + display = m.prEditDisplayEditor(prEditReviewersField, 76) + if display.Text != "@bob, @BOB" { + t.Fatalf("eligible reviewer display = %q", display.Text) + } + if len(display.protectedStyles) != 2 { + t.Fatalf("eligible reviewer styles = %#v", display.protectedStyles) + } + for _, style := range display.protectedStyles { + if style.color != string(authorColor("bob")) { + t.Fatalf("eligible reviewer color = %q, want normal author color", style.color) + } } } @@ -86,6 +176,111 @@ func TestPREditStartsWithCurrentRequestedReviewers(t *testing.T) { } } +func TestPREditShowsCompletedAndTeamReviewersReadOnly(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{"pending-user", "rerequested-user"}, + Reviewers: []Reviewer{ + {Login: "approved-user", State: "APPROVED"}, + {Login: "backend-team", State: "REVIEW_REQUESTED"}, + {Login: "commented-user", State: "COMMENTED"}, + {Login: "pending-user", State: "REVIEW_REQUESTED"}, + {Login: "rerequested-user", State: "APPROVED"}, + }, + Permissions: ViewerPermissions{CanUpdatePR: true, CanAssign: true}, + } + m.startPREdit() + + if got := m.prEditEditors[prEditReviewersField].Text; got != "pending-user, rerequested-user" { + t.Fatalf("editable reviewer requests = %q", got) + } + view := ansi.Strip(strings.Join( + m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n", + )) + for _, expected := range []string{ + "pending requests editable", + "[@approved-user · approved]", + "[@backend-team · review requested]", + "[@commented-user · commented]", + } { + if !strings.Contains(view, expected) { + t.Fatalf("reviewer field does not show %q:\n%s", expected, view) + } + } + for _, editable := range []string{"pending-user", "rerequested-user"} { + if strings.Contains(view, "[@"+editable) { + t.Fatalf("pending request @%s was rendered as a protected token:\n%s", editable, view) + } + } + displayEditor := m.prEditDisplayEditor(prEditReviewersField, 76) + if len(displayEditor.protectedStyles) != 3 { + t.Fatalf("protected reviewer styles = %#v", displayEditor.protectedStyles) + } + firstStyle := displayEditor.protectedStyles[0] + displayRunes := []rune(displayEditor.Text) + if got := string(displayRunes[firstStyle.start:firstStyle.end]); got != "@approved-user" { + t.Fatalf("first protected author span = %q", got) + } + if firstStyle.color != string(darkenColor(authorColor("approved-user"))) { + t.Fatalf("protected author color = %q, want darkened deterministic color", firstStyle.color) + } + + m.prEditEditors[prEditReviewersField] = newTextEditor("pending-user", false) + view = ansi.Strip(strings.Join( + m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n", + )) + if !strings.Contains(view, "[@rerequested-user · approved]") { + t.Fatalf("removed re-review request did not retain its submitted review read-only:\n%s", view) + } + + m.prEditEditors[prEditReviewersField] = newTextEditor("", false) + for range 20 { + m.prEditEditors[prEditReviewersField].handleKey( + tea.KeyMsg{Type: tea.KeyBackspace}, false, + ) + m.prEditEditors[prEditReviewersField].handleKey( + tea.KeyMsg{Type: tea.KeyDelete}, false, + ) + } + if m.prEditEditors[prEditReviewersField].Text != "" || + !strings.Contains( + ansi.Strip(strings.Join( + m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n", + )), + "[@approved-user · approved]", + ) { + t.Fatal("editing the reviewer field modified a protected reviewer token") + } +} + +func TestReviewerSuggestionsExcludeExistingReviewers(t *testing.T) { + m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) + m.details = PRDetails{ + PullRequest: PullRequest{Author: "author"}, + Reviewers: []Reviewer{ + {Login: "approved-user", State: "APPROVED"}, + {Login: "commented-user", State: "COMMENTED"}, + }, + } + m.prEditField = prEditReviewersField + m.prEditEditors[prEditReviewersField] = newTextEditor("", false) + m.prEditUsers = []RepositoryUser{ + {Login: "approved-user", CanReview: true}, + {Login: "commented-user", CanReview: true}, + {Login: "new-user", CanReview: true}, + } + + suggestions := m.userSuggestions() + if len(suggestions) != 1 || suggestions[0].user.Login != "new-user" { + t.Fatalf("reviewer suggestions include existing reviewers: %#v", suggestions) + } +} + func TestPeopleOnlyPREditSkipsCoreMetadataMutation(t *testing.T) { service := &recordingPRService{} m := NewApp(service, "o", "r", false, 50, time.Second)