Skip to content

Commit 3bdd480

Browse files
authored
Add codeowners feature (#24910)
Hello. This PR adds a github like configuration for the CODEOWNERS file. Resolves: #10161
1 parent b5a2bb9 commit 3bdd480

File tree

6 files changed

+329
-0
lines changed

6 files changed

+329
-0
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
date: "2023-05-24T16:00:00+00:00"
3+
title: "Code Owners"
4+
slug: "code-owners"
5+
weight: 30
6+
toc: false
7+
draft: false
8+
aliases:
9+
- /en-us/code-owners
10+
menu:
11+
sidebar:
12+
parent: "usage"
13+
name: "Code Owners"
14+
weight: 30
15+
identifier: "code-owners"
16+
---
17+
18+
# Code Owners
19+
20+
Gitea maintains code owner files. It looks for it in the following locations in this order:
21+
22+
- `./CODEOWNERS`
23+
- `./docs/CODEOWNERS`
24+
- `./.gitea/CODEOWNERS`
25+
26+
And stops at the first found file.
27+
28+
File format: `<regexp rule> <@user or @org/team> [@user or @org/team]...`
29+
30+
Regexp specified in golang Regex format.
31+
Regexp can start with `!` for negative rules - match all files except specified.
32+
33+
Example file:
34+
35+
```
36+
.*\\.go @user1 @user2 # This is comment
37+
38+
# Comment too
39+
# You can assigning code owning for users or teams
40+
frontend/src/.*\\.js @org1/team1 @org1/team2 @user3
41+
42+
# You can use negative pattern
43+
!frontend/src/.* @org1/team3 @user5
44+
45+
# You can use power of go regexp
46+
docs/(aws|google|azure)/[^/]*\\.(md|txt) @user8 @org1/team4
47+
!/assets/.*\\.(bin|exe|msi) @user9
48+
```
49+
50+
### Escaping
51+
52+
You can escape characters `#`, ` ` (space) and `\` with `\`, like:
53+
54+
```
55+
dir/with\#hashtag @user1
56+
path\ with\ space @user2
57+
path/with\\backslash @user3
58+
```
59+
60+
Some character (`.+*?()|[]{}^$\`) should be escaped with `\\` inside regexp, like:
61+
62+
```
63+
path/\\.with\\.dots
64+
path/with\\+plus
65+
```

models/issues/pull.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import (
88
"context"
99
"fmt"
1010
"io"
11+
"regexp"
1112
"strconv"
1213
"strings"
1314

1415
"code.gitea.io/gitea/models/db"
1516
git_model "code.gitea.io/gitea/models/git"
17+
org_model "code.gitea.io/gitea/models/organization"
1618
pull_model "code.gitea.io/gitea/models/pull"
1719
repo_model "code.gitea.io/gitea/models/repo"
1820
user_model "code.gitea.io/gitea/models/user"
@@ -887,3 +889,222 @@ func MergeBlockedByOfficialReviewRequests(ctx context.Context, protectBranch *gi
887889
func MergeBlockedByOutdatedBranch(protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
888890
return protectBranch.BlockOnOutdatedBranch && pr.CommitsBehind > 0
889891
}
892+
893+
func PullRequestCodeOwnersReview(ctx context.Context, pull *Issue, pr *PullRequest) error {
894+
files := []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
895+
896+
if pr.IsWorkInProgress() {
897+
return nil
898+
}
899+
900+
if err := pr.LoadBaseRepo(ctx); err != nil {
901+
return err
902+
}
903+
904+
repo, err := git.OpenRepository(ctx, pr.BaseRepo.RepoPath())
905+
if err != nil {
906+
return err
907+
}
908+
defer repo.Close()
909+
910+
branch, err := repo.GetDefaultBranch()
911+
if err != nil {
912+
return err
913+
}
914+
915+
commit, err := repo.GetBranchCommit(branch)
916+
if err != nil {
917+
return err
918+
}
919+
920+
var data string
921+
for _, file := range files {
922+
if blob, err := commit.GetBlobByPath(file); err == nil {
923+
data, err = blob.GetBlobContent()
924+
if err == nil {
925+
break
926+
}
927+
}
928+
}
929+
930+
rules, _ := GetCodeOwnersFromContent(ctx, data)
931+
changedFiles, err := repo.GetFilesChangedBetween(git.BranchPrefix+pr.BaseBranch, pr.GetGitRefName())
932+
if err != nil {
933+
return err
934+
}
935+
936+
uniqUsers := make(map[int64]*user_model.User)
937+
uniqTeams := make(map[string]*org_model.Team)
938+
for _, rule := range rules {
939+
for _, f := range changedFiles {
940+
if (rule.Rule.MatchString(f) && !rule.Negative) || (!rule.Rule.MatchString(f) && rule.Negative) {
941+
for _, u := range rule.Users {
942+
uniqUsers[u.ID] = u
943+
}
944+
for _, t := range rule.Teams {
945+
uniqTeams[fmt.Sprintf("%d/%d", t.OrgID, t.ID)] = t
946+
}
947+
}
948+
}
949+
}
950+
951+
for _, u := range uniqUsers {
952+
if u.ID != pull.Poster.ID {
953+
if _, err := AddReviewRequest(pull, u, pull.Poster); err != nil {
954+
log.Warn("Failed add assignee user: %s to PR review: %s#%d, error: %s", u.Name, pr.BaseRepo.Name, pr.ID, err)
955+
return err
956+
}
957+
}
958+
}
959+
for _, t := range uniqTeams {
960+
if _, err := AddTeamReviewRequest(pull, t, pull.Poster); err != nil {
961+
log.Warn("Failed add assignee team: %s to PR review: %s#%d, error: %s", t.Name, pr.BaseRepo.Name, pr.ID, err)
962+
return err
963+
}
964+
}
965+
966+
return nil
967+
}
968+
969+
// GetCodeOwnersFromContent returns the code owners configuration
970+
// Return empty slice if files missing
971+
// Return warning messages on parsing errors
972+
// We're trying to do the best we can when parsing a file.
973+
// Invalid lines are skipped. Non-existent users and teams too.
974+
func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRule, []string) {
975+
if len(data) == 0 {
976+
return nil, nil
977+
}
978+
979+
rules := make([]*CodeOwnerRule, 0)
980+
lines := strings.Split(data, "\n")
981+
warnings := make([]string, 0)
982+
983+
for i, line := range lines {
984+
tokens := TokenizeCodeOwnersLine(line)
985+
if len(tokens) == 0 {
986+
continue
987+
} else if len(tokens) < 2 {
988+
warnings = append(warnings, fmt.Sprintf("Line: %d: incorrect format", i+1))
989+
continue
990+
}
991+
rule, wr := ParseCodeOwnersLine(ctx, tokens)
992+
for _, w := range wr {
993+
warnings = append(warnings, fmt.Sprintf("Line: %d: %s", i+1, w))
994+
}
995+
if rule == nil {
996+
continue
997+
}
998+
999+
rules = append(rules, rule)
1000+
}
1001+
1002+
return rules, warnings
1003+
}
1004+
1005+
type CodeOwnerRule struct {
1006+
Rule *regexp.Regexp
1007+
Negative bool
1008+
Users []*user_model.User
1009+
Teams []*org_model.Team
1010+
}
1011+
1012+
func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule, []string) {
1013+
var err error
1014+
rule := &CodeOwnerRule{
1015+
Users: make([]*user_model.User, 0),
1016+
Teams: make([]*org_model.Team, 0),
1017+
Negative: strings.HasPrefix(tokens[0], "!"),
1018+
}
1019+
1020+
warnings := make([]string, 0)
1021+
1022+
rule.Rule, err = regexp.Compile(fmt.Sprintf("^%s$", strings.TrimPrefix(tokens[0], "!")))
1023+
if err != nil {
1024+
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
1025+
return nil, warnings
1026+
}
1027+
1028+
for _, user := range tokens[1:] {
1029+
user = strings.TrimPrefix(user, "@")
1030+
1031+
// Only @org/team can contain slashes
1032+
if strings.Contains(user, "/") {
1033+
s := strings.Split(user, "/")
1034+
if len(s) != 2 {
1035+
warnings = append(warnings, fmt.Sprintf("incorrect codeowner group: %s", user))
1036+
continue
1037+
}
1038+
orgName := s[0]
1039+
teamName := s[1]
1040+
1041+
org, err := org_model.GetOrgByName(ctx, orgName)
1042+
if err != nil {
1043+
warnings = append(warnings, fmt.Sprintf("incorrect codeowner organization: %s", user))
1044+
continue
1045+
}
1046+
teams, err := org.LoadTeams()
1047+
if err != nil {
1048+
warnings = append(warnings, fmt.Sprintf("incorrect codeowner team: %s", user))
1049+
continue
1050+
}
1051+
1052+
for _, team := range teams {
1053+
if team.Name == teamName {
1054+
rule.Teams = append(rule.Teams, team)
1055+
}
1056+
}
1057+
} else {
1058+
u, err := user_model.GetUserByName(ctx, user)
1059+
if err != nil {
1060+
warnings = append(warnings, fmt.Sprintf("incorrect codeowner user: %s", user))
1061+
continue
1062+
}
1063+
rule.Users = append(rule.Users, u)
1064+
}
1065+
}
1066+
1067+
if (len(rule.Users) == 0) && (len(rule.Teams) == 0) {
1068+
warnings = append(warnings, "no users/groups matched")
1069+
return nil, warnings
1070+
}
1071+
1072+
return rule, warnings
1073+
}
1074+
1075+
func TokenizeCodeOwnersLine(line string) []string {
1076+
if len(line) == 0 {
1077+
return nil
1078+
}
1079+
1080+
line = strings.TrimSpace(line)
1081+
line = strings.ReplaceAll(line, "\t", " ")
1082+
1083+
tokens := make([]string, 0)
1084+
1085+
escape := false
1086+
token := ""
1087+
for _, char := range line {
1088+
if escape {
1089+
token += string(char)
1090+
escape = false
1091+
} else if string(char) == "\\" {
1092+
escape = true
1093+
} else if string(char) == "#" {
1094+
break
1095+
} else if string(char) == " " {
1096+
if len(token) > 0 {
1097+
tokens = append(tokens, token)
1098+
token = ""
1099+
}
1100+
} else {
1101+
token += string(char)
1102+
}
1103+
}
1104+
1105+
if len(token) > 0 {
1106+
tokens = append(tokens, token)
1107+
}
1108+
1109+
return tokens
1110+
}

models/issues/pull_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,3 +303,25 @@ func TestDeleteOrphanedObjects(t *testing.T) {
303303
assert.NoError(t, err)
304304
assert.EqualValues(t, countBefore, countAfter)
305305
}
306+
307+
func TestParseCodeOwnersLine(t *testing.T) {
308+
type CodeOwnerTest struct {
309+
Line string
310+
Tokens []string
311+
}
312+
313+
given := []CodeOwnerTest{
314+
{Line: "", Tokens: nil},
315+
{Line: "# comment", Tokens: []string{}},
316+
{Line: "!.* @user1 @org1/team1", Tokens: []string{"!.*", "@user1", "@org1/team1"}},
317+
{Line: `.*\\.js @user2 #comment`, Tokens: []string{`.*\.js`, "@user2"}},
318+
{Line: `docs/(aws|google|azure)/[^/]*\\.(md|txt) @user3 @org2/team2`, Tokens: []string{`docs/(aws|google|azure)/[^/]*\.(md|txt)`, "@user3", "@org2/team2"}},
319+
{Line: `\#path @user3`, Tokens: []string{`#path`, "@user3"}},
320+
{Line: `path\ with\ spaces/ @user3`, Tokens: []string{`path with spaces/`, "@user3"}},
321+
}
322+
323+
for _, g := range given {
324+
tokens := issues_model.TokenizeCodeOwnersLine(g.Line)
325+
assert.Equal(t, g.Tokens, tokens, "Codeowners tokenizer failed")
326+
}
327+
}

