Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better

[![Go Report Card](https://goreportcard.com/badge/github.com/multimediallc/codeowners-plus)](https://goreportcard.com/report/github.com/multimediallc/codeowners-plus?kill_cache=1)
[![Tests](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml/badge.svg)](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml)
![Coverage](https://img.shields.io/badge/Coverage-82.6%25-brightgreen)
![Coverage](https://img.shields.io/badge/Coverage-83.4%25-brightgreen)
[![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)

Expand All @@ -21,6 +21,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better
- [.codeowners File Spec](#codeowners-file-spec)
- [Advanced Configuration](#advanced-configuration)
- [Enforcement Options](#enforcement-options)
- [Review Request Removal](#review-request-removal)
- [Quiet Mode](#quiet-mode)
- [CLI Tool](#cli-tool)
- [Contributing](#contributing)
Expand Down Expand Up @@ -254,6 +255,11 @@ self_approval_via_teams = false
# Optional reviewers are still invited with a CC comment.
disable_review_status_comments = false

# `remove_stale_review_requests` (default false) removes the review requests Codeowners Plus made
# for owners the pull request no longer involves.
# Requires a token whose user can be resolved - see "Review Request Removal" below
remove_stale_review_requests = false

# `enforcement` allows you to specify how the Codeowners Plus check should be enforced
[enforcement]
# see "Enforcement Options" below for more details
Expand Down Expand Up @@ -360,12 +366,39 @@ Result with `require_both_branch_reviewers = true`:

**Note:** The `require_both_branch_reviewers` setting is read from the base branch's `codeowners.toml` for security. PR authors cannot enable this feature for their own PRs.

### Review Request Removal

Codeowners Plus requests reviews from the owners of the changed files every time it runs. When a
push changes which files the pull request touches, some of those owners stop being required - for
example when the author reverts the changes which pulled a team in. By default those review
requests stay on the pull request, so its requested reviewers can end up listing more teams than the
review status comment does.

Opt in to cleaning them up with `remove_stale_review_requests`:

`codeowners.toml`:
```toml
# `remove_stale_review_requests` (default false) removes the review requests Codeowners Plus made
# for owners the pull request no longer involves
remove_stale_review_requests = true
```

Only review requests made by the token owner are removed. Reviewers added by anybody else stay
requested, even when they do not own any of the changed files, so manually added reviewers survive a
push. Owners which are still required, and owners which are optional reviewers of the changed
files, are never removed.

Telling the two apart requires resolving the token's user, so this needs a token which can read its
own user - a PAT (the same requirement as [GitHub Teams Support](#github-teams-support)). With a
token whose user cannot be resolved, such as `GITHUB_TOKEN`, removal is skipped with a warning.
Removal is also skipped in [Quiet Mode](#quiet-mode).

### Quiet Mode

Using the `quiet` input on the action will change the behavior in a couple ways:

* **No Comments:** The action will **not** post the review status comment (listing required/unapproved reviewers) or the optional reviewer "cc" comment to the Pull Request.
* **No Review Requests:** The action will **not** automatically request reviews from required owners who have not yet approved via the GitHub API.
* **No Review Requests:** The action will **not** automatically request reviews from required owners who have not yet approved via the GitHub API, and will **not** remove its own stale review requests even when `remove_stale_review_requests` is enabled.

#### Use Cases

Expand Down
57 changes: 57 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,12 @@ func (a *App) processApprovalsAndReviewers() (bool, string, []string, error) {
return false, message, nil, err
}

// Drop review requests for owners the PR no longer needs
err = a.removeStaleReviewRequests(slices.Concat(allRequiredOwnerNames, allOptionalReviewerNames))
if err != nil {
return false, message, nil, err
}

// Request reviews from required owners
err = a.requestReviews()
if err != nil {
Expand Down Expand Up @@ -529,6 +535,57 @@ func (a *App) processApprovals(ghApprovals []*gh.CurrentApproval) (int, error) {
return len(ghApprovals) - len(approvalsToDismiss), nil
}

// removeStaleReviewRequests drops the review requests this action made for
// owners the PR no longer involves - for example when the author reverts the
// changes which pulled a team in. currentOwners is every owner of the current
// diff (required and optional), and only review requests made by the token user
// are removed, so reviewers somebody added by hand are left alone.
//
// This is opt-in via the remove_stale_review_requests config setting.
func (a *App) removeStaleReviewRequests(currentOwners []codeowners.Slug) error {
if a.config.Quiet {
return nil
}
if !a.Conf.RemoveStaleReviewRequests {
a.printDebug("Skipping stale review request removal (not enabled in config).\n")
return nil
}

requestedReviewers, err := a.client.GetRequestedReviewers()
if err != nil {
return fmt.Errorf("GetRequestedReviewers Error: %v", err)
}
staleCandidates := codeowners.FilterOutNames(requestedReviewers, currentOwners)
if len(staleCandidates) == 0 {
return nil
}
a.printDebug("Requested Reviewers no longer owning changed files: %s\n", codeowners.OriginalStrings(staleCandidates))

// Only remove the requests we made ourselves - anything a human requested
// stays, even when they do not own any of the changed files.
selfRequested, err := a.client.GetSelfRequestedReviewers()
if err != nil {
a.printWarn("WARNING: Error finding review requests to remove: %v\n", err)
return nil
}
staleReviewers := f.Filtered(staleCandidates, func(reviewer codeowners.Slug) bool {
return codeowners.ContainsSlug(selfRequested, reviewer)
})

if len(staleReviewers) == 0 {
a.printDebug("No stale review requests to remove.\n")
return nil
}

a.printDebug("Removing Review Requests from: %s\n", codeowners.OriginalStrings(staleReviewers))
if err := a.client.RemoveReviewers(codeowners.OriginalStrings(staleReviewers)); err != nil {
// Removal is cosmetic - a transient API error should not fail the check
a.printWarn("WARNING: Error removing stale review requests: %v\n", err)
}

return nil
}

func (a *App) requestReviews() error {
if a.config.Quiet {
return nil
Expand Down
Loading
Loading