Files
diple/user_completion.go

437 lines
12 KiB
Go

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
}