add reviewer assigning

This commit is contained in:
2026-07-29 09:49:51 +02:00
parent a82f5e7e9f
commit 1d90e364ee
17 changed files with 1475 additions and 47 deletions

View File

@@ -70,7 +70,8 @@ When GitHub reports that the authenticated user has permission, diple can:
- reply to review threads; - reply to review threads;
- resolve and unresolve review threads; - resolve and unresolve review threads;
- edit the PR title, Markdown description, and target branch; - edit the PR title, Markdown description, target branch, requested reviewers,
and assignees;
- enable or disable auto-merge; and - enable or disable auto-merge; and
- merge immediately when the PR is eligible. - merge immediately when the PR is eligible.
@@ -78,8 +79,8 @@ The UI explains unavailable actions through its write-capability gate.
Metadata and reply drafts are persisted locally so cancellation or a restart Metadata and reply drafts are persisted locally so cancellation or a restart
does not silently discard work. does not silently discard work.
Reactions are currently read-only. Assigning reviewers, assignees, labels, or Reactions are currently read-only. Assigning labels or milestones is not
milestones is not implemented yet. implemented yet.
## Requirements ## Requirements
@@ -197,7 +198,13 @@ reference.
The PR description editor defaults to Vim-style modal editing, including The PR description editor defaults to Vim-style modal editing, including
Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard
yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a
non-modal editor. Target-branch completion uses `ctrl+n` and `ctrl+p`. 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.
## Configuration ## Configuration

View File

@@ -75,7 +75,7 @@ editing are already implemented.
## Data completeness and compatibility ## Data completeness and compatibility
- Paginate or explicitly mark truncation for the remaining fixed-size - Paginate or explicitly mark truncation for the remaining fixed-size
connections: assignees, labels, review requests, latest reviews, repository connections: labels, review requests, latest reviews, repository
rulesets, and rules within a ruleset. rulesets, and rules within a ruleset.
- Model pending reviews, minimized comments, deleted comments/users, edited - Model pending reviews, minimized comments, deleted comments/users, edited
timestamps, and explicit reply relationships. timestamps, and explicit reply relationships.
@@ -92,9 +92,8 @@ editing are already implemented.
## Write roadmap ## Write roadmap
- Add fuzzy multi-select editors for requested reviewers, assignees, labels, - Add fuzzy editors for labels and milestone with an explicit before/after
and milestone. Support adding, removing, and clearing values with an explicit confirmation.
before/after confirmation.
- Add top-level PR conversation replies and editing/deleting the viewer's own - Add top-level PR conversation replies and editing/deleting the viewer's own
comments. Fetch and enforce per-comment update/delete permissions. comments. Fetch and enforce per-comment update/delete permissions.
- Add reaction add/remove actions while retaining the current read-only counts. - Add reaction add/remove actions while retaining the current read-only counts.

View File

@@ -14,16 +14,7 @@ import (
) )
func (c *GitHubClient) PullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) { func (c *GitHubClient) PullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
base := strings.TrimSuffix(c.endpoint, "/") requestURL := c.restBaseURL() + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) +
switch {
case base == "https://api.github.com/graphql":
base = "https://api.github.com"
case strings.HasSuffix(base, "/api/graphql"):
base = strings.TrimSuffix(base, "/api/graphql") + "/api/v3"
default:
base = strings.TrimSuffix(base, "/graphql")
}
requestURL := base + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) +
"/pulls/" + strconv.Itoa(number) "/pulls/" + strconv.Itoa(number)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil { if err != nil {

View File

@@ -56,11 +56,10 @@ func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) {
}, },
BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true},
} }
command := m.startPREdit() if command := m.startPREdit(); command == nil {
if command == nil {
t.Fatal("opening the editor did not request branches") t.Fatal("opening the editor did not request branches")
} }
updated, _ := m.Update(command()) updated, _ := m.Update(m.loadPREditBranches()())
m = updated.(App) m = updated.(App)
m.prEditField = prEditBaseField m.prEditField = prEditBaseField
m.prEditEditors[prEditBaseField] = newTextEditor("release", false) m.prEditEditors[prEditBaseField] = newTextEditor("release", false)
@@ -79,7 +78,7 @@ func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) {
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab}) updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(App) m = updated.(App)
if m.prEditField != prEditBodyField { if m.prEditField != prEditReviewersField {
t.Fatalf("second tab did not advance: field=%d", m.prEditField) t.Fatalf("second tab did not advance: field=%d", m.prEditField)
} }
} }

View File

