From 1d90e364ee8ec8d330dd9b0083177cd803a370e4 Mon Sep 17 00:00:00 2001 From: pablu Date: Wed, 29 Jul 2026 09:49:51 +0200 Subject: [PATCH] add reviewer assigning --- README.md | 15 +- TODO.md | 7 +- ai_diff.go | 11 +- branch_completion_test.go | 7 +- cache.go | 40 +++++ drafts.go | 10 ++ github.go | 102 ++++++++++- github_people.go | 328 ++++++++++++++++++++++++++++++++++ github_people_test.go | 179 +++++++++++++++++++ keybindings.go | 12 ++ main.go | 4 + pr_editor.go | 183 ++++++++++++++++++- tui.go | 56 +++++- tui_test.go | 27 ++- types.go | 27 +++ user_completion.go | 360 ++++++++++++++++++++++++++++++++++++++ user_completion_test.go | 154 ++++++++++++++++ 17 files changed, 1475 insertions(+), 47 deletions(-) create mode 100644 github_people.go create mode 100644 github_people_test.go create mode 100644 user_completion.go create mode 100644 user_completion_test.go diff --git a/README.md b/README.md index f40fbb5..f7cb9a6 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ When GitHub reports that the authenticated user has permission, diple can: - reply to 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 - 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 does not silently discard work. -Reactions are currently read-only. Assigning reviewers, assignees, labels, or -milestones is not implemented yet. +Reactions are currently read-only. Assigning labels or milestones is not +implemented yet. ## Requirements @@ -197,7 +198,13 @@ reference. The PR description editor defaults to Vim-style modal editing, including Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard 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 diff --git a/TODO.md b/TODO.md index 61197a6..a5ec632 100644 --- a/TODO.md +++ b/TODO.md @@ -75,7 +75,7 @@ editing are already implemented. ## Data completeness and compatibility - 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. - Model pending reviews, minimized comments, deleted comments/users, edited timestamps, and explicit reply relationships. @@ -92,9 +92,8 @@ editing are already implemented. ## Write roadmap -- Add fuzzy multi-select editors for requested reviewers, assignees, labels, - and milestone. Support adding, removing, and clearing values with an explicit - before/after confirmation. +- Add fuzzy editors for labels and milestone with an explicit before/after + confirmation. - Add top-level PR conversation replies and editing/deleting the viewer's own comments. Fetch and enforce per-comment update/delete permissions. - Add reaction add/remove actions while retaining the current read-only counts. diff --git a/ai_diff.go b/ai_diff.go index c5a9528..e8c0d6c 100644 --- a/ai_diff.go +++ b/ai_diff.go @@ -14,16 +14,7 @@ import ( ) func (c *GitHubClient) PullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) { - base := strings.TrimSuffix(c.endpoint, "/") - 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) + + requestURL := c.restBaseURL() + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/pulls/" + strconv.Itoa(number) req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { diff --git a/branch_completion_test.go b/branch_completion_test.go index 6a2c1aa..9822ee2 100644 --- a/branch_completion_test.go +++ b/branch_completion_test.go @@ -56,11 +56,10 @@ func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) { }, BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, } - command := m.startPREdit() - if command == nil { + if command := m.startPREdit(); command == nil { t.Fatal("opening the editor did not request branches") } - updated, _ := m.Update(command()) + updated, _ := m.Update(m.loadPREditBranches()()) m = updated.(App) m.prEditField = prEditBaseField m.prEditEditors[prEditBaseField] = newTextEditor("release", false) @@ -79,7 +78,7 @@ func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) { updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab}) m = updated.(App) - if m.prEditField != prEditBodyField { + if m.prEditField != prEditReviewersField { t.Fatalf("second tab did not advance: field=%d", m.prEditField) } } diff --git a/cache.go b/cache.go index 6bc1ef5..e851293 100644 --- a/cache.go +++ b/cache.go @@ -164,6 +164,19 @@ func (c *CachedGitHubService) UpdatePullRequest( 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( ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool, ) (*AutoMergeRequest, error) { @@ -209,6 +222,29 @@ func (c *CachedGitHubService) ListBranches( 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( ctx context.Context, details PRDetails, ) PRDetailsEnrichment { @@ -238,6 +274,10 @@ func (c *CachedGitHubService) branchesKey(owner, repo string) string { 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 { sum := sha256.Sum256([]byte(key)) return filepath.Join(c.dir, hex.EncodeToString(sum[:])+".json") diff --git a/drafts.go b/drafts.go index 49aa634..9309b8f 100644 --- a/drafts.go +++ b/drafts.go @@ -22,6 +22,9 @@ type savedDraft struct { Reply string `json:"reply,omitempty"` Title string `json:"title,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"` OriginalUpdatedAt time.Time `json:"original_updated_at,omitempty"` SavedAt time.Time `json:"saved_at"` @@ -182,6 +185,10 @@ func (m *App) restorePREditDraft() { } m.prEditEditors[prEditTitleField] = newTextEditor(draft.Title, 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( 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, Number: m.details.Number, Title: m.prEditEditors[prEditTitleField].Text, BaseRef: m.prEditEditors[prEditBaseField].Text, + Reviewers: m.prEditEditors[prEditReviewersField].Text, + Assignees: m.prEditEditors[prEditAssigneesField].Text, + PeopleSet: true, Body: m.prEditEditors[prEditBodyField].Text, OriginalUpdatedAt: m.prEditOriginal.UpdatedAt, }) diff --git a/github.go b/github.go index b151564..0736a6d 100644 --- a/github.go +++ b/github.go @@ -30,6 +30,12 @@ type GitHubPullRequestWriteService interface { UpdatePullRequest(context.Context, string, PullRequestMetadata) (PullRequestMetadata, error) } +type GitHubPullRequestPeopleWriteService interface { + UpdatePullRequestPeople( + context.Context, string, string, int, PullRequestPeopleUpdate, + ) (PullRequestPeople, error) +} + type GitHubMergeService interface { SetPullRequestAutoMerge(context.Context, string, string, string, bool) (*AutoMergeRequest, error) MergePullRequest(context.Context, string, string, string) (PullRequestMergeResult, error) @@ -39,6 +45,10 @@ type GitHubBranchService interface { ListBranches(context.Context, string, string) ([]RepositoryBranch, error) } +type GitHubRepositoryPeopleService interface { + ListRepositoryUsers(context.Context, string, string) ([]RepositoryUser, error) +} + type GitHubEnrichmentService interface { EnrichPullRequest(context.Context, PRDetails) PRDetailsEnrichment } @@ -464,7 +474,10 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { } } author { login } - assignees(first: 20) { nodes { login } } + assignees(first: 100) { + pageInfo { hasNextPage endCursor } + nodes { id login name } + } labels(first: 20) { nodes { name } } milestone { title } 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 = ` query CheckContextsPage($id: ID!, $after: String) { node(id: $id) { @@ -722,6 +747,11 @@ type githubPRCommentConnection struct { Nodes []githubPRComment `json:"nodes"` } +type githubUserConnection struct { + PageInfo githubPageInfo `json:"pageInfo"` + Nodes []githubActor `json:"nodes"` +} + type githubReviewSummary struct { ID, Body, State, URL string SubmittedAt time.Time @@ -819,10 +849,8 @@ type githubPullRequestDetails struct { Position, EstimatedTimeToMerge int EnqueuedAt time.Time } - Assignees struct { - Nodes []githubActor `json:"nodes"` - } - Labels struct { + Assignees githubUserConnection + Labels struct { Nodes []struct { Name string `json:"name"` } `json:"nodes"` @@ -954,6 +982,36 @@ func (c *GitHubClient) allConversationComments( 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( ctx context.Context, owner, name string, number int, connection githubReviewSummaryConnection, ) ([]githubReviewSummary, error) { @@ -1112,22 +1170,30 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n node := data.Repository.PullRequest var ( threadNodes []githubReviewThread + assigneeNodes []githubActor conversationNodes []githubPRComment reviewNodes []githubReviewSummary timelineNodes []githubTimelineNode checkNodes []githubCheckContext threadErr error + assigneeErr error conversationErr error reviewErr error timelineErr error checkErr error wait sync.WaitGroup ) - wait.Add(4) + wait.Add(5) go func() { defer wait.Done() 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() { defer wait.Done() 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 { threadNodes = append([]githubReviewThread(nil), node.ReviewThreads.Nodes...) } + if assigneeErr != nil { + assigneeNodes = append([]githubActor(nil), node.Assignees.Nodes...) + } if conversationErr != nil { conversationNodes = append([]githubPRComment(nil), node.Comments.Nodes...) } @@ -1186,6 +1255,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n Permissions: ViewerPermissions{ Repository: data.Repository.ViewerPermission, CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact, + CanAssign: viewerCanAssign(data.Repository.ViewerPermission), CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge, 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{ "review threads": threadErr, "conversation": conversationErr, - "submitted reviews": reviewErr, "timeline": timelineErr, "checks": checkErr, + "submitted reviews": reviewErr, "assignees": assigneeErr, + "timeline": timelineErr, "checks": checkErr, } { if err != nil { 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 { details.Milestone = node.Milestone.Title } - for _, assignee := range node.Assignees.Nodes { + for _, assignee := range assigneeNodes { details.Assignees = append(details.Assignees, assignee.Login) } reviewers := map[string]string{} @@ -1269,6 +1340,11 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n if login != "" { reviewers[login] = "REVIEW_REQUESTED" } + if request.RequestedReviewer.Login != "" { + details.RequestedReviewers = append( + details.RequestedReviewers, request.RequestedReviewer.Login, + ) + } } for _, review := range node.LatestReviews.Nodes { 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}) } 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 { rollup := node.Commits.Nodes[0].Commit.StatusCheckRollup details.CheckState = rollup.State @@ -1646,6 +1723,15 @@ func actorLogin(actor *githubActor) string { 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 { if value == nil { return 0 diff --git a/github_people.go b/github_people.go new file mode 100644 index 0000000..edd634e --- /dev/null +++ b/github_people.go @@ -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") + } +} diff --git a/github_people_test.go b/github_people_test.go new file mode 100644 index 0000000..59c7ebc --- /dev/null +++ b/github_people_test.go @@ -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) + } +} diff --git a/keybindings.go b/keybindings.go index 6c13a48..84b740e 100644 --- a/keybindings.go +++ b/keybindings.go @@ -354,6 +354,18 @@ func (k KeyBindings) canonicalPREditKey(key string, field int, confirming bool) return "y" case keyMatches(key, k.General.Reject), keyMatches(key, k.Input.Cancel): 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 { diff --git a/main.go b/main.go index 7c557bb..d8804ac 100644 --- a/main.go +++ b/main.go @@ -225,3 +225,7 @@ var _ GitHubWriteService = (*GitHubClient)(nil) var _ GitHubWriteService = (*CachedGitHubService)(nil) var _ GitHubPullRequestWriteService = (*GitHubClient)(nil) var _ GitHubPullRequestWriteService = (*CachedGitHubService)(nil) +var _ GitHubPullRequestPeopleWriteService = (*GitHubClient)(nil) +var _ GitHubPullRequestPeopleWriteService = (*CachedGitHubService)(nil) +var _ GitHubRepositoryPeopleService = (*GitHubClient)(nil) +var _ GitHubRepositoryPeopleService = (*CachedGitHubService)(nil) diff --git a/pr_editor.go b/pr_editor.go index 0e987ce..4007e8d 100644 --- a/pr_editor.go +++ b/pr_editor.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "slices" + "sort" "strings" "time" @@ -14,6 +16,8 @@ import ( const ( prEditTitleField = iota prEditBaseField + prEditReviewersField + prEditAssigneesField prEditBodyField prEditFieldCount ) @@ -27,6 +31,12 @@ func (m *App) startPREdit() tea.Cmd { m.prEditField = prEditBodyField m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, 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( normalizeLineEndings(m.details.Body), m.editorMode == "vim", @@ -42,11 +52,15 @@ func (m *App) startPREdit() tea.Cmd { m.prEditBranchesLoading = false m.prEditBranchesError = "" m.prEditBranchIndex = 0 + m.prEditUsers = nil + m.prEditUsersLoading = false + m.prEditUsersError = "" + m.prEditUserIndex = 0 m.scroll = 0 m.err = m.prEditEditors[m.prEditField].err m.prEditEditors[m.prEditField].err = nil m.ensurePREditCursorVisible() - return m.loadPREditBranches() + return tea.Batch(m.loadPREditBranches(), m.loadPREditUsers()) } 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 { if m.loading { 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 { 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 == "" { 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": m.writeMode = writePREdit 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 } @@ -134,11 +181,16 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } else { m.writeMode = writePREditConfirm + m.helpScroll = 0 m.err = nil return m, nil } 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) } case "shift+tab": @@ -146,10 +198,14 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+n": if m.prEditField == prEditBaseField { m.moveBranchSuggestion(1) + } else if isPREditPeopleField(m.prEditField) { + m.moveUserSuggestion(1) } case "ctrl+p": if m.prEditField == prEditBaseField { m.moveBranchSuggestion(-1) + } else if isPREditPeopleField(m.prEditField) { + m.moveUserSuggestion(-1) } case "ctrl+d", "ctrl+u": 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() { break } + if isPREditPeopleField(m.prEditField) && m.completeUserSuggestion() { + break + } if m.prEditField != prEditBodyField { m.movePREditField(1) } else { @@ -204,6 +263,9 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { if m.prEditField == prEditBaseField && editor.Text != before { m.prEditBranchIndex = 0 } + if isPREditPeopleField(m.prEditField) && editor.Text != before { + m.prEditUserIndex = 0 + } } m.err = nil m.ensurePREditCursorVisible() @@ -235,13 +297,44 @@ func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) { func (m App) submitPREdit() tea.Cmd { writer := m.service.(GitHubPullRequestWriteService) + peopleWriter := m.service.(GitHubPullRequestPeopleWriteService) id := m.details.ID 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 { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - metadata, err := writer.UpdatePullRequest(ctx, id, update) - return pullRequestUpdatedMsg{metadata: metadata, err: err} + result := pullRequestUpdatedMsg{} + 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) } } + if err := m.validatePREditUsers(update); err != nil { + return err + } 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 } @@ -278,6 +374,8 @@ func (m App) prEditIsStale() bool { func (m App) currentPRMetadata() PullRequestMetadata { return PullRequestMetadata{ 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, UpdatedAt: m.details.UpdatedAt, } @@ -292,24 +390,68 @@ func (m App) prEditMetadata() PullRequestMetadata { body = m.prEditOriginal.Body } return PullRequestMetadata{ - Title: strings.TrimSpace(m.prEditEditors[prEditTitleField].Text), - Body: body, - BaseRef: strings.TrimSpace(m.prEditEditors[prEditBaseField].Text), + Title: strings.TrimSpace(m.prEditEditors[prEditTitleField].Text), + Body: body, + 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 { + 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 } +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() { m.prEditField = 0 - m.prEditEditors = [3]textEditor{} + m.prEditEditors = [prEditFieldCount]textEditor{} m.prEditOriginal = PullRequestMetadata{} m.prEditBranches = nil m.prEditBranchesLoading = false m.prEditBranchesError = "" m.prEditBranchIndex = 0 + m.prEditUsers = nil + m.prEditUsersLoading = false + m.prEditUsersError = "" + m.prEditUserIndex = 0 } func (m *App) movePREditField(delta int) { @@ -387,6 +529,8 @@ func (m App) dashboardEditLayout() ([]string, int) { } appendField("title", prEditTitleField) appendField("target branch", prEditBaseField) + appendField("reviewers", prEditReviewersField) + appendField("assignees", prEditAssigneesField) appendField("description", prEditBodyField) return lines, cursorLine } @@ -422,6 +566,9 @@ func (m App) prEditFieldLines(label string, field, width int) []string { if active && field == prEditBaseField { lines = append(lines, m.branchCompletionLines(max(1, width-2))...) } + if active && isPREditPeopleField(field) { + lines = append(lines, m.userCompletionLines(max(1, width-2))...) + } return lines } @@ -469,6 +616,20 @@ func (m App) prEditConfirmationLines(width int) []string { 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( "%s submit • %s continue editing", primaryKeyLabel(m.keybindings.General.Confirm), @@ -476,3 +637,7 @@ func (m App) prEditConfirmationLines(width int) []string { ))) 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)) +} diff --git a/tui.go b/tui.go index 6ce1b12..05bc512 100644 --- a/tui.go +++ b/tui.go @@ -65,8 +65,10 @@ type threadRepliedMsg struct { } type pullRequestUpdatedMsg struct { - metadata PullRequestMetadata - err error + metadata PullRequestMetadata + people PullRequestPeople + peopleSaved bool + err error } type autoMergeUpdatedMsg struct { @@ -103,6 +105,12 @@ type branchesLoadedMsg struct { err error } +type repositoryUsersLoadedMsg struct { + owner, repo string + users []RepositoryUser + err error +} + type detailsEnrichedMsg struct { enrichment PRDetailsEnrichment requestID uint64 @@ -146,12 +154,16 @@ type App struct { autoMergeTarget bool mergeMethod string prEditField int - prEditEditors [3]textEditor + prEditEditors [prEditFieldCount]textEditor prEditOriginal PullRequestMetadata prEditBranches []RepositoryBranch prEditBranchesLoading bool prEditBranchesError string prEditBranchIndex int + prEditUsers []RepositoryUser + prEditUsersLoading bool + prEditUsersError string + prEditUserIndex int cursorOutput *terminalCursorOutput foldResolved bool @@ -871,6 +883,22 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.prEditBranchesError = "" m.prEditBranchIndex = 0 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: m.writeMode = writeNone if msg.err != nil { @@ -960,6 +988,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case pullRequestUpdatedMsg: if msg.err != nil { 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.recordHealth("PR metadata update", healthError, msg.err.Error()) 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.Body = msg.metadata.Body 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.MergeState = msg.metadata.MergeState m.details.UpdatedAt = msg.metadata.UpdatedAt @@ -1885,7 +1922,12 @@ func (m App) viewWritePopup() string { } maxLines := max(3, m.height-4) if len(lines) > maxLines { - lines = lines[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:] + } } for index := range lines { 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.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.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next target-branch completion"}, - {combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected target branch"}, + {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 branch or user"}, } if m.prEditEditors[prEditBodyField].Modal { bindings = append(bindings, @@ -2022,7 +2064,7 @@ func (m App) helpBindings() []helpBinding { {keyLabel(m.keybindings.Navigation.Up), "Scroll description up"}, {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"}, {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.MergeNow), "Merge the pull request now when all requirements are met"}, {keyLabel(m.keybindings.Views.AI), "Open the local AI review menu"}, diff --git a/tui_test.go b/tui_test.go index edb6a25..9002979 100644 --- a/tui_test.go +++ b/tui_test.go @@ -31,6 +31,10 @@ type recordingPRService struct { updateErr error branches []RepositoryBranch branchErr error + users []RepositoryUser + userErr error + people PullRequestPeopleUpdate + peopleErr error } type recordingMergeService struct { @@ -77,6 +81,25 @@ func (s *recordingPRService) ListBranches( 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( _ context.Context, threadID string, resolved bool, ) (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.KeyEsc}) + send(tea.KeyMsg{Type: tea.KeyShiftTab}) + send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(tea.KeyMsg{Type: tea.KeyHome}) for range len("main") { @@ -901,7 +926,7 @@ func TestDashboardPositionsHardwareCursorAtInsertBoundary(t *testing.T) { m.startPREdit() m.prEditEditors[prEditBodyField].Cursor = 4 m.prEditEditors[prEditBodyField].Mode = textEditorInsert - m.positionPREditHardwareCursor(0, m.dashboardViewportHeight()) + m.positionPREditHardwareCursor(m.scroll, m.dashboardViewportHeight()) m.cursorOutput.mu.Lock() visible, column, row := m.cursorOutput.visible, m.cursorOutput.column, m.cursorOutput.row diff --git a/types.go b/types.go index 1e0831f..655eb34 100644 --- a/types.go +++ b/types.go @@ -37,6 +37,7 @@ type PRDetails struct { ConflictFileError string Assignees []string Reviewers []Reviewer + RequestedReviewers []string Labels []string Milestone string Additions int @@ -83,6 +84,8 @@ type PullRequestMetadata struct { Title string Body string BaseRef string + Reviewers []string + Assignees []string Mergeable string MergeState string UpdatedAt time.Time @@ -105,6 +108,29 @@ type RepositoryBranch struct { 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 { ID string Name string @@ -169,6 +195,7 @@ type ReviewSummary struct { type ViewerPermissions struct { Repository string CanUpdatePR bool + CanAssign bool CanResolveAny bool CanUnresolveAny bool CanReplyAny bool diff --git a/user_completion.go b/user_completion.go new file mode 100644 index 0000000..d206751 --- /dev/null +++ b/user_completion.go @@ -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 +} diff --git a/user_completion_test.go b/user_completion_test.go new file mode 100644 index 0000000..0d3b7f5 --- /dev/null +++ b/user_completion_test.go @@ -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) + } +}