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) 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 } 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 } 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) || existingReviewers[strings.ToLower(user.Login)] { 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] 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 } editor.Text = completed editor.Cursor = len([]rune(completed)) m.prEditUserIndex = 0 m.err = nil 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 { 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", )) } separatorHelp := "comma separates users" if m.prEditField == prEditReviewersField { separatorHelp = "space starts next reviewer" } lines = append(lines, dimStyle.Render(fmt.Sprintf( " %s • %s choose • %s complete", separatorHelp, 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 }