@@ -164,6 +164,19 @@ func (c *CachedGitHubService) UpdatePullRequest(
return writer.UpdatePullRequest(ctx, pullRequestID, update) return writer.UpdatePullRequest(ctx, pullRequestID, update)
} }
func (c *CachedGitHubService) UpdatePullRequestPeople(
ctx context.Context,
owner, repo string,
number int,
update PullRequestPeopleUpdate,
) (PullRequestPeople, error) {
writer, ok := c.remote.(GitHubPullRequestPeopleWriteService)
if !ok {
return PullRequestPeople{}, errors.New("GitHub service does not support updating pull request people")
}
return writer.UpdatePullRequestPeople(ctx, owner, repo, number, update)
}
func (c *CachedGitHubService) SetPullRequestAutoMerge( func (c *CachedGitHubService) SetPullRequestAutoMerge(
ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool, ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool,
) (*AutoMergeRequest, error) { ) (*AutoMergeRequest, error) {
@@ -209,6 +222,29 @@ func (c *CachedGitHubService) ListBranches(
return nil, err return nil, err
} }
func (c *CachedGitHubService) ListRepositoryUsers(
ctx context.Context, owner, repo string,
) ([]RepositoryUser, error) {
service, ok := c.remote.(GitHubRepositoryPeopleService)
if !ok {
return nil, errors.New("GitHub service does not support listing repository users")
}
users, err := service.ListRepositoryUsers(ctx, owner, repo)
if err == nil {
_ = c.write(c.repositoryUsersKey(owner, repo), users)
return users, nil
}
var cached cacheEnvelope[[]RepositoryUser]
if _, cacheErr := c.read(c.repositoryUsersKey(owner, repo), &cached); cacheErr == nil {
c.health.set(HealthComponent{
Name: "repository user cache", Level: healthWarning,
Summary: "using cached repository users", Detail: err.Error(), UpdatedAt: time.Now(),
})
return cached.Value, nil
}
return nil, err
}
func (c *CachedGitHubService) EnrichPullRequest( func (c *CachedGitHubService) EnrichPullRequest(
ctx context.Context, details PRDetails, ctx context.Context, details PRDetails,
) PRDetailsEnrichment { ) PRDetailsEnrichment {
@@ -238,6 +274,10 @@ func (c *CachedGitHubService) branchesKey(owner, repo string) string {
return fmt.Sprintf("branches:%s/%s", owner, repo) return fmt.Sprintf("branches:%s/%s", owner, repo)
} }
func (c *CachedGitHubService) repositoryUsersKey(owner, repo string) string {
return fmt.Sprintf("repository-users:%s/%s", owner, repo)
}
func (c *CachedGitHubService) file(key string) string { func (c *CachedGitHubService) file(key string) string {
sum := sha256.Sum256([]byte(key)) sum := sha256.Sum256([]byte(key))
return filepath.Join(c.dir, hex.EncodeToString(sum[:])+".json") return filepath.Join(c.dir, hex.EncodeToString(sum[:])+".json")

View File

@@ -22,6 +22,9 @@ type savedDraft struct {
Reply string `json:"reply,omitempty"` Reply string `json:"reply,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
BaseRef string `json:"base_ref,omitempty"` BaseRef string `json:"base_ref,omitempty"`
Reviewers string `json:"reviewers,omitempty"`
Assignees string `json:"assignees,omitempty"`
PeopleSet bool `json:"people_set,omitempty"`
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
OriginalUpdatedAt time.Time `json:"original_updated_at,omitempty"` OriginalUpdatedAt time.Time `json:"original_updated_at,omitempty"`
SavedAt time.Time `json:"saved_at"` SavedAt time.Time `json:"saved_at"`
@@ -182,6 +185,10 @@ func (m *App) restorePREditDraft() {
} }
m.prEditEditors[prEditTitleField] = newTextEditor(draft.Title, false) m.prEditEditors[prEditTitleField] = newTextEditor(draft.Title, false)
m.prEditEditors[prEditBaseField] = newTextEditor(draft.BaseRef, false) m.prEditEditors[prEditBaseField] = newTextEditor(draft.BaseRef, false)
if draft.PeopleSet {
m.prEditEditors[prEditReviewersField] = newTextEditor(draft.Reviewers, false)
m.prEditEditors[prEditAssigneesField] = newTextEditor(draft.Assignees, false)
}
m.prEditEditors[prEditBodyField] = newTextEditor( m.prEditEditors[prEditBodyField] = newTextEditor(
normalizeLineEndings(draft.Body), m.editorMode == "vim", normalizeLineEndings(draft.Body), m.editorMode == "vim",
) )
@@ -201,6 +208,9 @@ func (m *App) queuePREditDraft() tea.Cmd {
Kind: "pr-metadata", Owner: m.details.Owner, Repository: m.details.Repository, Kind: "pr-metadata", Owner: m.details.Owner, Repository: m.details.Repository,
Number: m.details.Number, Title: m.prEditEditors[prEditTitleField].Text, Number: m.details.Number, Title: m.prEditEditors[prEditTitleField].Text,
BaseRef: m.prEditEditors[prEditBaseField].Text, BaseRef: m.prEditEditors[prEditBaseField].Text,
Reviewers: m.prEditEditors[prEditReviewersField].Text,
Assignees: m.prEditEditors[prEditAssigneesField].Text,
PeopleSet: true,
Body: m.prEditEditors[prEditBodyField].Text, Body: m.prEditEditors[prEditBodyField].Text,
OriginalUpdatedAt: m.prEditOriginal.UpdatedAt, OriginalUpdatedAt: m.prEditOriginal.UpdatedAt,
}) })

100
github.go
View File

@@ -30,6 +30,12 @@ type GitHubPullRequestWriteService interface {
UpdatePullRequest(context.Context, string, PullRequestMetadata) (PullRequestMetadata, error) UpdatePullRequest(context.Context, string, PullRequestMetadata) (PullRequestMetadata, error)
} }
type GitHubPullRequestPeopleWriteService interface {
UpdatePullRequestPeople(
context.Context, string, string, int, PullRequestPeopleUpdate,
) (PullRequestPeople, error)
}
type GitHubMergeService interface { type GitHubMergeService interface {
SetPullRequestAutoMerge(context.Context, string, string, string, bool) (*AutoMergeRequest, error) SetPullRequestAutoMerge(context.Context, string, string, string, bool) (*AutoMergeRequest, error)
MergePullRequest(context.Context, string, string, string) (PullRequestMergeResult, error) MergePullRequest(context.Context, string, string, string) (PullRequestMergeResult, error)
@@ -39,6 +45,10 @@ type GitHubBranchService interface {
ListBranches(context.Context, string, string) ([]RepositoryBranch, error) ListBranches(context.Context, string, string) ([]RepositoryBranch, error)
} }
type GitHubRepositoryPeopleService interface {
ListRepositoryUsers(context.Context, string, string) ([]RepositoryUser, error)
}
type GitHubEnrichmentService interface { type GitHubEnrichmentService interface {
EnrichPullRequest(context.Context, PRDetails) PRDetailsEnrichment EnrichPullRequest(context.Context, PRDetails) PRDetailsEnrichment
} }
@@ -464,7 +474,10 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
} }
} }
author { login } author { login }
assignees(first: 20) { nodes { login } } assignees(first: 100) {
pageInfo { hasNextPage endCursor }
nodes { id login name }
}
labels(first: 20) { nodes { name } } labels(first: 20) { nodes { name } }
milestone { title } milestone { title }
additions deletions changedFiles additions deletions changedFiles
@@ -565,6 +578,18 @@ query TimelinePage($owner: String!, $name: String!, $number: Int!, $after: Strin
} }
}` }`
const assigneesPageQuery = `
query AssigneesPage($owner: String!, $name: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
assignees(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { id login name }
}
}
}
}`
const checkContextsPageQuery = ` const checkContextsPageQuery = `
query CheckContextsPage($id: ID!, $after: String) { query CheckContextsPage($id: ID!, $after: String) {
node(id: $id) { node(id: $id) {
@@ -722,6 +747,11 @@ type githubPRCommentConnection struct {
Nodes []githubPRComment `json:"nodes"` Nodes []githubPRComment `json:"nodes"`
} }
type githubUserConnection struct {
PageInfo githubPageInfo `json:"pageInfo"`
Nodes []githubActor `json:"nodes"`
}
type githubReviewSummary struct { type githubReviewSummary struct {
ID, Body, State, URL string ID, Body, State, URL string
SubmittedAt time.Time SubmittedAt time.Time
@@ -819,9 +849,7 @@ type githubPullRequestDetails struct {
Position, EstimatedTimeToMerge int Position, EstimatedTimeToMerge int
EnqueuedAt time.Time EnqueuedAt time.Time
} }
Assignees struct { Assignees githubUserConnection
Nodes []githubActor `json:"nodes"`
}
Labels struct { Labels struct {
Nodes []struct { Nodes []struct {
Name string `json:"name"` Name string `json:"name"`
@@ -954,6 +982,36 @@ func (c *GitHubClient) allConversationComments(
return nodes, nil return nodes, nil
} }
func (c *GitHubClient) allAssignees(
ctx context.Context, owner, name string, number int, connection githubUserConnection,
) ([]githubActor, error) {
nodes := append([]githubActor(nil), connection.Nodes...)
for pages := 0; connection.PageInfo.HasNextPage; pages++ {
if pages >= 100 {
return nil, errors.New("assignee pagination exceeded 100 pages")
}
var data struct {
Repository *struct {
PullRequest *struct {
Assignees githubUserConnection `json:"assignees"`
} `json:"pullRequest"`
} `json:"repository"`
}
if err := c.query(ctx, assigneesPageQuery, map[string]any{
"owner": owner, "name": name, "number": number,
"after": connection.PageInfo.EndCursor,
}, &data); err != nil {
return nil, fmt.Errorf("load more assignees: %w", err)
}
if data.Repository == nil || data.Repository.PullRequest == nil {
return nil, errors.New("pull request disappeared while loading assignees")
}
connection = data.Repository.PullRequest.Assignees
nodes = append(nodes, connection.Nodes...)
}
return nodes, nil
}
func (c *GitHubClient) allReviewSummaries( func (c *GitHubClient) allReviewSummaries(
ctx context.Context, owner, name string, number int, connection githubReviewSummaryConnection, ctx context.Context, owner, name string, number int, connection githubReviewSummaryConnection,
) ([]githubReviewSummary, error) { ) ([]githubReviewSummary, error) {
@@ -1112,22 +1170,30 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
node := data.Repository.PullRequest node := data.Repository.PullRequest
var ( var (
threadNodes []githubReviewThread threadNodes []githubReviewThread
assigneeNodes []githubActor
conversationNodes []githubPRComment conversationNodes []githubPRComment
reviewNodes []githubReviewSummary reviewNodes []githubReviewSummary
timelineNodes []githubTimelineNode timelineNodes []githubTimelineNode
checkNodes []githubCheckContext checkNodes []githubCheckContext
threadErr error threadErr error
assigneeErr error
conversationErr error conversationErr error
reviewErr error reviewErr error
timelineErr error timelineErr error
checkErr error checkErr error
wait sync.WaitGroup wait sync.WaitGroup
) )
wait.Add(4) wait.Add(5)
go func() { go func() {
defer wait.Done() defer wait.Done()
threadNodes, threadErr = c.allReviewThreads(ctx, owner, name, number, node.ReviewThreads) threadNodes, threadErr = c.allReviewThreads(ctx, owner, name, number, node.ReviewThreads)
}() }()
go func() {
defer wait.Done()
assigneeNodes, assigneeErr = c.allAssignees(
ctx, owner, name, number, node.Assignees,
)
}()
go func() { go func() {
defer wait.Done() defer wait.Done()
conversationNodes, conversationErr = c.allConversationComments(ctx, owner, name, number, node.Comments) conversationNodes, conversationErr = c.allConversationComments(ctx, owner, name, number, node.Comments)
@@ -1152,6 +1218,9 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
if threadErr != nil { if threadErr != nil {
threadNodes = append([]githubReviewThread(nil), node.ReviewThreads.Nodes...) threadNodes = append([]githubReviewThread(nil), node.ReviewThreads.Nodes...)
} }
if assigneeErr != nil {
assigneeNodes = append([]githubActor(nil), node.Assignees.Nodes...)
}
if conversationErr != nil { if conversationErr != nil {
conversationNodes = append([]githubPRComment(nil), node.Comments.Nodes...) conversationNodes = append([]githubPRComment(nil), node.Comments.Nodes...)
} }
@@ -1186,6 +1255,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
Permissions: ViewerPermissions{ Permissions: ViewerPermissions{
Repository: data.Repository.ViewerPermission, Repository: data.Repository.ViewerPermission,
CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact, CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact,
CanAssign: viewerCanAssign(data.Repository.ViewerPermission),
CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge, CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge,
CanDisableMerge: node.ViewerCanDisableAutoMerge, CanDisableMerge: node.ViewerCanDisableAutoMerge,
}, },
@@ -1211,7 +1281,8 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
} }
for component, err := range map[string]error{ for component, err := range map[string]error{
"review threads": threadErr, "conversation": conversationErr, "review threads": threadErr, "conversation": conversationErr,
"submitted reviews": reviewErr, "timeline": timelineErr, "checks": checkErr, "submitted reviews": reviewErr, "assignees": assigneeErr,
"timeline": timelineErr, "checks": checkErr,
} { } {
if err != nil { if err != nil {
details.DataIssues = append(details.DataIssues, DataIssue{ details.DataIssues = append(details.DataIssues, DataIssue{
@@ -1260,7 +1331,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
if node.Milestone != nil { if node.Milestone != nil {
details.Milestone = node.Milestone.Title details.Milestone = node.Milestone.Title
} }
for _, assignee := range node.Assignees.Nodes { for _, assignee := range assigneeNodes {
details.Assignees = append(details.Assignees, assignee.Login) details.Assignees = append(details.Assignees, assignee.Login)
} }
reviewers := map[string]string{} reviewers := map[string]string{}
@@ -1269,6 +1340,11 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
if login != "" { if login != "" {
reviewers[login] = "REVIEW_REQUESTED" reviewers[login] = "REVIEW_REQUESTED"
} }
if request.RequestedReviewer.Login != "" {
details.RequestedReviewers = append(
details.RequestedReviewers, request.RequestedReviewer.Login,
)
}
} }
for _, review := range node.LatestReviews.Nodes { for _, review := range node.LatestReviews.Nodes {
if review.Author != nil { if review.Author != nil {
@@ -1279,6 +1355,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
details.Reviewers = append(details.Reviewers, Reviewer{Login: login, State: state}) details.Reviewers = append(details.Reviewers, Reviewer{Login: login, State: state})
} }
sort.Slice(details.Reviewers, func(i, j int) bool { return details.Reviewers[i].Login < details.Reviewers[j].Login }) sort.Slice(details.Reviewers, func(i, j int) bool { return details.Reviewers[i].Login < details.Reviewers[j].Login })
sort.Strings(details.RequestedReviewers)
if len(node.Commits.Nodes) > 0 && node.Commits.Nodes[0].Commit.StatusCheckRollup != nil { if len(node.Commits.Nodes) > 0 && node.Commits.Nodes[0].Commit.StatusCheckRollup != nil {
rollup := node.Commits.Nodes[0].Commit.StatusCheckRollup rollup := node.Commits.Nodes[0].Commit.StatusCheckRollup
details.CheckState = rollup.State details.CheckState = rollup.State
@@ -1646,6 +1723,15 @@ func actorLogin(actor *githubActor) string {
return actor.Login return actor.Login
} }
func viewerCanAssign(permission string) bool {
switch strings.ToUpper(permission) {
case "TRIAGE", "WRITE", "MAINTAIN", "ADMIN":
return true
default:
return false
}
}
func intValue(value *int) int { func intValue(value *int) int {
if value == nil { if value == nil {
return 0 return 0

328
github_people.go Normal file
View File

@@ -0,0 +1,328 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
)
const repositoryReviewersQuery = `
query RepositoryReviewers($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
collaborators(first: 100, after: $after, affiliation: ALL) {
pageInfo { hasNextPage endCursor }
nodes { id login name }
}
}
}`
const repositoryAssigneesQuery = `
query RepositoryAssignees($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
assignableUsers(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { id login name }
}
}
}`
const repositoryContributionsQuery = `
query RepositoryContributions($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
defaultBranchRef {
target {
... on Commit {
history(first: 100) {
nodes {
committedDate
additions
author { user { login } }
}
}
}
}
}
}
}`
type repositoryUserNode struct {
ID, Login, Name string
}
type repositoryUserConnection struct {
PageInfo githubPageInfo
Nodes []repositoryUserNode
}
type repositoryContribution struct {
CommittedDate time.Time
Additions int
Author struct {
User *githubActor
}
}
func (c *GitHubClient) ListRepositoryUsers(
ctx context.Context, owner, repo string,
) ([]RepositoryUser, error) {
reviewers, err := c.listRepositoryUserConnection(
ctx, repositoryReviewersQuery, "collaborators", owner, repo,
)
if err != nil {
return nil, fmt.Errorf("list eligible reviewers: %w", err)
}
assignees, err := c.listRepositoryUserConnection(
ctx, repositoryAssigneesQuery, "assignableUsers", owner, repo,
)
if err != nil {
return nil, fmt.Errorf("list eligible assignees: %w", err)
}
users := make(map[string]RepositoryUser, len(reviewers)+len(assignees))
for _, reviewer := range reviewers {
key := strings.ToLower(reviewer.Login)
users[key] = RepositoryUser{
ID: reviewer.ID, Login: reviewer.Login, Name: reviewer.Name, CanReview: true,
}
}
for _, assignee := range assignees {
key := strings.ToLower(assignee.Login)
user := users[key]
if user.ID == "" {
user.ID, user.Login, user.Name = assignee.ID, assignee.Login, assignee.Name
}
user.CanAssign = true
users[key] = user
}
if contributions, contributionErr := c.listRecentContributions(ctx, owner, repo); contributionErr == nil {
for _, contribution := range contributions {
if contribution.Author.User == nil || contribution.Author.User.Login == "" {
continue
}
key := strings.ToLower(contribution.Author.User.Login)
user, exists := users[key]
if !exists {
continue
}
user.RecentCommits++
user.RecentAdditions += max(0, contribution.Additions)
if contribution.CommittedDate.After(user.LastContributionAt) {
user.LastContributionAt = contribution.CommittedDate
}
users[key] = user
}
}
result := make([]RepositoryUser, 0, len(users))
for _, user := range users {
result = append(result, user)
}
sortRepositoryUsers(result)
return result, nil
}
func (c *GitHubClient) listRecentContributions(
ctx context.Context, owner, repo string,
) ([]repositoryContribution, error) {
var data struct {
Repository *struct {
DefaultBranchRef *struct {
Target *struct {
History struct {
Nodes []repositoryContribution
}
}
}
}
}
if err := c.query(ctx, repositoryContributionsQuery, map[string]any{
"owner": owner, "name": repo,
}, &data); err != nil {
return nil, err
}
if data.Repository == nil {
return nil, errors.New("repository was not found")
}
if data.Repository.DefaultBranchRef == nil ||
data.Repository.DefaultBranchRef.Target == nil {
return nil, nil
}
return data.Repository.DefaultBranchRef.Target.History.Nodes, nil
}
func (c *GitHubClient) listRepositoryUserConnection(
ctx context.Context, query, field, owner, repo string,
) ([]repositoryUserNode, error) {
var result []repositoryUserNode
cursor := ""
for {
var data struct {
Repository *struct {
Collaborators repositoryUserConnection
AssignableUsers repositoryUserConnection `json:"assignableUsers"`
}
}
if err := c.query(ctx, query, map[string]any{
"owner": owner, "name": repo, "after": nullableCursor(cursor),
}, &data); err != nil {
return nil, err
}
if data.Repository == nil {
return nil, errors.New("repository was not found")
}
connection := data.Repository.Collaborators
if field == "assignableUsers" {
connection = data.Repository.AssignableUsers
}
result = append(result, connection.Nodes...)
if !connection.PageInfo.HasNextPage {
return result, nil
}
if connection.PageInfo.EndCursor == "" || connection.PageInfo.EndCursor == cursor {
return nil, errors.New("GitHub returned an empty user pagination cursor")
}
cursor = connection.PageInfo.EndCursor
}
}
func sortRepositoryUsers(users []RepositoryUser) {
slices.SortStableFunc(users, func(left, right RepositoryUser) int {
return strings.Compare(strings.ToLower(left.Login), strings.ToLower(right.Login))
})
}
func (c *GitHubClient) UpdatePullRequestPeople(
ctx context.Context,
owner, repo string,
number int,
update PullRequestPeopleUpdate,
) (PullRequestPeople, error) {
added, removed := loginDifference(update.Reviewers, update.CurrentReviewers),
loginDifference(update.CurrentReviewers, update.Reviewers)
result := PullRequestPeople{
Reviewers: append([]string(nil), update.CurrentReviewers...),
Assignees: append([]string(nil), update.CurrentAssignees...),
}
if len(added) > 0 {
if err := c.updateReviewRequests(ctx, http.MethodPost, owner, repo, number, added); err != nil {
return result, fmt.Errorf("add reviewers: %w", err)
}
result.Reviewers = normalizedLogins(append(result.Reviewers, added...))
}
if len(removed) > 0 {
if err := c.updateReviewRequests(ctx, http.MethodDelete, owner, repo, number, removed); err != nil {
return result, fmt.Errorf("remove reviewers: %w", err)
}
result.Reviewers = append([]string(nil), update.Reviewers...)
}
if !equalLoginSets(update.Assignees, update.CurrentAssignees) {
if err := c.replaceAssignees(ctx, owner, repo, number, update.Assignees); err != nil {
return result, fmt.Errorf("update assignees: %w", err)
}
result.Assignees = append([]string(nil), update.Assignees...)
}
result.Reviewers = append([]string(nil), update.Reviewers...)
return result, nil
}
func equalLoginSets(left, right []string) bool {
return len(loginDifference(left, right)) == 0 &&
len(loginDifference(right, left)) == 0
}
func loginDifference(left, right []string) []string {
existing := make(map[string]bool, len(right))
for _, login := range right {
existing[strings.ToLower(login)] = true
}
var result []string
for _, login := range left {
if !existing[strings.ToLower(login)] {
result = append(result, login)
}
}
return result
}
func (c *GitHubClient) updateReviewRequests(
ctx context.Context, method, owner, repo string, number int, reviewers []string,
) error {
return c.restJSON(
ctx, method,
"/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+
"/pulls/"+strconv.Itoa(number)+"/requested_reviewers",
map[string]any{"reviewers": reviewers}, nil,
)
}
func (c *GitHubClient) replaceAssignees(
ctx context.Context, owner, repo string, number int, assignees []string,
) error {
return c.restJSON(
ctx, http.MethodPatch,
"/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+
"/issues/"+strconv.Itoa(number),
map[string]any{"assignees": assignees}, nil,
)
}
func (c *GitHubClient) restJSON(
ctx context.Context, method, requestPath string, input, output any,
) error {
var body io.Reader
if input != nil {
encoded, err := json.Marshal(input)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(
ctx, method, c.restBaseURL()+requestPath, body,
)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+c.token)
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "diple")
response, err := c.http.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf(
"GitHub returned %s: %s",
response.Status, strings.TrimSpace(string(data)),
)
}
if output == nil || response.StatusCode == http.StatusNoContent {
return nil
}
if err := json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(output); err != nil {
return fmt.Errorf("decode GitHub response: %w", err)
}
return nil
}
func (c *GitHubClient) restBaseURL() string {
base := strings.TrimSuffix(c.endpoint, "/")
switch {
case base == "https://api.github.com/graphql":
return "https://api.github.com"
case strings.HasSuffix(base, "/api/graphql"):
return strings.TrimSuffix(base, "/api/graphql") + "/api/v3"
default:
return strings.TrimSuffix(base, "/graphql")
}
}

179
github_people_test.go Normal file
View File

@@ -0,0 +1,179 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
)
func TestListRepositoryUsersCombinesReviewerAndAssigneeEligibility(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
switch {
case strings.Contains(request.Query, "RepositoryReviewers"):
if request.Variables["after"] == nil {
_, _ = w.Write([]byte(`{"data":{"repository":{"collaborators":{
"pageInfo":{"hasNextPage":true,"endCursor":"next"},
"nodes":[
{"id":"u1","login":"alice","name":"Alice"},
{"id":"u2","login":"bob","name":"Bob"}
]
}}}}`))
} else {
_, _ = w.Write([]byte(`{"data":{"repository":{"collaborators":{
"pageInfo":{"hasNextPage":false},
"nodes":[{"id":"u4","login":"dave","name":"Dave"}]
}}}}`))
}
case strings.Contains(request.Query, "RepositoryAssignees"):
_, _ = w.Write([]byte(`{"data":{"repository":{"assignableUsers":{
"pageInfo":{"hasNextPage":false},
"nodes":[
{"id":"u2","login":"bob","name":"Bob"},
{"id":"u3","login":"carol","name":"Carol"}
]
}}}}`))
case strings.Contains(request.Query, "RepositoryContributions"):
_, _ = w.Write([]byte(`{"data":{"repository":{"defaultBranchRef":{"target":{"history":{
"nodes":[
{"committedDate":"2026-07-28T12:00:00Z","additions":42,
"author":{"user":{"login":"alice"}}},
{"committedDate":"2026-07-27T12:00:00Z","additions":8,
"author":{"user":{"login":"alice"}}},
{"committedDate":"2026-06-01T12:00:00Z","additions":5,
"author":{"user":{"login":"bob"}}}
]
}}}}}}`))
default:
t.Fatalf("unexpected query: %s", request.Query)
}
}))
defer server.Close()
client := NewGitHubClient(server.URL, "token")
users, err := client.ListRepositoryUsers(context.Background(), "o", "r")
if err != nil {
t.Fatal(err)
}
if len(users) != 4 || users[0].Login != "alice" || !users[0].CanReview ||
users[0].CanAssign || users[1].Login != "bob" ||
!users[1].CanReview || !users[1].CanAssign ||
users[2].Login != "carol" || users[2].CanReview || !users[2].CanAssign ||
users[3].Login != "dave" || !users[3].CanReview {
t.Fatalf("repository users = %#v", users)
}
if users[0].RecentCommits != 2 || users[0].RecentAdditions != 50 ||
users[0].LastContributionAt.IsZero() || users[1].RecentCommits != 1 {
t.Fatalf("repository activity = %#v", users)
}
}
func TestAllAssigneesPaginates(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
if !strings.Contains(request.Query, "AssigneesPage") {
t.Fatalf("unexpected query: %s", request.Query)
}
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"assignees":{
"pageInfo":{"hasNextPage":false},
"nodes":[{"id":"u2","login":"bob","name":"Bob"}]
}}}}}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "token")
users, err := client.allAssignees(
context.Background(), "o", "r", 1,
githubUserConnection{
PageInfo: githubPageInfo{HasNextPage: true, EndCursor: "next"},
Nodes: []githubActor{{Login: "alice"}},
},
)
if err != nil {
t.Fatal(err)
}
if len(users) != 2 || users[0].Login != "alice" || users[1].Login != "bob" {
t.Fatalf("assignees = %#v", users)
}
}
func TestUpdatePullRequestPeoplePreservesUnchangedReviewers(t *testing.T) {
var requests []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var input struct {
Reviewers []string `json:"reviewers"`
Assignees []string `json:"assignees"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Fatal(err)
}
switch {
case strings.HasSuffix(r.URL.Path, "/requested_reviewers"):
requests = append(requests, r.Method+":"+strings.Join(input.Reviewers, ","))
case strings.HasSuffix(r.URL.Path, "/issues/7"):
requests = append(requests, r.Method+":"+strings.Join(input.Assignees, ","))
default:
t.Fatalf("unexpected REST path: %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL+"/api/graphql", "token")
people, err := client.UpdatePullRequestPeople(
context.Background(), "o", "r", 7,
PullRequestPeopleUpdate{
CurrentReviewers: []string{"keep", "remove"},
CurrentAssignees: []string{"alice"},
Reviewers: []string{"keep", "add"},
Assignees: []string{"alice", "bob"},
},
)
if err != nil {
t.Fatal(err)
}
want := []string{"POST:add", "DELETE:remove", "PATCH:alice,bob"}
if !slices.Equal(requests, want) {
t.Fatalf("REST requests = %v, want %v", requests, want)
}
if !slices.Equal(people.Reviewers, []string{"keep", "add"}) ||
!slices.Equal(people.Assignees, []string{"alice", "bob"}) {
t.Fatalf("updated people = %#v", people)
}
}
func TestUpdatePullRequestPeopleReportsSuccessfulPartialChanges(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete {
http.Error(w, "cannot remove", http.StatusUnprocessableEntity)
return
}
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL+"/api/graphql", "token")
people, err := client.UpdatePullRequestPeople(
context.Background(), "o", "r", 7,
PullRequestPeopleUpdate{
CurrentReviewers: []string{"keep", "remove"},
Reviewers: []string{"keep", "add"},
},
)
if err == nil || !strings.Contains(err.Error(), "remove reviewers") {
t.Fatalf("partial update error = %v", err)
}
if !equalLoginSets(people.Reviewers, []string{"keep", "remove", "add"}) {
t.Fatalf("partial reviewer state = %#v", people.Reviewers)
}
}

