Skip to content
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

Add codeowners feature #24910

Merged
merged 21 commits into from
Jun 8, 2023
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
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:
cl-bvl marked this conversation as resolved.
Show resolved Hide resolved

```
.*\\.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
```
221 changes: 221 additions & 0 deletions models/issues/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import (
"context"
"fmt"
"io"
"regexp"
"strconv"
"strings"

"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
org_model "code.gitea.io/gitea/models/organization"
pull_model "code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
Expand Down Expand Up @@ -887,3 +889,222 @@ func MergeBlockedByOfficialReviewRequests(ctx context.Context, protectBranch *gi
func MergeBlockedByOutdatedBranch(protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
return protectBranch.BlockOnOutdatedBranch && pr.CommitsBehind > 0
}

func PullRequestCodeOwnersReview(ctx context.Context, pull *Issue, pr *PullRequest) error {
files := []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}

if pr.IsWorkInProgress() {
return nil
}

if err := pr.LoadBaseRepo(ctx); err != nil {
return err
}

repo, err := git.OpenRepository(ctx, pr.BaseRepo.RepoPath())
if err != nil {
return err
}
defer repo.Close()

branch, err := repo.GetDefaultBranch()
if err != nil {
return err
}

commit, err := repo.GetBranchCommit(branch)
if err != nil {
return err
}

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

rules, _ := GetCodeOwnersFromContent(ctx, data)
changedFiles, err := repo.GetFilesChangedBetween(git.BranchPrefix+pr.BaseBranch, pr.GetGitRefName())
if err != nil {
return err
}

uniqUsers := make(map[int64]*user_model.User)
uniqTeams := make(map[string]*org_model.Team)
for _, rule := range rules {
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 _, t := range rule.Teams {
uniqTeams[fmt.Sprintf("%d/%d", t.OrgID, t.ID)] = t
}
}
}
}

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

return nil
}

// GetCodeOwnersFromContent returns the code owners configuration
// Return empty slice if files missing
// Return warning messages on parsing errors
// We're trying to do the best we can when parsing a file.
// Invalid lines are skipped. Non-existent users and teams too.
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
Teams []*org_model.Team
}

func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule, []string) {
var err error
rule := &CodeOwnerRule{
Users: make([]*user_model.User, 0),
Teams: make([]*org_model.Team, 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 {
rule.Teams = append(rule.Teams, team)
}
}
} 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) && (len(rule.Teams) == 0) {
warnings = append(warnings, "no users/groups 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
}
23 changes: 23 additions & 0 deletions models/issues/pull_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,26 @@ func TestDeleteOrphanedObjects(t *testing.T) {
assert.NoError(t, err)
assert.EqualValues(t, countBefore, countAfter)
}

func TestParseCodeOwnersLine(t *testing.T) {
type CodeOwnerTest struct {
Line string
Tokens []string
}

given := []CodeOwnerTest{
{Line: "", Tokens: nil},
{Line: "# comment", Tokens: []string{}},
{Line: "!.* @user1 @org1/team1", Tokens: []string{"!.*", "@user1", "@org1/team1"}},
{Line: `.*\\.js @user2 #comment`, Tokens: []string{`.*\.js`, "@user2"}},
{Line: `docs/(aws|google|azure)/[^/]*\\.(md|txt) @user3 @org2/team2`, Tokens: []string{`docs/(aws|google|azure)/[^/]*\.(md|txt)`, "@user3", "@org2/team2"}},
{Line: `\#path @user3`, Tokens: []string{`#path`, "@user3"}},
{Line: `path\ with\ spaces/ @user3`, Tokens: []string{`path with spaces/`, "@user3"}},
}

for _, g := range given {
tokens := issues_model.TokenizeCodeOwnersLine(g.Line)
assert.Equal(t, g.Tokens, tokens, "Codeowners tokenizer failed")
}

}
13 changes: 11 additions & 2 deletions routers/web/repo/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ import (
"strings"
"time"

"github.com/nektos/act/pkg/model"

activities_model "code.gitea.io/gitea/models/activities"
admin_model "code.gitea.io/gitea/models/admin"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issue_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
Expand All @@ -41,8 +44,6 @@ import (
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/feed"
issue_service "code.gitea.io/gitea/services/issue"

"github.com/nektos/act/pkg/model"
)

const (
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) {
cl-bvl marked this conversation as resolved.
Show resolved Hide resolved
if data, err := blob.GetBlobContent(); err == nil {
_, warnings := issue_model.GetCodeOwnersFromContent(ctx, data)
if len(warnings) > 0 {
ctx.Data["FileWarning"] = strings.Join(warnings, "\n")
}
}

}

isDisplayingSource := ctx.FormString("display") == "source"
Expand Down
4 changes: 4 additions & 0 deletions services/issue/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode
return
}

if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issues_model.HasWorkInProgressPrefix(title) {
issues_model.PullRequestCodeOwnersReview(ctx, issue, issue.PullRequest)
}

notification.NotifyIssueChangeTitle(ctx, doer, issue, oldTitle)

return nil
Expand Down
7 changes: 7 additions & 0 deletions services/pull/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, pull *issu
}

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

if !pr.IsWorkInProgress() {
if err := issues_model.PullRequestCodeOwnersReview(ctx, pull, pr); err != nil {
return err
}
}

}

return nil
Expand Down