Skip to content

Add codeowners feature #24910

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Jun 8, 2023
Merged
Show file tree
Hide file tree
Changes from 9 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
65 changes: 65 additions & 0 deletions docs/content/doc/usage/code-owners.en-us.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
date: "2023-05-24T16:00:00+00:00"
title: "Code Owners"
slug: "code-owners"
weight: 30
toc: false
draft: false
aliases:
- /en-us/code-owners
menu:
sidebar:
parent: "usage"
name: "Code Owners"
weight: 30
identifier: "code-owners"
---

# Code Owners

Gitea maintains code owner files. It looks for it in the following locations in this order:

- `./CODEOWNERS`
- `./docs/CODEOWNERS`
- `./.gitea/CODEOWNERS`

And stops at the first found file.

File format: `<regexp rule> <@user or @org/team> [@user or @org/team]...`

Regexp specified in golang Regex format.
Regexp can start with `!` for negative rules - match all files except specified.

Example file:

```
.*\\.go @user1 @user2 # This is comment

# Comment too
# You can assigning code owning for users or teams
frontend/src/.*\\.js @org1/team1 @org1/team2 @user3

# You can use negative pattern
!frontend/src/.* @org1/team3 @user5

# You can use power of go regexp
docs/(aws|google|azure)/[^/]*\\.(md|txt) @user8 @org1/team4
!/assets/.*\\.(bin|exe|msi) @user9
```

### Escaping