routers/web/repo/view.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
asymkey_model "code.gitea.io/gitea/models/asymkey"
2222
"code.gitea.io/gitea/models/db"
2323
git_model "code.gitea.io/gitea/models/git"
24+
issue_model "code.gitea.io/gitea/models/issues"
2425
repo_model "code.gitea.io/gitea/models/repo"
2526
unit_model "code.gitea.io/gitea/models/unit"
2627
user_model "code.gitea.io/gitea/models/user"
@@ -361,6 +362,13 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
361362
if workFlowErr != nil {
362363
ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error())
363364
}
365+
} else if util.SliceContains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) {
366+
if data, err := blob.GetBlobContent(); err == nil {
367+
_, warnings := issue_model.GetCodeOwnersFromContent(ctx, data)
368+
if len(warnings) > 0 {
369+
ctx.Data["FileWarning"] = strings.Join(warnings, "\n")
370+
}
371+
}
364372
}
365373

366374
isDisplayingSource := ctx.FormString("display") == "source"

services/issue/issue.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode
5757
return
5858
}
5959

60+
if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issues_model.HasWorkInProgressPrefix(title) {
61+
if err = issues_model.PullRequestCodeOwnersReview(ctx, issue, issue.PullRequest); err != nil {
62+
return
63+
}
64+
}
65+
6066
notification.NotifyIssueChangeTitle(ctx, doer, issue, oldTitle)
6167

6268
return nil

services/pull/pull.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, pull *issu
123123
}
124124

125125
_, _ = issue_service.CreateComment(ctx, ops)
126+
127+
if !pr.IsWorkInProgress() {
128+
if err := issues_model.PullRequestCodeOwnersReview(ctx, pull, pr); err != nil {
129+
return err
130+
}
131+
}
132+
126133
}
127134

128135
return nil

0 commit comments

Comments
 (0)