Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
3 changes: 2 additions & 1 deletion .github/workflows/codeowners.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ jobs:
run: |
sed -i "s/image: .*/image: 'Dockerfile'/" action.yml
cat action.yml

- name: 'Codeowners Plus'
uses: ./
with:
github-token: '${{ secrets.GITHUB_TOKEN }}'
pr: '${{ github.event.pull_request.number }}'
verbose: true
quiet: false
49 changes: 42 additions & 7 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-83.6%25-brightgreen)
![Coverage](https://img.shields.io/badge/Coverage-83.3%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 Down Expand Up @@ -70,9 +70,9 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: 'Codeowners Plus'
uses: multimediallc/[email protected].0
uses: multimediallc/[email protected].2
with:
github-token: '${{ secrets.GITHUB_TOKEN }}'
pr: '${{ github.event.pull_request.number }}'
Expand Down Expand Up @@ -121,7 +121,7 @@ By default, each file will resolve to a single reviewer. See [priority section]

To instead require an owner as an additional reviewer (`AND` rule), put an `&` at the start of the line:
```
# this rule will add `@task-auditor` as a required review in addition to the file owner's required review
# this rule will add `@task-auditor` as a required review in addition to the file owner's required review
& **/task.go @task-auditor
```

Expand Down Expand Up @@ -219,7 +219,7 @@ If you want to allow PR authors to bypass some reviews when there are a large nu

`codeowners.toml`
```toml
#
#
# `max_reviews` (default nil) allows you to skip some reviewers if the number of reviewers is greater than the max_reviewers
max_reviews = 2
```
Expand Down Expand Up @@ -254,13 +254,45 @@ high_priority_labels = ["high-priority", "urgent"]

When a PR has any of these labels, the comment will look like this:
```
❗High Prio❗
❗High Prio❗

Codeowners approval required for this PR:
- @user1
- @user2
```

### Quiet Mode
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't exactly in the right section - since this isn't a codeowners.toml config. But we don't have the right section atm. I think it would be good to have a "GHA Inputs" section where we talk about verbose and quiet

The README may be getting big enough that we should start thinking about collapsible sections or WIKI... but let's not worry about that in this PR


You can run Codeowners Plus in a "quiet" mode using the `quiet` input in the GitHub Action or the `-quiet=true` flag in the CLI.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can leave off the CLI flag detail - we only really talk about the use case in terms of the GHA. But add the flag to the CONTRIBUTING.md call


**When Quiet Mode is Enabled:**
Copy link
Collaborator

@BakerNet BakerNet Mar 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer subheaders over bold with :


* **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.

**Behavior:**

Even in quiet mode, the tool still performs all its internal calculations: determining required/optional owners based on file changes, checking existing approvals, and determining if the ownership rules are satisfied. The primary outcome is still the success or failure status of the associated status check (unless you've configured `enforcement.fail_check = false`).

**Use Cases:**

* **Draft Pull Requests:** This is a common use case. You might want the Codeowners Plus logic to run and report a status (e.g., pending or failed) on draft PRs, but without notifying reviewers prematurely by adding comments or requesting reviews until the PR is marked "Ready for review".
* **Custom Notification Workflows:** You might prefer to handle notifications or review requests through a different mechanism and only use Codeowners Plus for the status check enforcement.

**Activation:**

* **GitHub Action:** Set the `quiet` input to `'true'`.
```yaml
- name: 'Codeowners Plus (Quiet)'
uses: multimediallc/[email protected]
with:
# ... other inputs ...
quiet: 'true'
```
* **CLI:** Use the flag `-quiet=true`.

**Default:** Quiet mode is **disabled** (`false`) by default.

## CLI Tool

A CLI tool is available which provides some utilities for working with `.codeowners` files.
Expand All @@ -276,7 +308,10 @@ Available commands are:
* `owner` to check who owns a specific file
* `verify` to check for typos in a `.codeowners` file

## Contributing

See [CONTRIBUTING.md](https://github.com/multimediallc/codeowners-plus/blob/main/CONTRIBUTING.md)

## Future Features

* Inline ownership comments for having owners for specific functions, classes, etc.

153 changes: 103 additions & 50 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type AppConfig struct {
PR int
Repo string
Verbose bool
Quiet bool
}

// App represents the application with its dependencies
Expand All @@ -44,6 +45,7 @@ type Flags struct {
PR *int
Repo *string
Verbose *bool
Quiet *bool
}

var (
Expand All @@ -53,6 +55,7 @@ var (
PR: flag.Int("pr", ignoreError(strconv.Atoi(getEnv("INPUT_PR", ""))), "Pull Request number"),
Repo: flag.String("repo", getEnv("INPUT_REPOSITORY", ""), "GitHub repo name"),
Verbose: flag.Bool("v", ignoreError(strconv.ParseBool(getEnv("INPUT_VERBOSE", "0"))), "Verbose output"),
Quiet: flag.Bool("quiet", ignoreError(strconv.ParseBool(getEnv("INPUT_QUIET", "0"))), "Prevents addition of comments to PR and requesting reviews from unapproved owners"),
}
WarningBuffer = bytes.NewBuffer([]byte{})
InfoBuffer = bytes.NewBuffer([]byte{})
Expand Down Expand Up @@ -199,11 +202,12 @@ func (a *App) processApprovalsAndReviewers() (bool, string, error) {
}

// Request reviews from required owners
unapprovedOwners, err := a.requestReviews()
err = a.requestReviews()
if err != nil {
return false, message, err
}

unapprovedOwners := a.codeowners.AllRequired()
maxReviewsMet := false
if a.conf.MaxReviews != nil && *a.conf.MaxReviews > 0 {
if validApprovalCount >= *a.conf.MaxReviews && len(f.Intersection(unapprovedOwners.Flatten(), a.conf.UnskippableReviewers)) == 0 {
Expand All @@ -212,51 +216,13 @@ func (a *App) processApprovalsAndReviewers() (bool, string, error) {
}

// Add comments to the PR if necessary
if len(unapprovedOwners) > 0 {
// Comment on the PR with the codeowner teams that have not approved the PR
comment := allRequiredOwners.ToCommentString()
hasHighPriority, err := a.client.IsInLabels(a.conf.HighPriorityLabels)
if err != nil {
fmt.Fprintf(WarningBuffer, "WARNING: Error checking high priority labels: %v\n", err)
} else if hasHighPriority {
comment = "❗High Prio❗\n\n" + comment
}
if maxReviewsMet {
comment += "\n\n"
comment += "The PR has received the max number of required reviews. No further action is required."
}
fiveDaysAgo := time.Now().AddDate(0, 0, -5)
found, err := a.client.IsInComments(comment, &fiveDaysAgo)
if err != nil {
return false, message, fmt.Errorf("IsInComments Error: %v\n", err)
}
if !found {
err = a.client.AddComment(comment)
if err != nil {
return false, message, fmt.Errorf("AddComment Error: %v\n", err)
}
}
err = a.addReviewStatusComment(allRequiredOwners, unapprovedOwners, maxReviewsMet)
if err != nil {
return false, message, fmt.Errorf("failed to add review status comment: %w", err)
}
if len(allOptionalReviewerNames) > 0 {
var isInCommentsError error = nil
// Add CC comment to the PR with the optional reviewers that have not already been mentioned in the PR comments
viewersToPing := f.Filtered(allOptionalReviewerNames, func(name string) bool {
found, err := a.client.IsSubstringInComments(name, nil)
if err != nil {
isInCommentsError = err
}
return !found
})
if isInCommentsError != nil {
return false, message, fmt.Errorf("IsInComments Error: %v\n", err)
}
if len(viewersToPing) > 0 {
comment := fmt.Sprintf("cc %s", strings.Join(viewersToPing, " "))
err = a.client.AddComment(comment)
if err != nil {
return false, message, fmt.Errorf("AddComment Error: %v\n", err)
}
}
err = a.addOptionalCcComment(allOptionalReviewerNames)
if err != nil {
return false, message, fmt.Errorf("failed to add optional CC comment: %w", err)
}

// Exit if there are any unapproved codeowner teams
Expand Down Expand Up @@ -294,6 +260,87 @@ func (a *App) processApprovalsAndReviewers() (bool, string, error) {
return true, message, nil
}

func (a *App) addReviewStatusComment(allRequiredOwners, unapprovedOwners codeowners.ReviewerGroups, maxReviewsMet bool) error {
// Comment on the PR with the codeowner teams that have not approved the PR

if a.config.Quiet || len(unapprovedOwners) == 0 {
printDebug("Skipping review status comment (disabled or no unapproved owners).\n")
return nil
}

comment := allRequiredOwners.ToCommentString()
hasHighPriority, err := a.client.IsInLabels(a.conf.HighPriorityLabels)
if err != nil {
printWarning("WARNING: Error checking high priority labels: %v\n", err)
} else if hasHighPriority {
comment = "❗High Prio❗\n\n" + comment
}

if maxReviewsMet {
comment += "\n\nThe PR has received the max number of required reviews. No further action is required."
}

fiveDaysAgo := time.Now().AddDate(0, 0, -5)
found, err := a.client.IsInComments(comment, &fiveDaysAgo)
if err != nil {
return fmt.Errorf("IsInComments Error: %v\n", err)
}

// Add the comment if it wasn't found recently
if !found {
printDebug("Adding review status comment: %q\n", comment)
err = a.client.AddComment(comment)
if err != nil {
return fmt.Errorf("AddComment Error: %v\n", err)
}
} else {
printDebug("Similar review status comment already exists.\n")
}

return nil
}

func (a *App) addOptionalCcComment(allOptionalReviewerNames []string) error {
// Add CC comment to the PR with the optional reviewers that have not already been mentioned in the PR comments

if a.config.Quiet || len(allOptionalReviewerNames) == 0 {
printDebug("Skipping optional CC comment (disabled or no optional reviewers).\n")
return nil
}

var isInCommentsError error
viewersToPing := f.Filtered(allOptionalReviewerNames, func(name string) bool {
if isInCommentsError != nil {
return false
}
found, err := a.client.IsSubstringInComments(name, nil)
if err != nil {
printWarning("WARNING: Error checking comments for substring '%s': %v\n", name, err)
isInCommentsError = err
return false
}
return !found
})

if isInCommentsError != nil {
return fmt.Errorf("IsInComments Error: %v\n", isInCommentsError)
}

// Add the CC comment if there are any viewers to ping
if len(viewersToPing) > 0 {
comment := fmt.Sprintf("cc %s", strings.Join(viewersToPing, " "))
printDebug("Adding CC comment: %q\n", comment)
err := a.client.AddComment(comment)
if err != nil {
return fmt.Errorf("AddComment Error: %v\n", err)
}
} else {
printDebug("No new optional reviewers to CC.\n")
}

return nil
}

func (a *App) processTokenOwnerApproval() (*gh.CurrentApproval, error) {
tokenOwner, err := a.client.GetTokenUser()
if err != nil {
Expand Down Expand Up @@ -324,20 +371,25 @@ func (a *App) processApprovals(ghApprovals []*gh.CurrentApproval) (int, error) {
return len(ghApprovals) - len(approvalsToDismiss), nil
}

func (a *App) requestReviews() (codeowners.ReviewerGroups, error) {
func (a *App) requestReviews() error {
if a.config.Quiet {
printDebug("Skipping review requests (disabled in quiet mode).\n")
return nil
}

unapprovedOwners := a.codeowners.AllRequired()
unapprovedOwnerNames := unapprovedOwners.Flatten()
printDebug("Remaining Required Owners: %s\n", unapprovedOwnerNames)

currentlyRequestedOwners, err := a.client.GetCurrentlyRequested()
if err != nil {
return nil, fmt.Errorf("GetCurrentlyRequested Error: %v", err)
return fmt.Errorf("GetCurrentlyRequested Error: %v", err)
}
printDebug("Currently Requested Owners: %s\n", currentlyRequestedOwners)

previousReviewers, err := a.client.GetAlreadyReviewed()
if err != nil {
return nil, fmt.Errorf("GetAlreadyReviewed Error: %v", err)
return fmt.Errorf("GetAlreadyReviewed Error: %v", err)
}
printDebug("Already Reviewed Owners: %s\n", previousReviewers)

Expand All @@ -348,11 +400,11 @@ func (a *App) requestReviews() (codeowners.ReviewerGroups, error) {
if len(filteredOwners) > 0 {
printDebug("Requesting Reviews from: %s\n", filteredOwnerNames)
if err := a.client.RequestReviewers(filteredOwnerNames); err != nil {
return nil, fmt.Errorf("RequestReviewers Error: %v", err)
return fmt.Errorf("RequestReviewers Error: %v", err)
}
}

return unapprovedOwners, nil
return nil
}

func printFileOwners(codeOwners codeowners.CodeOwners) {
Expand Down Expand Up @@ -425,6 +477,7 @@ func main() {
PR: *flags.PR,
Repo: *flags.Repo,
Verbose: *flags.Verbose,
Quiet: *flags.Quiet,
}

app, err := NewApp(cfg)
Expand Down
Loading
Loading