You can escape characters `#`, ` ` (space) and `\` with `\`, like:

```
dir/with\#hashtag @user1
path\ with\ space @user2
path/with\\backslash @user3
```

Some character (`.+*?()|[]{}^$\`) should be escaped with `\\` inside regexp, like:

```
path/\\.with\\.dots
path/with\\+plus
```
9 changes: 9 additions & 0 deletions routers/web/repo/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/feed"
issue_service "code.gitea.io/gitea/services/issue"
"code.gitea.io/gitea/services/pull"

"github.com/nektos/act/pkg/model"
)
Expand Down Expand Up @@ -361,6 +362,14 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
if workFlowErr != nil {
ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error())
}
} else if util.SliceContains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) {
if data, err := blob.GetBlobContent(); err == nil {
_, warnings := pull.GetCodeOwnersFromContent(ctx, data)
if len(warnings) > 0 {
ctx.Data["FileWarning"] = strings.Join(warnings, "\n")
}
}

}

isDisplayingSource := ctx.FormString("display") == "source"
Expand Down
17 changes: 9 additions & 8 deletions services/pull/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,14 +357,15 @@ func testPR(id int64) {
return
}

if err := TestPatch(pr); err != nil {
log.Error("testPatch[%-v]: %v", pr, err)
pr.Status = issues_model.PullRequestStatusError
if err := pr.UpdateCols("status"); err != nil {
log.Error("update pr [%-v] status to PullRequestStatusError failed: %v", pr, err)
}
return
}
//if err := TestPatch(pr); err != nil {
// log.Error("testPatch[%-v]: %v", pr, err)
// pr.Status = issues_model.PullRequestStatusError
// if err := pr.UpdateCols("status"); err != nil {
// log.Error("update pr [%-v] status to PullRequestStatusError failed: %v", pr, err)
// }
// return
//}
TestPatch(pr)
checkAndUpdateStatus(ctx, pr)
}

Expand Down
199 changes: 199 additions & 0 deletions services/pull/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
org_model "code.gitea.io/gitea/models/organization"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
Expand Down Expand Up @@ -123,6 +124,32 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, pull *issu
}

_, _ = issue_service.CreateComment(ctx, ops)

if coRules, _, err := GetCodeOwners(ctx, repo, repo.DefaultBranch); err == nil && len(coRules) > 0 {
changedFiles, err := baseGitRepo.GetFilesChangedBetween(git.BranchPrefix+pr.BaseBranch, pr.GetGitRefName())
if err != nil {
return err
}

uniqUsers := make(map[int64]*user_model.User)
for _, rule := range coRules {
for _, f := range changedFiles {
if (rule.Rule.MatchString(f) && !rule.Negative) || (!rule.Rule.MatchString(f) && rule.Negative) {
for _, u := range rule.Users {
uniqUsers[u.ID] = u
}
}
}
}

for _, u := range uniqUsers {
if u.ID != pull.Poster.ID {
if _, err := issues_model.AddReviewRequest(pull, u, pull.Poster); err != nil {
log.Warn("Failed add assignee user: %s to PR review: %s#%d", u.Name, pr.BaseRepo.Name, pr.ID)
}
}
}
}
}

return nil
Expand Down Expand Up @@ -838,3 +865,175 @@ func IsHeadEqualWithBranch(ctx context.Context, pr *issues_model.PullRequest, br
}
return baseCommit.HasPreviousCommit(headCommit.ID)
}

// GetCodeOwners returns the code owners configuration
// Return empty slice if files missing
// Return error on file system errors
// We're trying to do the best we can when parsing a file.
// Invalid lines are skipped. Non-existent users and groups too.
func GetCodeOwners(ctx context.Context, repo *repo_model.Repository, branch string) ([]*CodeOwnerRule, []string, error) {
files := []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath())
if err != nil {
return nil, nil, err
}
defer closer.Close()

if !gitRepo.IsBranchExist(branch) {
return nil, nil, &git.ErrBranchNotExist{Name: branch}
}

commit, err := gitRepo.GetBranchCommit(branch)

var data string
for _, file := range files {
if blob, err := commit.GetBlobByPath(file); err == nil {
data, err = blob.GetBlobContent()
if err == nil {
break
}
}
}

rules, warnings := GetCodeOwnersFromContent(ctx, data)
return rules, warnings, nil
}

func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRule, []string) {
if len(data) == 0 {
return nil, nil
}

rules := make([]*CodeOwnerRule, 0)
lines := strings.Split(data, "\n")
warnings := make([]string, 0)

for i, line := range lines {
tokens := tokenizeCodeOwnersLine(line)
if len(tokens) == 0 {
continue
} else if len(tokens) < 2 {
warnings = append(warnings, fmt.Sprintf("Line: %d: incorrect format", i+1))
continue
}
rule, wr := parseCodeOwnersLine(ctx, tokens)
for _, w := range wr {
warnings = append(warnings, fmt.Sprintf("Line: %d: %s", i+1, w))
}
if rule == nil {
continue
}

rules = append(rules, rule)
}

return rules, warnings
}

type CodeOwnerRule struct {
Rule *regexp.Regexp
Negative bool
Users []*user_model.User
}

func parseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule, []string) {
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO it needs some tests to cover the code, to make sure the logic won't be broken by future changes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added test for file parser

var err error
rule := &CodeOwnerRule{
Users: make([]*user_model.User, 0),
Negative: strings.HasPrefix(tokens[0], "!"),
}

warnings := make([]string, 0)

rule.Rule, err = regexp.Compile(fmt.Sprintf("^%s$", strings.TrimPrefix(tokens[0], "!")))
if err != nil {
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
return nil, warnings
}

for _, user := range tokens[1:] {
user = strings.TrimPrefix(user, "@")

// Only @org/team can contain slashes
if strings.Contains(user, "/") {
s := strings.Split(user, "/")
if len(s) != 2 {
warnings = append(warnings, fmt.Sprintf("incorrect codeowner group: %s", user))
continue
}
orgName := s[0]
teamName := s[1]

org, err := org_model.GetOrgByName(ctx, orgName)
if err != nil {
warnings = append(warnings, fmt.Sprintf("incorrect codeowner organization: %s", user))
continue
}
teams, err := org.LoadTeams()
if err != nil {
warnings = append(warnings, fmt.Sprintf("incorrect codeowner team: %s", user))
continue
}

for _, team := range teams {
if team.Name == teamName {
if err := team.LoadMembers(ctx); err != nil {
continue
}
rule.Users = append(rule.Users, team.Members...)
}
}
} else {
u, err := user_model.GetUserByName(ctx, user)
if err != nil {
warnings = append(warnings, fmt.Sprintf("incorrect codeowner user: %s", user))
continue
}
rule.Users = append(rule.Users, u)
}
}

if len(rule.Users) == 0 {
warnings = append(warnings, "no users matched")
return nil, warnings
}

return rule, warnings
}

func tokenizeCodeOwnersLine(line string) []string {
if len(line) == 0 {
return nil
}

line = strings.TrimSpace(line)
line = strings.ReplaceAll(line, "\t", " ")

tokens := make([]string, 0)

escape := false
token := ""
for _, char := range line {
if escape {
token += string(char)
escape = false
} else if string(char) == "\\" {
escape = true
} else if string(char) == "#" {
break
} else if string(char) == " " {
if len(token) > 0 {
tokens = append(tokens, token)
token = ""
}
} else {
token += string(char)
}
}

if len(token) > 0 {
tokens = append(tokens, token)
}

return tokens
}