add reviewer assigning
This commit is contained in:
360
user_completion.go
Normal file
360
user_completion.go
Normal file
@@ -0,0 +1,360 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user