Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e2dd7e8
Move request review functions to pull service package
lunny Aug 23, 2025
193c6c0
hide functions which will not be referenced outside of the package
lunny Aug 23, 2025
7669760
hide functions which will not be referenced outside of the package
lunny Aug 23, 2025
306922c
add webhook test for requestreviwer
lunny Aug 23, 2025
b997afd
Fix test
lunny Aug 23, 2025
cd3b112
Merge branch 'main' into lunny/refactor_review_request
lunny Aug 23, 2025
e433ff3
Add mergable test
lunny Aug 23, 2025
ee59a66
Merge branch 'main' into lunny/refactor_review_request
lunny Aug 28, 2025
766f7fc
Merge branch 'main' into lunny/refactor_review_request
lunny Sep 5, 2025
be34352
Merge branch 'main' into lunny/refactor_review_request
lunny Sep 9, 2025
960a5b9
Merge branch 'main' into lunny/refactor_review_request
lunny Sep 9, 2025
8285eae
Merge branch 'main' into lunny/refactor_review_request
lunny Sep 10, 2025
5be18d2
Merge branch 'main' into lunny/refactor_review_request
lunny Sep 10, 2025
a7016ce
Merge branch 'main' into lunny/refactor_review_request
lunny Sep 18, 2025
927fcfa
Merge branch 'main' into lunny/refactor_review_request
lunny Sep 21, 2025
440f1ed
Merge branch 'lunny/refactor_review_request' of github.com:lunny/gite…
lunny Sep 21, 2025
a035dd2
Merge branch 'main' into lunny/refactor_review_request
lunny Sep 29, 2025
e9fa2ea
Merge branch 'main' into lunny/refactor_review_request
lunny Oct 6, 2025
4eb9fa8
Merge branch 'main' into lunny/refactor_review_request
lunny Oct 9, 2025
e2c16d9
Merge branch 'main' into lunny/refactor_review_request
lunny Oct 18, 2025
3fc59b8
Merge branch 'main' into lunny/refactor_review_request
lunny Oct 25, 2025
bfe6fb9
Merge branch 'lunny/refactor_review_request' of github.com:lunny/gite…
lunny Oct 25, 2025
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
144 changes: 0 additions & 144 deletions models/issues/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"errors"
"fmt"
"io"
"regexp"
"strings"

"code.gitea.io/gitea/models/db"
Expand Down Expand Up @@ -817,149 +816,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
}

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, "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 {
Expand Down
22 changes: 0 additions & 22 deletions models/issues/pull_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,28 +317,6 @@ func TestDeleteOrphanedObjects(t *testing.T) {
assert.Equal(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) @org3 @org2/team2`, Tokens: []string{`docs/(aws|google|azure)/[^/]*\.(md|txt)`, "@org3", "@org2/team2"}},
{Line: `\#path @org3`, Tokens: []string{`#path`, "@org3"}},
{Line: `path\ with\ spaces/ @org3`, Tokens: []string{`path with spaces/`, "@org3"}},
}

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

func TestGetApprovers(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
Expand Down
168 changes: 168 additions & 0 deletions modules/repository/codeowner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repository

import (
"context"
"fmt"
"regexp"
"slices"
"strings"

org_model "code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
)

var codeOwnerFiles = []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}

func IsCodeOwnerFile(f string) bool {
return slices.Contains(codeOwnerFiles, f)
}

func GetCodeOwnerFiles() []string {
return codeOwnerFiles
}

// 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, "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
}
32 changes: 32 additions & 0 deletions modules/repository/codeowner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repository

import (
"testing"

"github.com/stretchr/testify/assert"
)

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) @org3 @org2/team2`, Tokens: []string{`docs/(aws|google|azure)/[^/]*\.(md|txt)`, "@org3", "@org2/team2"}},
{Line: `\#path @org3`, Tokens: []string{`#path`, "@org3"}},
{Line: `path\ with\ spaces/ @org3`, Tokens: []string{`path with spaces/`, "@org3"}},
}

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