View File

@@ -354,6 +354,18 @@ func (k KeyBindings) canonicalPREditKey(key string, field int, confirming bool)
return "y" return "y"
case keyMatches(key, k.General.Reject), keyMatches(key, k.Input.Cancel): case keyMatches(key, k.General.Reject), keyMatches(key, k.Input.Cancel):
return "esc" return "esc"
case keyMatches(key, k.Navigation.Down):
return "down"
case keyMatches(key, k.Navigation.Up):
return "up"
case keyMatches(key, k.Navigation.PageDown):
return "ctrl+d"
case keyMatches(key, k.Navigation.PageUp):
return "ctrl+u"
case keyMatches(key, k.Navigation.First):
return "g"
case keyMatches(key, k.Navigation.Last):
return "G"
} }
} }
switch { switch {

View File

@@ -225,3 +225,7 @@ var _ GitHubWriteService = (*GitHubClient)(nil)
var _ GitHubWriteService = (*CachedGitHubService)(nil) var _ GitHubWriteService = (*CachedGitHubService)(nil)
var _ GitHubPullRequestWriteService = (*GitHubClient)(nil) var _ GitHubPullRequestWriteService = (*GitHubClient)(nil)
var _ GitHubPullRequestWriteService = (*CachedGitHubService)(nil) var _ GitHubPullRequestWriteService = (*CachedGitHubService)(nil)
var _ GitHubPullRequestPeopleWriteService = (*GitHubClient)(nil)
var _ GitHubPullRequestPeopleWriteService = (*CachedGitHubService)(nil)
var _ GitHubRepositoryPeopleService = (*GitHubClient)(nil)
var _ GitHubRepositoryPeopleService = (*CachedGitHubService)(nil)

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"slices"
"sort"
"strings" "strings"
"time" "time"
@@ -14,6 +16,8 @@ import (
const ( const (
prEditTitleField = iota prEditTitleField = iota
prEditBaseField prEditBaseField
prEditReviewersField
prEditAssigneesField
prEditBodyField prEditBodyField
prEditFieldCount prEditFieldCount
) )
@@ -27,6 +31,12 @@ func (m *App) startPREdit() tea.Cmd {
m.prEditField = prEditBodyField m.prEditField = prEditBodyField
m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, false) m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, false)
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, false) m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, false)
m.prEditEditors[prEditReviewersField] = newTextEditor(
strings.Join(m.details.RequestedReviewers, ", "), false,
)
m.prEditEditors[prEditAssigneesField] = newTextEditor(
strings.Join(m.details.Assignees, ", "), false,
)
m.prEditEditors[prEditBodyField] = newTextEditor( m.prEditEditors[prEditBodyField] = newTextEditor(
normalizeLineEndings(m.details.Body), normalizeLineEndings(m.details.Body),
m.editorMode == "vim", m.editorMode == "vim",
@@ -42,11 +52,15 @@ func (m *App) startPREdit() tea.Cmd {
m.prEditBranchesLoading = false m.prEditBranchesLoading = false
m.prEditBranchesError = "" m.prEditBranchesError = ""
m.prEditBranchIndex = 0 m.prEditBranchIndex = 0
m.prEditUsers = nil
m.prEditUsersLoading = false
m.prEditUsersError = ""
m.prEditUserIndex = 0
m.scroll = 0 m.scroll = 0
m.err = m.prEditEditors[m.prEditField].err m.err = m.prEditEditors[m.prEditField].err
m.prEditEditors[m.prEditField].err = nil m.prEditEditors[m.prEditField].err = nil
m.ensurePREditCursorVisible() m.ensurePREditCursorVisible()
return m.loadPREditBranches() return tea.Batch(m.loadPREditBranches(), m.loadPREditUsers())
} }
func (m *App) loadPREditBranches() tea.Cmd { func (m *App) loadPREditBranches() tea.Cmd {
@@ -65,6 +79,22 @@ func (m *App) loadPREditBranches() tea.Cmd {
} }
} }
func (m *App) loadPREditUsers() tea.Cmd {
service, ok := m.service.(GitHubRepositoryPeopleService)
if !ok {
m.prEditUsersError = "configured GitHub service cannot list repository users"
return nil
}
m.prEditUsersLoading = true
owner, repo := m.details.Owner, m.details.Repository
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
users, err := service.ListRepositoryUsers(ctx, owner, repo)
return repositoryUsersLoadedMsg{owner: owner, repo: repo, users: users, err: err}
}
}
func (m App) pullRequestUpdateUnavailable() string { func (m App) pullRequestUpdateUnavailable() string {
if m.loading { if m.loading {
return "pull request update unavailable while PR data is refreshing" return "pull request update unavailable while PR data is refreshing"
@@ -75,6 +105,9 @@ func (m App) pullRequestUpdateUnavailable() string {
if _, ok := m.service.(GitHubPullRequestWriteService); !ok { if _, ok := m.service.(GitHubPullRequestWriteService); !ok {
return "configured GitHub service does not support pull request updates" return "configured GitHub service does not support pull request updates"
} }
if _, ok := m.service.(GitHubPullRequestPeopleWriteService); !ok {
return "configured GitHub service does not support reviewer and assignee updates"
}
if m.details.ID == "" { if m.details.ID == "" {
return "pull request details are not loaded" return "pull request details are not loaded"
} }
@@ -118,6 +151,20 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case "n", "esc": case "n", "esc":
m.writeMode = writePREdit m.writeMode = writePREdit
m.ensurePREditCursorVisible() m.ensurePREditCursorVisible()
case "down":
m.helpScroll = min(m.helpScroll+1, m.prEditConfirmationMaxScroll())
case "up":
m.helpScroll = max(0, m.helpScroll-1)
case "ctrl+d":
m.helpScroll = min(
m.helpScroll+max(1, m.height/2), m.prEditConfirmationMaxScroll(),
)
case "ctrl+u":
m.helpScroll = max(0, m.helpScroll-max(1, m.height/2))
case "g":
m.helpScroll = 0
case "G":
m.helpScroll = m.prEditConfirmationMaxScroll()
} }
return m, nil return m, nil
} }
@@ -134,11 +181,16 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} else { } else {
m.writeMode = writePREditConfirm m.writeMode = writePREditConfirm
m.helpScroll = 0
m.err = nil m.err = nil
return m, nil return m, nil
} }
case "tab": case "tab":
if m.prEditField != prEditBaseField || !m.completeBranchSuggestion() { completed := m.prEditField == prEditBaseField && m.completeBranchSuggestion()
if isPREditPeopleField(m.prEditField) {
completed = m.completeUserSuggestion()
}
if !completed {
m.movePREditField(1) m.movePREditField(1)
} }
case "shift+tab": case "shift+tab":
@@ -146,10 +198,14 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case "ctrl+n": case "ctrl+n":
if m.prEditField == prEditBaseField { if m.prEditField == prEditBaseField {
m.moveBranchSuggestion(1) m.moveBranchSuggestion(1)
} else if isPREditPeopleField(m.prEditField) {
m.moveUserSuggestion(1)
} }
case "ctrl+p": case "ctrl+p":
if m.prEditField == prEditBaseField { if m.prEditField == prEditBaseField {
m.moveBranchSuggestion(-1) m.moveBranchSuggestion(-1)
} else if isPREditPeopleField(m.prEditField) {
m.moveUserSuggestion(-1)
} }
case "ctrl+d", "ctrl+u": case "ctrl+d", "ctrl+u":
if m.prEditField == prEditBodyField { if m.prEditField == prEditBodyField {
@@ -165,6 +221,9 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.prEditField == prEditBaseField && m.completeBranchSuggestion() { if m.prEditField == prEditBaseField && m.completeBranchSuggestion() {
break break
} }
if isPREditPeopleField(m.prEditField) && m.completeUserSuggestion() {
break
}
if m.prEditField != prEditBodyField { if m.prEditField != prEditBodyField {
m.movePREditField(1) m.movePREditField(1)
} else { } else {
@@ -204,6 +263,9 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.prEditField == prEditBaseField && editor.Text != before { if m.prEditField == prEditBaseField && editor.Text != before {
m.prEditBranchIndex = 0 m.prEditBranchIndex = 0
} }
if isPREditPeopleField(m.prEditField) && editor.Text != before {
m.prEditUserIndex = 0
}
} }
m.err = nil m.err = nil
m.ensurePREditCursorVisible() m.ensurePREditCursorVisible()
@@ -235,13 +297,44 @@ func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) {
func (m App) submitPREdit() tea.Cmd { func (m App) submitPREdit() tea.Cmd {
writer := m.service.(GitHubPullRequestWriteService) writer := m.service.(GitHubPullRequestWriteService)
peopleWriter := m.service.(GitHubPullRequestPeopleWriteService)
id := m.details.ID id := m.details.ID
update := m.prEditMetadata() update := m.prEditMetadata()
owner, repo, number := m.details.Owner, m.details.Repository, m.details.Number
peopleUpdate := PullRequestPeopleUpdate{
CurrentReviewers: slices.Clone(m.details.RequestedReviewers),
CurrentAssignees: slices.Clone(m.details.Assignees),
Reviewers: slices.Clone(update.Reviewers), Assignees: slices.Clone(update.Assignees),
}
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
metadata, err := writer.UpdatePullRequest(ctx, id, update) result := pullRequestUpdatedMsg{}
return pullRequestUpdatedMsg{metadata: metadata, err: err} peopleChanged := !slices.Equal(update.Reviewers, m.prEditOriginal.Reviewers) ||
!slices.Equal(update.Assignees, m.prEditOriginal.Assignees)
if peopleChanged {
result.people, result.err = peopleWriter.UpdatePullRequestPeople(
ctx, owner, repo, number, peopleUpdate,
)
if result.err != nil {
result.peopleSaved =
!equalLoginSets(result.people.Reviewers, peopleUpdate.CurrentReviewers) ||
!equalLoginSets(result.people.Assignees, peopleUpdate.CurrentAssignees)
return result
}
result.peopleSaved = true
}
if !samePRMetadataCore(update, m.prEditOriginal) {
result.metadata, result.err = writer.UpdatePullRequest(ctx, id, update)
if result.err != nil {
return result
}
} else {
result.metadata = m.prEditOriginal
}
result.metadata.Reviewers = slices.Clone(update.Reviewers)
result.metadata.Assignees = slices.Clone(update.Assignees)
return result
} }
} }
@@ -265,8 +358,11 @@ func (m App) validatePREdit() error {
return fmt.Errorf("target branch %q is not an available repository branch", update.BaseRef) return fmt.Errorf("target branch %q is not an available repository branch", update.BaseRef)
} }
} }
if err := m.validatePREditUsers(update); err != nil {
return err
}
if samePRMetadata(update, m.prEditOriginal) { if samePRMetadata(update, m.prEditOriginal) {
return errors.New("title, target branch, and description are unchanged") return errors.New("pull request fields are unchanged")
} }
return nil return nil
} }
@@ -278,6 +374,8 @@ func (m App) prEditIsStale() bool {
func (m App) currentPRMetadata() PullRequestMetadata { func (m App) currentPRMetadata() PullRequestMetadata {
return PullRequestMetadata{ return PullRequestMetadata{
Title: m.details.Title, Body: m.details.Body, BaseRef: m.details.BaseRef, Title: m.details.Title, Body: m.details.Body, BaseRef: m.details.BaseRef,
Reviewers: normalizedLogins(m.details.RequestedReviewers),
Assignees: normalizedLogins(m.details.Assignees),
Mergeable: m.details.Mergeable, MergeState: m.details.MergeState, Mergeable: m.details.Mergeable, MergeState: m.details.MergeState,
UpdatedAt: m.details.UpdatedAt, UpdatedAt: m.details.UpdatedAt,
} }
@@ -295,21 +393,65 @@ func (m App) prEditMetadata() PullRequestMetadata {
Title: strings.TrimSpace(m.prEditEditors[prEditTitleField].Text), Title: strings.TrimSpace(m.prEditEditors[prEditTitleField].Text),
Body: body, Body: body,
BaseRef: strings.TrimSpace(m.prEditEditors[prEditBaseField].Text), BaseRef: strings.TrimSpace(m.prEditEditors[prEditBaseField].Text),
Reviewers: parseLoginList(m.prEditEditors[prEditReviewersField].Text),
Assignees: parseLoginList(m.prEditEditors[prEditAssigneesField].Text),
} }
} }
func samePRMetadata(left, right PullRequestMetadata) bool { func samePRMetadata(left, right PullRequestMetadata) bool {
return samePRMetadataCore(left, right) &&
slices.Equal(left.Reviewers, right.Reviewers) &&
slices.Equal(left.Assignees, right.Assignees)
}
func samePRMetadataCore(left, right PullRequestMetadata) bool {
return left.Title == right.Title && left.Body == right.Body && left.BaseRef == right.BaseRef return left.Title == right.Title && left.Body == right.Body && left.BaseRef == right.BaseRef
} }
func (m *App) applyPREditPeople(people PullRequestPeople) {
oldRequested := make(map[string]bool, len(m.details.RequestedReviewers))
for _, login := range m.details.RequestedReviewers {
oldRequested[strings.ToLower(login)] = true
}
desired := make(map[string]bool, len(people.Reviewers))
for _, login := range people.Reviewers {
desired[strings.ToLower(login)] = true
}
filtered := m.details.Reviewers[:0]
known := make(map[string]bool)
for _, reviewer := range m.details.Reviewers {
key := strings.ToLower(reviewer.Login)
if oldRequested[key] && reviewer.State == "REVIEW_REQUESTED" && !desired[key] {
continue
}
filtered = append(filtered, reviewer)
known[key] = true
}
for _, login := range people.Reviewers {
if !known[strings.ToLower(login)] {
filtered = append(filtered, Reviewer{Login: login, State: "REVIEW_REQUESTED"})
}
}
sort.Slice(filtered, func(i, j int) bool {
return strings.ToLower(filtered[i].Login) < strings.ToLower(filtered[j].Login)
})
m.details.Reviewers = filtered
m.details.RequestedReviewers = slices.Clone(people.Reviewers)
m.details.Assignees = slices.Clone(people.Assignees)
}
func (m *App) clearPREdit() { func (m *App) clearPREdit() {
m.prEditField = 0 m.prEditField = 0
m.prEditEditors = [3]textEditor{} m.prEditEditors = [prEditFieldCount]textEditor{}
m.prEditOriginal = PullRequestMetadata{} m.prEditOriginal = PullRequestMetadata{}
m.prEditBranches = nil m.prEditBranches = nil
m.prEditBranchesLoading = false m.prEditBranchesLoading = false
m.prEditBranchesError = "" m.prEditBranchesError = ""
m.prEditBranchIndex = 0 m.prEditBranchIndex = 0
m.prEditUsers = nil
m.prEditUsersLoading = false
m.prEditUsersError = ""
m.prEditUserIndex = 0
} }
func (m *App) movePREditField(delta int) { func (m *App) movePREditField(delta int) {
@@ -387,6 +529,8 @@ func (m App) dashboardEditLayout() ([]string, int) {
} }
appendField("title", prEditTitleField) appendField("title", prEditTitleField)
appendField("target branch", prEditBaseField) appendField("target branch", prEditBaseField)
appendField("reviewers", prEditReviewersField)
appendField("assignees", prEditAssigneesField)
appendField("description", prEditBodyField) appendField("description", prEditBodyField)
return lines, cursorLine return lines, cursorLine
} }
@@ -422,6 +566,9 @@ func (m App) prEditFieldLines(label string, field, width int) []string {
if active && field == prEditBaseField { if active && field == prEditBaseField {
lines = append(lines, m.branchCompletionLines(max(1, width-2))...) lines = append(lines, m.branchCompletionLines(max(1, width-2))...)
} }
if active && isPREditPeopleField(field) {
lines = append(lines, m.userCompletionLines(max(1, width-2))...)
}
return lines return lines
} }
@@ -469,6 +616,20 @@ func (m App) prEditConfirmationLines(width int) []string {
len([]rune(m.prEditOriginal.Body)), len([]rune(update.Body)), len([]rune(m.prEditOriginal.Body)), len([]rune(update.Body)),
), "") ), "")
} }
if !slices.Equal(update.Reviewers, m.prEditOriginal.Reviewers) {
lines = append(lines,
dimStyle.Render("reviewers"),
loginChangeSummary(m.prEditOriginal.Reviewers, update.Reviewers),
"",
)
}
if !slices.Equal(update.Assignees, m.prEditOriginal.Assignees) {
lines = append(lines,
dimStyle.Render("assignees"),
loginChangeSummary(m.prEditOriginal.Assignees, update.Assignees),
"",
)
}
lines = append(lines, warnStyle.Render(fmt.Sprintf( lines = append(lines, warnStyle.Render(fmt.Sprintf(
"%s submit • %s continue editing", "%s submit • %s continue editing",
primaryKeyLabel(m.keybindings.General.Confirm), primaryKeyLabel(m.keybindings.General.Confirm),
@@ -476,3 +637,7 @@ func (m App) prEditConfirmationLines(width int) []string {
))) )))
return lines return lines
} }
func (m App) prEditConfirmationMaxScroll() int {
return max(0, len(m.prEditConfirmationLines(max(1, min(74, m.width-6))))-max(3, m.height-4))
}

