Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
4 changes: 2 additions & 2 deletions .github/workflows/codeowners.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ jobs:
codeowners:
name: 'Run Codeowners Plus'
runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.draft }}
steps:
- name: 'Checkout Code Repository'
uses: actions/checkout@v4
Expand All @@ -24,10 +23,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: ${{ github.event.pull_request.draft }}
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Thank you for considering contributing to Codeowners Plus! We welcome contributi
> Running locally still requires real Github PRs to exist that you are testing against.

```bash
go run main.go -token <your_gh_token> -dir ../chaturbate -pr <pr_num> -repo multimediallc/chaturbate -v true
go run main.go -token <your_gh_token> -dir ../chaturbate -pr <pr_num> -repo multimediallc/chaturbate -v true -quiet=true
```

#### Running the CLI tool Locally
Expand Down
48 changes: 41 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Code Ownership &amp; 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,44 @@ 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

You can run Codeowners Plus in a "quiet" mode using the `quiet` input in the GitHub Action.

### When Quiet Mode is Enabled

* **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'
```

**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 +307,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