fix: reviewers not correctly shown on dashboard edit screen
This commit is contained in:
21
README.md
21
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
|
||||
|
||||
|
||||
202
pr_editor.go
202
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
12
theme.go
12
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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user