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 6 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
60 changes: 60 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,60 @@
---
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.

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

# 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
```
20 changes: 20 additions & 0 deletions modules/git/repo_show.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package git

import (
"fmt"
)

// GetFileContent returns file content for given revision.
func (repo *Repository) GetFileContent(rev, path string) ([]byte, error) {
cmd := NewCommand(repo.Ctx, "show")
cmd.AddDynamicArguments(fmt.Sprintf("%s:%s", rev, path))
stdout, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path})
if err != nil {
return nil, err
}

return stdout, nil
}
cl-bvl marked this conversation as resolved.
Show resolved Hide resolved
183 changes: 183 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,33 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, pull *issu
}

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

if coRules, err := GetCodeOwners(ctx, repo, pr.BaseBranch); err == nil {
cl-bvl marked this conversation as resolved.
Show resolved Hide resolved
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 +866,158 @@ 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, error) {
files := []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
techknowlogick marked this conversation as resolved.
Show resolved Hide resolved
gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath())
if err != nil {
return nil, err
}
defer closer.Close()

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

var data []byte
for _, file := range files {
data, err = gitRepo.GetFileContent(branch, file)
if err == nil {
break
}
}

if len(data) == 0 {
return nil, nil
}

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

for _, line := range lines {
tokens := tokenizeCodeOwnersLine(line)
if len(tokens) == 0 {
continue
} else if len(tokens) < 2 {
log.Info("Incorrect codeowner line: %s", line)
cl-bvl marked this conversation as resolved.
Show resolved Hide resolved
continue
}
rule := parseCodeOwnersLine(ctx, tokens)
if rule == nil {
continue
}

rules = append(rules, rule)
}

return rules, nil
}

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

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

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

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 {
log.Info("Incorrect codeowner group: %s", user)
continue
}
orgName := s[0]
teamName := s[1]

org, err := org_model.GetOrgByName(ctx, orgName)
if err != nil {
log.Info("Incorrect codeowner org name: %s", user)
}
teams, err := org.LoadTeams()
if err != nil {
log.Info("Incorrect codeowner team name: %s", user)
}

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 {
continue
}
rule.Users = append(rule.Users, u)
}
}

if len(rule.Users) == 0 {
return nil
}

return rule
}

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) == "/" {
// token += "\\/"
cl-bvl marked this conversation as resolved.
Show resolved Hide resolved
} 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
}