From 3bdd48016f659c440d6e8bb57386fab7ad7b357b Mon Sep 17 00:00:00 2001 From: Vladimir Buyanov <81759784+cl-bvl@users.noreply.github.com> Date: Thu, 8 Jun 2023 11:56:05 +0300 Subject: [PATCH] Add codeowners feature (#24910) Hello. This PR adds a github like configuration for the CODEOWNERS file. Resolves: #10161 --- docs/content/doc/usage/code-owners.en-us.md | 65 ++++++ models/issues/pull.go | 221 ++++++++++++++++++++ models/issues/pull_test.go | 22 ++ routers/web/repo/view.go | 8 + services/issue/issue.go | 6 + services/pull/pull.go | 7 + 6 files changed, 329 insertions(+) create mode 100644 docs/content/doc/usage/code-owners.en-us.md diff --git a/docs/content/doc/usage/code-owners.en-us.md b/docs/content/doc/usage/code-owners.en-us.md new file mode 100644 index 0000000000..94f81eeae1 --- /dev/null +++ b/docs/content/doc/usage/code-owners.en-us.md @@ -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: ` <@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 +``` diff --git a/models/issues/pull.go b/models/issues/pull.go index 3f37d8d243..63bc258d2f 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -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" @@ -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 +} diff --git a/models/issues/pull_test.go b/models/issues/pull_test.go index 7a183ac312..1eb106047c 100644 --- a/models/issues/pull_test.go +++ b/models/issues/pull_test.go @@ -303,3 +303,25 @@ 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") + } +} diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 2fd893f91c..1d54f25884 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -21,6 +21,7 @@ import ( 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" @@ -361,6 +362,13 @@ 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 := issue_model.GetCodeOwnersFromContent(ctx, data) + if len(warnings) > 0 { + ctx.Data["FileWarning"] = strings.Join(warnings, "\n") + } + } } isDisplayingSource := ctx.FormString("display") == "source" diff --git a/services/issue/issue.go b/services/issue/issue.go index ce2b1c0976..61890c75de 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -57,6 +57,12 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode return } + if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issues_model.HasWorkInProgressPrefix(title) { + if err = issues_model.PullRequestCodeOwnersReview(ctx, issue, issue.PullRequest); err != nil { + return + } + } + notification.NotifyIssueChangeTitle(ctx, doer, issue, oldTitle) return nil diff --git a/services/pull/pull.go b/services/pull/pull.go index 8f2befa8ff..f44e690ab7 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -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