50
tui.go
View File

@@ -66,6 +66,8 @@ type threadRepliedMsg struct {
type pullRequestUpdatedMsg struct { type pullRequestUpdatedMsg struct {
metadata PullRequestMetadata metadata PullRequestMetadata
people PullRequestPeople
peopleSaved bool
err error err error
} }
@@ -103,6 +105,12 @@ type branchesLoadedMsg struct {
err error err error
} }
type repositoryUsersLoadedMsg struct {
owner, repo string
users []RepositoryUser
err error
}
type detailsEnrichedMsg struct { type detailsEnrichedMsg struct {
enrichment PRDetailsEnrichment enrichment PRDetailsEnrichment
requestID uint64 requestID uint64
@@ -146,12 +154,16 @@ type App struct {
autoMergeTarget bool autoMergeTarget bool
mergeMethod string mergeMethod string
prEditField int prEditField int
prEditEditors [3]textEditor prEditEditors [prEditFieldCount]textEditor
prEditOriginal PullRequestMetadata prEditOriginal PullRequestMetadata
prEditBranches []RepositoryBranch prEditBranches []RepositoryBranch
prEditBranchesLoading bool prEditBranchesLoading bool
prEditBranchesError string prEditBranchesError string
prEditBranchIndex int prEditBranchIndex int
prEditUsers []RepositoryUser
prEditUsersLoading bool
prEditUsersError string
prEditUserIndex int
cursorOutput *terminalCursorOutput cursorOutput *terminalCursorOutput
foldResolved bool foldResolved bool
@@ -871,6 +883,22 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.prEditBranchesError = "" m.prEditBranchesError = ""
m.prEditBranchIndex = 0 m.prEditBranchIndex = 0
m.ensurePREditCursorVisible() m.ensurePREditCursorVisible()
case repositoryUsersLoadedMsg:
if m.writeMode != writePREdit ||
msg.owner != m.details.Owner || msg.repo != m.details.Repository {
return m, nil
}
m.prEditUsersLoading = false
if msg.err != nil {
m.prEditUsersError = msg.err.Error()
m.recordHealth("repository user recommendations", healthWarning, msg.err.Error())
m.ensurePREditCursorVisible()
return m, nil
}
m.prEditUsers = msg.users
m.prEditUsersError = ""
m.prEditUserIndex = 0
m.ensurePREditCursorVisible()
case threadResolvedMsg: case threadResolvedMsg:
m.writeMode = writeNone m.writeMode = writeNone
if msg.err != nil { if msg.err != nil {
@@ -960,6 +988,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case pullRequestUpdatedMsg: case pullRequestUpdatedMsg:
if msg.err != nil { if msg.err != nil {
m.writeMode = writePREdit m.writeMode = writePREdit
if msg.peopleSaved {
m.applyPREditPeople(msg.people)
m.prEditOriginal.Reviewers = slices.Clone(msg.people.Reviewers)
m.prEditOriginal.Assignees = slices.Clone(msg.people.Assignees)
}
m.err = fmt.Errorf("update pull request: %w", msg.err) m.err = fmt.Errorf("update pull request: %w", msg.err)
m.recordHealth("PR metadata update", healthError, msg.err.Error()) m.recordHealth("PR metadata update", healthError, msg.err.Error())
m.scroll = 0 m.scroll = 0
@@ -969,6 +1002,10 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.details.Title = msg.metadata.Title m.details.Title = msg.metadata.Title
m.details.Body = msg.metadata.Body m.details.Body = msg.metadata.Body
m.details.BaseRef = msg.metadata.BaseRef m.details.BaseRef = msg.metadata.BaseRef
m.applyPREditPeople(PullRequestPeople{
Reviewers: msg.metadata.Reviewers,
Assignees: msg.metadata.Assignees,
})
m.details.Mergeable = msg.metadata.Mergeable m.details.Mergeable = msg.metadata.Mergeable
m.details.MergeState = msg.metadata.MergeState m.details.MergeState = msg.metadata.MergeState
m.details.UpdatedAt = msg.metadata.UpdatedAt m.details.UpdatedAt = msg.metadata.UpdatedAt
@@ -1885,8 +1922,13 @@ func (m App) viewWritePopup() string {
} }
maxLines := max(3, m.height-4) maxLines := max(3, m.height-4)
if len(lines) > maxLines { if len(lines) > maxLines {
if m.writeMode == writePREditConfirm {
start := clamp(m.helpScroll, 0, len(lines)-maxLines)
lines = lines[start : start+maxLines]
} else {
lines = lines[len(lines)-maxLines:] lines = lines[len(lines)-maxLines:]
} }
}
for index := range lines { for index := range lines {
lines[index] = ansi.Truncate(lines[index], width, "") lines[index] = ansi.Truncate(lines[index], width, "")
} }
@@ -1916,8 +1958,8 @@ func (m App) helpBindings() []helpBinding {
{keyLabel(m.keybindings.Input.Submit), "Review pull request metadata changes"}, {keyLabel(m.keybindings.Input.Submit), "Review pull request metadata changes"},
{keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"}, {keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"},
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"},
{combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next target-branch completion"}, {combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next branch or user completion"},
{combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected target branch"}, {combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected branch or user"},
} }
if m.prEditEditors[prEditBodyField].Modal { if m.prEditEditors[prEditBodyField].Modal {
bindings = append(bindings, bindings = append(bindings,
@@ -2022,7 +2064,7 @@ func (m App) helpBindings() []helpBinding {
{keyLabel(m.keybindings.Navigation.Up), "Scroll description up"}, {keyLabel(m.keybindings.Navigation.Up), "Scroll description up"},
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"}, {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"},
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
{keyLabel(m.keybindings.Views.Edit), "Edit title, target branch, and description"}, {keyLabel(m.keybindings.Views.Edit), "Edit title, branch, reviewers, assignees, and description"},
{keyLabel(m.keybindings.Views.AutoMerge), "Enable or disable auto-merge"}, {keyLabel(m.keybindings.Views.AutoMerge), "Enable or disable auto-merge"},
{keyLabel(m.keybindings.Views.MergeNow), "Merge the pull request now when all requirements are met"}, {keyLabel(m.keybindings.Views.MergeNow), "Merge the pull request now when all requirements are met"},
{keyLabel(m.keybindings.Views.AI), "Open the local AI review menu"}, {keyLabel(m.keybindings.Views.AI), "Open the local AI review menu"},

View File

@@ -31,6 +31,10 @@ type recordingPRService struct {
updateErr error updateErr error
branches []RepositoryBranch branches []RepositoryBranch
branchErr error branchErr error
users []RepositoryUser
userErr error
people PullRequestPeopleUpdate
peopleErr error
} }
type recordingMergeService struct { type recordingMergeService struct {
@@ -77,6 +81,25 @@ func (s *recordingPRService) ListBranches(
return append([]RepositoryBranch(nil), s.branches...), s.branchErr return append([]RepositoryBranch(nil), s.branches...), s.branchErr
} }
func (s *recordingPRService) ListRepositoryUsers(
_ context.Context, _, _ string,
) ([]RepositoryUser, error) {
return append([]RepositoryUser(nil), s.users...), s.userErr
}
func (s *recordingPRService) UpdatePullRequestPeople(
_ context.Context, _, _ string, _ int, update PullRequestPeopleUpdate,
) (PullRequestPeople, error) {
s.people = update
if s.peopleErr != nil {
return PullRequestPeople{}, s.peopleErr
}
return PullRequestPeople{
Reviewers: append([]string(nil), update.Reviewers...),
Assignees: append([]string(nil), update.Assignees...),
}, nil
}
func (s *recordingService) SetThreadResolved( func (s *recordingService) SetThreadResolved(
_ context.Context, threadID string, resolved bool, _ context.Context, threadID string, resolved bool,
) (ReviewThread, error) { ) (ReviewThread, error) {
@@ -674,6 +697,8 @@ func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) {
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
send(tea.KeyMsg{Type: tea.KeyEsc}) send(tea.KeyMsg{Type: tea.KeyEsc})
send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyHome}) send(tea.KeyMsg{Type: tea.KeyHome})
for range len("main") { for range len("main") {
@@ -901,7 +926,7 @@ func TestDashboardPositionsHardwareCursorAtInsertBoundary(t *testing.T) {
m.startPREdit() m.startPREdit()
m.prEditEditors[prEditBodyField].Cursor = 4 m.prEditEditors[prEditBodyField].Cursor = 4
m.prEditEditors[prEditBodyField].Mode = textEditorInsert m.prEditEditors[prEditBodyField].Mode = textEditorInsert
m.positionPREditHardwareCursor(0, m.dashboardViewportHeight()) m.positionPREditHardwareCursor(m.scroll, m.dashboardViewportHeight())
m.cursorOutput.mu.Lock() m.cursorOutput.mu.Lock()
visible, column, row := m.cursorOutput.visible, m.cursorOutput.column, m.cursorOutput.row visible, column, row := m.cursorOutput.visible, m.cursorOutput.column, m.cursorOutput.row

View File

@@ -37,6 +37,7 @@ type PRDetails struct {
ConflictFileError string ConflictFileError string
Assignees []string Assignees []string
Reviewers []Reviewer Reviewers []Reviewer
RequestedReviewers []string
Labels []string Labels []string
Milestone string Milestone string
Additions int Additions int
@@ -83,6 +84,8 @@ type PullRequestMetadata struct {
Title string Title string
Body string Body string
BaseRef string BaseRef string
Reviewers []string
Assignees []string
Mergeable string Mergeable string
MergeState string MergeState string
UpdatedAt time.Time UpdatedAt time.Time
@@ -105,6 +108,29 @@ type RepositoryBranch struct {
IsDefault bool IsDefault bool
} }
type RepositoryUser struct {
ID string
Login string
Name string
CanReview bool
CanAssign bool
RecentCommits int
RecentAdditions int
LastContributionAt time.Time
}
type PullRequestPeopleUpdate struct {
CurrentReviewers []string
CurrentAssignees []string
Reviewers []string
Assignees []string
}
type PullRequestPeople struct {
Reviewers []string
Assignees []string
}
type Check struct { type Check struct {
ID string ID string
Name string Name string
@@ -169,6 +195,7 @@ type ReviewSummary struct {
type ViewerPermissions struct { type ViewerPermissions struct {
Repository string Repository string
CanUpdatePR bool CanUpdatePR bool
CanAssign bool
CanResolveAny bool CanResolveAny bool
CanUnresolveAny bool CanUnresolveAny bool
CanReplyAny bool CanReplyAny bool

360
user_completion.go Normal file
View 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
}

154
user_completion_test.go Normal file
View File

@@ -0,0 +1,154 @@
package main
import (
"slices"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)
func TestReviewerCompletionSupportsMultipleEligibleUsers(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.width = 80
m.details = PRDetails{
PullRequest: PullRequest{Author: "author"},
ViewerLogin: "current",
}
m.prEditField = prEditReviewersField
m.prEditEditors[prEditReviewersField] = newTextEditor("alice, bo", false)
m.prEditUsers = []RepositoryUser{
{Login: "alice", Name: "Alice", CanReview: true},
{Login: "bob", Name: "Bob", CanReview: true},
{Login: "author", Name: "Author", CanReview: true},
{Login: "carol", Name: "Carol", CanAssign: true},
}
suggestions := m.userSuggestions()
if len(suggestions) != 1 || suggestions[0].user.Login != "bob" {
t.Fatalf("reviewer suggestions = %#v", suggestions)
}
if !m.completeUserSuggestion() ||
m.prEditEditors[prEditReviewersField].Text != "alice, bob" {
t.Fatalf("completed reviewers = %q", m.prEditEditors[prEditReviewersField].Text)
}
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)
}
}
func TestReviewerSuggestionsPreferRecentRepositoryActivity(t *testing.T) {
now := time.Now()
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.details = PRDetails{PullRequest: PullRequest{Author: "author"}}
m.prEditField = prEditReviewersField
m.prEditEditors[prEditReviewersField] = newTextEditor("", false)
m.prEditUsers = []RepositoryUser{
{
Login: "old-contributor", CanReview: true, RecentCommits: 30,
LastContributionAt: now.AddDate(-2, 0, 0),
},
{
Login: "active-contributor", CanReview: true, RecentCommits: 3,
RecentAdditions: 50, LastContributionAt: now.Add(-24 * time.Hour),
},
{Login: "never-contributed", CanReview: true},
}
suggestions := m.userSuggestions()
if len(suggestions) != 3 || suggestions[0].user.Login != "active-contributor" {
t.Fatalf("activity-ranked reviewers = %#v", suggestions)
}
}
func TestPREditStartsWithCurrentRequestedReviewers(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{"alice", "bob"},
Assignees: []string{"carol"},
Permissions: ViewerPermissions{CanUpdatePR: true, CanAssign: true},
}
m.startPREdit()
if got := m.prEditEditors[prEditReviewersField].Text; got != "alice, bob" {
t.Fatalf("reviewer field = %q", got)
}
if got := m.prEditEditors[prEditAssigneesField].Text; got != "carol" {
t.Fatalf("assignee field = %q", got)
}
}
func TestPeopleOnlyPREditSkipsCoreMetadataMutation(t *testing.T) {
service := &recordingPRService{}
m := NewApp(service, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
m.details = PRDetails{
PullRequest: PullRequest{
ID: "pr", Owner: "o", Repository: "r", Number: 4,
Title: "Title", Author: "author",
},
BaseRef: "main", RequestedReviewers: []string{"alice"},
Permissions: ViewerPermissions{CanUpdatePR: true, CanAssign: true},
}
m.startPREdit()
m.prEditUsers = []RepositoryUser{
{Login: "alice", CanReview: true},
{Login: "bob", CanReview: true, CanAssign: true},
}
m.prEditUsersLoading = false
m.prEditEditors[prEditReviewersField] = newTextEditor("alice, bob", false)
m.prEditEditors[prEditAssigneesField] = newTextEditor("bob", false)
if err := m.validatePREdit(); err != nil {
t.Fatal(err)
}
m.writeMode = writePREditConfirm
updated, command := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
m = updated.(App)
if command == nil {
t.Fatal("people update did not create a command")
}
msg := command().(pullRequestUpdatedMsg)
if msg.err != nil {
t.Fatal(msg.err)
}
if service.updateID != "" {
t.Fatalf("people-only edit rewrote core metadata for %q", service.updateID)
}
if !slices.Equal(service.people.Reviewers, []string{"alice", "bob"}) ||
!slices.Equal(service.people.Assignees, []string{"bob"}) {
t.Fatalf("people update = %#v", service.people)
}
}
func TestApplyingUserReviewersPreservesTeamRequestsAndCompletedReviews(t *testing.T) {
m := App{details: PRDetails{
RequestedReviewers: []string{"old-user"},
Reviewers: []Reviewer{
{Login: "old-user", State: "REVIEW_REQUESTED"},
{Login: "backend-team", State: "REVIEW_REQUESTED"},
{Login: "approved-user", State: "APPROVED"},
},
}}
m.applyPREditPeople(PullRequestPeople{
Reviewers: []string{"new-user"},
Assignees: []string{"assignee"},
})
got := make(map[string]string)
for _, reviewer := range m.details.Reviewers {
got[reviewer.Login] = reviewer.State
}
if len(got) != 3 || got["backend-team"] != "REVIEW_REQUESTED" ||
got["approved-user"] != "APPROVED" ||
got["new-user"] != "REVIEW_REQUESTED" {
t.Fatalf("reviewers after update = %#v", m.details.Reviewers)
}
}