mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-09 17:37:20 -06:00
enhance(codeowners): support section / glob / file view display / skip review request (#40243)
Document: https://gitea.com/gitea/docs/pulls/634 ([doc PR
preview](955ae7d75c/docs/usage/repository/code-owners.md))
1. If the pattern starts with `*` or contains `/*`, use our glob package
to parse it as glob to regexp
* Make it easier to use. Otherwise it's really ugly to keep writing a
lot of backslash escapes
2. Add GitLab-style "section" support
* Fix #28926
```codeowners
[Section] @user
*.js
```
3. Add our annotation support, at the moment only need to support
review-request=true/false
* Fix #40241
```codeowners
## review-request=false # this is global
*.js $user ## review-request=true # this is line-level
[Section] @user ## review-request=true # this is section-level
*.md
```
4. Add GitHub/GitLab-style "show codeowner when viewing a file"
* Fix #28137
This commit is contained in:
256
models/issues/code_owner.go
Normal file
256
models/issues/code_owner.go
Normal file
@@ -0,0 +1,256 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issues
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
org_model "gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/optional"
|
||||
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
)
|
||||
|
||||
// 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) (rules []*CodeOwnerRule, warnings []string) {
|
||||
globalReview := optional.None[bool]()
|
||||
sectionReview := optional.None[bool]()
|
||||
section := ""
|
||||
var owners []string
|
||||
|
||||
lineNum := 0
|
||||
for line := range strings.SplitSeq(data, "\n") {
|
||||
lineNum++
|
||||
line, reviewRequest, annotationWarnings := parseCodeOwnerAnnotation(line)
|
||||
for _, warning := range annotationWarnings {
|
||||
warnings = append(warnings, fmt.Sprintf("Line: %d: %s", lineNum, warning))
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
if reviewRequest.Has() {
|
||||
globalReview = reviewRequest
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// To support section headers, there could be an edge case for end users:
|
||||
// `[a-z] @user` now becomes a section header instead of a rule, but it is really rare to happen.
|
||||
// A workaround is to use `/[a-z] @user` to make it a rule.
|
||||
if heading := line; strings.HasPrefix(heading, "[") {
|
||||
if end := strings.IndexByte(heading, ']'); end > 1 && (end == len(heading)-1 || heading[end+1] == ' ' || heading[end+1] == '\t') {
|
||||
section = heading[1:end]
|
||||
owners = TokenizeCodeOwnersLine(heading[end+1:])
|
||||
sectionReview = reviewRequest
|
||||
continue
|
||||
}
|
||||
}
|
||||
tokens := TokenizeCodeOwnersLine(line)
|
||||
if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(tokens) == 1 && len(owners) > 0 {
|
||||
tokens = append(tokens, owners...)
|
||||
}
|
||||
if len(tokens) < 2 {
|
||||
warnings = append(warnings, fmt.Sprintf("Line: %d: incorrect format", lineNum))
|
||||
continue
|
||||
}
|
||||
rule, wr := ParseCodeOwnersLine(ctx, tokens)
|
||||
for _, w := range wr {
|
||||
warnings = append(warnings, fmt.Sprintf("Line: %d: %s", lineNum, w))
|
||||
}
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
rule.LineNum = lineNum
|
||||
|
||||
rule.Section = section
|
||||
rule.ReviewRequest = reviewRequest
|
||||
if !rule.ReviewRequest.Has() {
|
||||
rule.ReviewRequest = sectionReview
|
||||
}
|
||||
if !rule.ReviewRequest.Has() {
|
||||
rule.ReviewRequest = globalReview
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
return rules, warnings
|
||||
}
|
||||
|
||||
// codeOwnerMatchTimeout bounds a single pattern match so a crafted pattern
|
||||
// cannot stall via catastrophic backtracking. See also the aggregate budget
|
||||
// enforced by the caller across the whole rules×files match loop.
|
||||
const codeOwnerMatchTimeout = 150 * time.Millisecond
|
||||
|
||||
type CodeOwnerRule struct {
|
||||
LineNum int
|
||||
Section string
|
||||
ReviewRequest optional.Option[bool]
|
||||
|
||||
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
||||
Negative bool
|
||||
|
||||
// TODO: these objects should not be loaded here, but rather lazily when needed.
|
||||
// The parsed rule should only contain the user/team names.
|
||||
Users []*user_model.User
|
||||
Teams []*org_model.Team
|
||||
}
|
||||
|
||||
func ParseCodeOwnersLine(ctx context.Context, tokens []string) (rule *CodeOwnerRule, warnings []string) {
|
||||
rule = &CodeOwnerRule{Negative: strings.HasPrefix(tokens[0], "!")}
|
||||
|
||||
// Strip leading "!" for negative rules, then strip leading "/" since
|
||||
// git returns relative paths (e.g. "docs/foo.md" not "/docs/foo.md")
|
||||
// and the regex is already anchored with ^...$, so the "/" is redundant.
|
||||
pattern := strings.TrimPrefix(tokens[0], "!")
|
||||
pattern = strings.TrimPrefix(pattern, "/")
|
||||
expr := fmt.Sprintf("^%s$", pattern)
|
||||
|
||||
// To make life easier, we support glob expressions if the pattern contains a "*" or "/*" (e.g. "*.md" or "docs/*.md")
|
||||
// However, "src/test\\..*\\.js" is still the only way because "src/test.*.js" can't be used as a glob at the moment.
|
||||
// TODO: In the future, maybe it's better to introduce a new annotation to specify the pattern type, e.g.: "pattern-syntax=glob" or "pattern-syntax=regexp"
|
||||
if strings.HasPrefix(pattern, "*") || strings.Contains(pattern, "/*") {
|
||||
compiled, compileErr := glob.CompileToRegexp(pattern)
|
||||
if compileErr != nil {
|
||||
return nil, []string{fmt.Sprintf("incorrect codeowner glob: %s", compileErr)}
|
||||
}
|
||||
expr = compiled.String()
|
||||
}
|
||||
|
||||
var err error
|
||||
rule.Rule, err = regexp2.Compile(expr, regexp2.None)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
||||
return nil, warnings
|
||||
}
|
||||
|
||||
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
|
||||
rule.Rule.MatchTimeout = codeOwnerMatchTimeout
|
||||
|
||||
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, "incorrect codeowner group: "+user)
|
||||
continue
|
||||
}
|
||||
orgName := s[0]
|
||||
teamName := s[1]
|
||||
|
||||
org, err := org_model.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
warnings = append(warnings, "incorrect codeowner organization: "+user)
|
||||
continue
|
||||
}
|
||||
teams, err := org.LoadTeams(ctx)
|
||||
if err != nil {
|
||||
warnings = append(warnings, "incorrect codeowner team: "+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, "incorrect codeowner user: "+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) (tokens []string) {
|
||||
line = strings.TrimSpace(line)
|
||||
line = strings.ReplaceAll(line, "\t", " ")
|
||||
if line == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
escape := false
|
||||
var token strings.Builder
|
||||
for _, char := range line {
|
||||
if escape {
|
||||
token.WriteRune(char)
|
||||
escape = false
|
||||
} else if char == '\\' {
|
||||
escape = true
|
||||
} else if char == '#' {
|
||||
break
|
||||
} else if char == ' ' {
|
||||
if token.Len() > 0 {
|
||||
tokens = append(tokens, token.String())
|
||||
token.Reset()
|
||||
}
|
||||
} else {
|
||||
token.WriteRune(char)
|
||||
}
|
||||
}
|
||||
|
||||
if token.Len() > 0 {
|
||||
tokens = append(tokens, token.String())
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
func parseCodeOwnerAnnotation(line string) (string, optional.Option[bool], []string) {
|
||||
escape := false
|
||||
for i := 0; i < len(line); i++ {
|
||||
if escape {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
if line[i] == '\\' {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
if line[i] != '#' {
|
||||
continue
|
||||
}
|
||||
review := optional.None[bool]()
|
||||
var warnings []string
|
||||
if strings.HasPrefix(line[i:], "##") {
|
||||
annotation, _, _ := strings.Cut(line[i+2:], "#")
|
||||
for field := range strings.FieldsSeq(annotation) {
|
||||
key, value, ok := strings.Cut(field, "=")
|
||||
if key != "review-request" {
|
||||
warnings = append(warnings, "unknown codeowner annotation: "+key)
|
||||
continue
|
||||
}
|
||||
if !ok || (value != "true" && value != "false") {
|
||||
warnings = append(warnings, "incorrect review-request annotation: "+field)
|
||||
continue
|
||||
}
|
||||
review = optional.Some(value == "true")
|
||||
}
|
||||
}
|
||||
return line[:i], review, warnings
|
||||
}
|
||||
return line, optional.None[bool](), nil
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
@@ -24,7 +23,6 @@ import (
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -825,162 +823,6 @@ func MergeBlockedByOutdatedBranch(protectBranch *git_model.ProtectedBranch, pr *
|
||||
return protectBranch.BlockOnOutdatedBranch && pr.CommitsBehind > 0
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// codeOwnerMatchTimeout bounds a single pattern match so a crafted pattern
|
||||
// cannot stall via catastrophic backtracking. See also the aggregate budget
|
||||
// enforced by the caller across the whole rules×files match loop.
|
||||
const codeOwnerMatchTimeout = 150 * time.Millisecond
|
||||
|
||||
type CodeOwnerRule struct {
|
||||
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
||||
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)
|
||||
|
||||
// Strip leading "!" for negative rules, then strip leading "/" since
|
||||
// git returns relative paths (e.g. "docs/foo.md" not "/docs/foo.md")
|
||||
// and the regex is already anchored with ^...$, so the "/" is redundant.
|
||||
pattern := strings.TrimPrefix(tokens[0], "!")
|
||||
pattern = strings.TrimPrefix(pattern, "/")
|
||||
expr := fmt.Sprintf("^%s$", pattern)
|
||||
rule.Rule, err = regexp2.Compile(expr, regexp2.None)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
||||
return nil, warnings
|
||||
}
|
||||
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
|
||||
rule.Rule.MatchTimeout = codeOwnerMatchTimeout
|
||||
|
||||
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, "incorrect codeowner group: "+user)
|
||||
continue
|
||||
}
|
||||
orgName := s[0]
|
||||
teamName := s[1]
|
||||
|
||||
org, err := org_model.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
warnings = append(warnings, "incorrect codeowner organization: "+user)
|
||||
continue
|
||||
}
|
||||
teams, err := org.LoadTeams(ctx)
|
||||
if err != nil {
|
||||
warnings = append(warnings, "incorrect codeowner team: "+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, "incorrect codeowner user: "+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
|
||||
}
|
||||
|
||||
// InsertPullRequests inserted pull requests
|
||||
func InsertPullRequests(ctx context.Context, prs ...*PullRequest) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
|
||||
@@ -44,6 +44,7 @@ func TestPullRequest(t *testing.T) {
|
||||
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
||||
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
||||
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
|
||||
t.Run("CodeOwnerSectionsAndAnnotations", testCodeOwnerSectionsAndAnnotations)
|
||||
t.Run("GetApprovers", testGetApprovers)
|
||||
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
||||
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
||||
@@ -335,7 +336,7 @@ func testParseCodeOwnersLine(t *testing.T) {
|
||||
|
||||
given := []CodeOwnerTest{
|
||||
{Line: "", Tokens: nil},
|
||||
{Line: "# comment", Tokens: []string{}},
|
||||
{Line: "# comment", Tokens: nil},
|
||||
{Line: "!.* @user1 @org1/team1", Tokens: []string{"!.*", "@user1", "@org1/team1"}},
|
||||
{Line: `.*\\.js @user2 #comment`, Tokens: []string{`.*\.js`, "@user2"}},
|
||||
{Line: `docs/(aws|google|azure)/[^/]*\\.(md|txt) @org3 @org2/team2`, Tokens: []string{`docs/(aws|google|azure)/[^/]*\.(md|txt)`, "@org3", "@org2/team2"}},
|
||||
@@ -347,7 +348,7 @@ func testParseCodeOwnersLine(t *testing.T) {
|
||||
|
||||
for _, g := range given {
|
||||
tokens := issues_model.TokenizeCodeOwnersLine(g.Line)
|
||||
assert.Equal(t, g.Tokens, tokens, "Codeowners tokenizer failed")
|
||||
assert.Equal(t, g.Tokens, tokens, "Codeowners tokenizer failed: input=%s", g.Line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,3 +449,49 @@ func testMigrateInsertPullRequests(t *testing.T) {
|
||||
|
||||
unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.PullRequest{})
|
||||
}
|
||||
|
||||
func testCodeOwnerSectionsAndAnnotations(t *testing.T) {
|
||||
rules, warnings := issues_model.GetCodeOwnersFromContent(t.Context(), "*.js @user5\n[Docs] @user1\n*.md\n")
|
||||
require.Empty(t, warnings)
|
||||
require.Len(t, rules, 2)
|
||||
for _, rule := range rules {
|
||||
assert.False(t, rule.ReviewRequest.Has())
|
||||
}
|
||||
|
||||
rules, warnings = issues_model.GetCodeOwnersFromContent(t.Context(), `## review-request=false # global
|
||||
*.js @user5 ## review-request=true # line
|
||||
[Documentation] @user1 ## review-request=true # section
|
||||
*.md
|
||||
/docs/* @user5 ## review-request=false
|
||||
[Other Section] @user5
|
||||
*.txt
|
||||
`)
|
||||
match := func(rule *issues_model.CodeOwnerRule, path string) bool {
|
||||
v, _ := rule.Rule.MatchString(path)
|
||||
return v
|
||||
}
|
||||
require.Empty(t, warnings)
|
||||
require.Len(t, rules, 4)
|
||||
assert.True(t, rules[0].ReviewRequest.Value())
|
||||
assert.True(t, match(rules[0], "src/app.js"))
|
||||
assert.False(t, match(rules[0], "appXjs"))
|
||||
assert.Equal(t, "Documentation", rules[1].Section)
|
||||
assert.Equal(t, "user1", rules[1].Users[0].Name)
|
||||
assert.True(t, rules[1].ReviewRequest.Value())
|
||||
assert.Equal(t, "user5", rules[2].Users[0].Name)
|
||||
assert.False(t, rules[2].ReviewRequest.Value())
|
||||
assert.True(t, match(rules[2], "docs/guide.md"))
|
||||
assert.False(t, match(rules[2], "other/docs/guide.md"))
|
||||
assert.False(t, rules[3].ReviewRequest.Value())
|
||||
|
||||
rules, warnings = issues_model.GetCodeOwnersFromContent(t.Context(), "*.js @user5 ## review-request=invalid\n*[ @user5\n")
|
||||
require.Len(t, rules, 1)
|
||||
assert.False(t, rules[0].ReviewRequest.Has())
|
||||
require.Len(t, warnings, 2)
|
||||
|
||||
rules, warnings = issues_model.GetCodeOwnersFromContent(t.Context(), `file\#name @user5 ## review-request=false`)
|
||||
require.Empty(t, warnings)
|
||||
require.Len(t, rules, 1)
|
||||
assert.True(t, match(rules[0], "file#name"))
|
||||
assert.False(t, rules[0].ReviewRequest.Value())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user