361 lines
10 KiB
Go
361 lines
10 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) 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
|
|
}
|