diff --git a/docs/content/doc/usage/codeowners.en-us.md b/docs/content/doc/usage/codeowners.en-us.md new file mode 100644 index 0000000000000..672ac18f89b9b --- /dev/null +++ b/docs/content/doc/usage/codeowners.en-us.md @@ -0,0 +1,96 @@ +--- +date: "2023-06-02T16:00:00+00:00" +title: "Code Owners" +slug: "codeowners" +weight: 30 +toc: false +draft: false +aliases: + - /en-us/codeowners +menu: + sidebar: + parent: "usage" + name: "Code Owners" + weight: 30 + identifier: "codeowners" +--- + +# Code Owners +You can use a CODEOWNERS file to define individuals or teams that are responsible for code in a repository. Code owners are automatically requested for review when someone opens a pull request that modifies code that they own. + +If a file has a code owner, you can see who the code owner is before you open a pull request. In a Gitea repository, you can browse to the file and hover over the shield icon to see a tool tip with code ownership details. You can also see this code ownership shield for individual files when opening a pull request, viewing a pull request's changed files, or viewing a commit. + +## CODEOWNRS File Location +To use a CODEOWNERS file, create a new file called `CODEOWNERS` in the root, `docs/`, or `.gitea/` directory of the repository in the branch where you'd like to add the code owners. There should only be one such file. The first matching file is used to determine ownership and the rest are ignored. + +Each CODEOWNERS file assigns the code owners for a single branch in the repository. Thus, you can assign different code owners for different branches. + +For code owners to receive review requests, the CODEOWNERS file must be on the base branch of the pull request. For example, if you assign `@userA` as the code owner for *.js* files on the `feature-A` branch of your repository, `@userA` will receive review requests when a pull request with changes to *.js* files is opened between the head branch and `feature-A`. + +## CODEOWNERS File Size +CODEOWNERS files must be under 3 MB in size. A CODEOWNERS file over this limit will not be loaded, which means that code owner information is not shown and the appropriate code owners will not be requested to review changes in a pull request. + +To reduce the size of your CODEOWNERS file, consider using wildcard patterns to consolidate multiple entries into a single entry. + +## CODEOWNERS Syntax +> **Warning**: There are some syntax rules for gitignore files that *do not work* in CODEOWNERS files: +> * Escaping a pattern starting with `#` using `\` so it is treated as a pattern and not a comment +> * Using `!` to negate a pattern +> * Using `[ ]` to define a character range + +A CODEOWNERS file uses a pattern that follows most of the same rules used in [gitignore](https://git-scm.com/docs/gitignore#_pattern_format) files. The pattern is followed by one or more Gitea usernames or team names using the standard `@username` or `@org/team-name` format. Users must have explicit `write` access to the repository. Teams must also have explicit `write` access to the repository, even if the all the team's members already have access. + +If you want to match two or more code owners with the same pattern, all the code owners must be on the same line. If the code owners are not on the same line, the pattern matches only the last mentioned code owner. + +You can also refer to a user by their email address (for example, `user@example.com`). Note that if their email address changes after the CODEOWNERS file is created, they would fail to be identified when parsing the file. + +CODEOWNERS paths are case sensitive, because Gitea uses a case sensitive file system. Since CODEOWNERS are evaluated by Gitea, even systems that are case insensitive (for example, macOS) must use paths and files that are cased correctly in the CODEOWNERS file. + +If any line in your CODEOWNERS file contains invalid syntax or references a user or team that is ineligible, that line will be skipped. When you navigate to the CODEOWNERS file in your repository on Gitea, you can see any validation errors. + +### Example CODEOWNERS file +``` +# This is a comment. +# Each line is a file pattern followed by one or more owners. + +# These owners will be the default owners for everything in the repo. Unless a later match takes precedence, +# @global-owner1 and @global-owner2 will be requested for review when someone opens a pull request. +* @global-owner1 @global-owner2 + +# Order is important; the last matching pattern takes the most precedence. When someone opens a pull request +# that only modifies JS files, only @js-owner and not the global owner(s) will be requested for a review. +*.js @js-owner #This is an inline comment. + +# You can also use email addresses if you prefer. +*.go docs@example.com + +# Teams can be specified as code owners as well. Teams should be identified in the format @org/team-name. Teams must have +# explicit write access to the repository. In this example, the octocats team in the octo-org organization owns all .txt files. +*.txt @octo-org/octocats + +# In this example, @doctocat owns any files in the build/logs +# directory at the root of the repository and any of its subdirectories. +/build/logs/ @doctocat + +# The `docs/*` pattern will match files like `docs/getting-started.md` but not further +# nested files like `docs/build-app/troubleshooting.md`. +docs/* docs@example.com + +# In this example, @octocat owns any file in an apps directory anywhere in your repository. +apps/ @octocat + +# In this example, @doctocat owns any file in the `/docs` directory in the root of your repository and any of its subdirectories. +/docs/ @doctocat + +# In this example, any change inside the `/scripts` directory will require approval from @doctocat or @octocat. +/scripts/ @doctocat @octocat + +# In this example, @octocat owns any file in a `/logs` directory such as `/build/logs`, `/scripts/logs`, and +# `/deeply/nested/logs`. Any changes in a `/logs` directory will require approval from @octocat. +**/logs @octocat + +# In this example, @octocat owns any file in the `/apps` directory in the root of your repository +# except for the `/apps/github` subdirectory, as its owners are left empty. +/apps/ @octocat +/apps/github +``` \ No newline at end of file diff --git a/go.mod b/go.mod index 93b8052daf86f..8c862c388cb5b 100644 --- a/go.mod +++ b/go.mod @@ -125,6 +125,11 @@ require ( xorm.io/xorm v1.3.3-0.20230219231735-056cecc97e9e ) +require ( + github.com/MichaelTJones/walk v0.0.0-20161122175330-4748e29d5718 // indirect + github.com/mgutz/str v1.2.0 // indirect +) + require ( cloud.google.com/go/compute v1.18.0 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect @@ -292,6 +297,7 @@ require ( google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/godo.v2 v2.0.9 gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 8e5b728aac9de..878755140f923 100644 --- a/go.sum +++ b/go.sum @@ -98,6 +98,8 @@ github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7Y github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/MichaelTJones/walk v0.0.0-20161122175330-4748e29d5718 h1:FSsoaa1q4jAaeiAUxf9H0PgFP7eA/UL6c3PdJH+nMN4= +github.com/MichaelTJones/walk v0.0.0-20161122175330-4748e29d5718/go.mod h1:VVwKsx9Dc8rNG55BWqogoJzGubjKnRoXdUvpGbWqeCc= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= @@ -872,6 +874,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zk github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/meilisearch/meilisearch-go v0.24.0 h1:GTP8LWZmkMYrGgX5BRZdkC2Txyp0mFYLzXYMlVV7cSQ= github.com/meilisearch/meilisearch-go v0.24.0/go.mod h1:SxuSqDcPBIykjWz1PX+KzsYzArNLSCadQodWs8extS0= +github.com/mgutz/str v1.2.0 h1:4IzWSdIz9qPQWLfKZ0rJcV0jcUDpxvP4JVZ4GXQyvSw= +github.com/mgutz/str v1.2.0/go.mod h1:w1v0ofgLaJdoD0HpQ3fycxKD1WtxpjSo151pK/31q6w= github.com/mholt/acmez v1.1.0 h1:IQ9CGHKOHokorxnffsqDvmmE30mDenO1lptYZ1AYkHY= github.com/mholt/acmez v1.1.0/go.mod h1:zwo5+fbLLTowAX8o8ETfQzbDtwGEXnPhkmGdKIP+bgs= github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= @@ -1764,6 +1768,8 @@ gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qS gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= +gopkg.in/godo.v2 v2.0.9 h1:jnbznTzXVk0JDKOxN3/LJLDPYJzIl0734y+Z0cEJb4A= +gopkg.in/godo.v2 v2.0.9/go.mod h1:wgvPPKLsWN0hPIJ4JyxvFGGbIW3fJMSrXhdvSuZ1z/8= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 6f85bc4d2d977..ff5cde930d618 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3441,3 +3441,10 @@ need_approval_desc = Need approval to run workflows for fork pull request. type-1.display_name = Individual Project type-2.display_name = Repository Project type-3.display_name = Organization Project + +[codeowners] +codeownership = Owned by %s (CODEOWNERS) +error_notice = This CODEOWNERS file contains errors: +validation_message_syntax = Error on line %d: make sure owners are formated like "@user", "user@email.com", or "@org-name/team-name" +validation_message_permissions_plural = Error on line %d: make sure %s exist and have "write" access to the repository +validation_message_permissions_singular = Error on line %d: make sure %s exists and has "write" access to the repository \ No newline at end of file diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 0ca1f90547efc..67dd2394860ba 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -34,6 +34,7 @@ import ( "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/gitdiff" + issue_service "code.gitea.io/gitea/services/issue" ) const ( @@ -62,6 +63,39 @@ func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner setPathsCompareContext(ctx, before, head, headOwner, headName) setImageCompareContext(ctx) setCsvCompareContext(ctx) + err := setCodeownersContext(ctx, ctx.Repo.CommitID, before.ID.String(), head.ID.String()) + if err != nil { + log.Warn("setCodeownersContext: %v", err) + } +} + +// setCodeownersContext gets a map of file paths to their code owners using the CODEOWNERS in the base branch and puts it in the context data +func setCodeownersContext(ctx *context.Context, baseCommitID, beforeCommitID, headCommitID string) error { + gitRepo, err := git.OpenRepository(ctx, ctx.Repo.Repository.RepoPath()) + if err != nil { + return err + } + defer gitRepo.Close() + + mapping := make(map[string]string) + baseCommit, err := gitRepo.GetCommit(baseCommitID) + if err == nil { + codeownersContents, err := issue_service.GetCodeownersFileContents(ctx, baseCommit, gitRepo) + if err == nil && codeownersContents != nil && len(codeownersContents) > 0 { + changedFiles, err := gitRepo.GetFilesChangedBetween(beforeCommitID, headCommitID) + if err == nil { + for _, filePath := range changedFiles { + codeowners, err := GetFileCodeowners(ctx, filePath, codeownersContents) + if err == nil && len(codeowners) > 0 { + mapping[filePath] = ctx.Locale.Tr("codeowners.codeownership", strings.Join(codeowners, ", ")) + } + } + } + } + } + + ctx.Data["CodeownersFileMap"] = mapping + return nil } // SourceCommitURL creates a relative URL for a commit in the given repository @@ -777,6 +811,14 @@ func CompareDiff(ctx *context.Context) { beforeCommitID := ctx.Data["BeforeCommitID"].(string) afterCommitID := ctx.Data["AfterCommitID"].(string) + // Want to display codeownership of changed files if there are any to display + if ctx.Data["PageIsComparePull"] == true && !nothingToCompare { + err := setCodeownersContext(ctx, ci.CompareInfo.BaseCommitID, beforeCommitID, afterCommitID) + if err != nil { + log.Warn("setCodeownersContext: %v", err) + } + } + separator := "..." if ci.DirectComparison { separator = ".." diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index 63387df281e3f..ca76a3349f58d 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -160,6 +160,11 @@ func editFile(ctx *context.Context, isNewFile bool) { ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") ctx.Data["Editorconfig"] = GetEditorConfig(ctx, treePath) + err := SetCodeownerValidationInfo(ctx) + if err != nil { + log.Warn("GetCodeownerValidationInfo: %v", err) + } + ctx.HTML(http.StatusOK, tplEditFile) } diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 2fd893f91c6dd..2a79fec30d0c4 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -383,6 +383,11 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st ctx.Data["IsDisplayingSource"] = isDisplayingSource ctx.Data["IsDisplayingRendered"] = isDisplayingRendered + err = SetCodeownersOwnershipInfo(ctx) + if err != nil { + log.Warn("SetCodeownersOwnershipInfo: %v", err) + } + isTextSource := fInfo.isTextFile || isDisplayingSource ctx.Data["IsTextSource"] = isTextSource if isTextSource { @@ -582,6 +587,86 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st } else if !ctx.Repo.CanWriteToBranch(ctx.Doer, ctx.Repo.BranchName) { ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") } + + if entry.Name() == "CODEOWNERS" { + err := SetCodeownerValidationInfo(ctx) + if err != nil { + log.Warn("GetCodeownerValidationInfo: %v", err) + } + } +} + +// SetCodeownerValidationInfo gets the validation information for the current file (assumes is "CODEOWNERS") and sets the context data accordingly +func SetCodeownerValidationInfo(ctx *context.Context) error { + contentsBytes, err := issue_service.GetCodeownersFileContents(ctx, ctx.Repo.Commit, ctx.Repo.GitRepo) + if err != nil { + return err + } + + _, _, validationErrors, err := issue_service.ParseCodeowners(ctx, ctx.Repo.Repository, ctx.Doer, []string{}, contentsBytes) + if err != nil { + return err + } + + if len(validationErrors) > 0 { + ctx.Data["CodeownersValidationNotice"] = ctx.Locale.Tr("codeowners.error_notice") + validationMessages := []string{} + for lineNum, err := range validationErrors { + var message string + switch problem := err.(type) { + default: + return fmt.Errorf("CODEOWNERS validation error was not of type InvalidSyntaxError or ExistenceAndPermissionError [validation_error: %v]", problem) + case *issue_service.InvalidSyntaxError: + message = ctx.Locale.Tr("codeowners.validation_message_syntax", lineNum) + case *issue_service.ExistenceAndPermissionError: + if len(problem.Owners()) > 1 { + message = ctx.Locale.Tr("codeowners.validation_message_permissions_plural", lineNum, strings.Join(problem.Owners(), ", ")) + } else { + message = ctx.Locale.Tr("codeowners.validation_message_permissions_singular", lineNum, strings.Join(problem.Owners(), ", ")) + } + } + validationMessages = append(validationMessages, message) + } + ctx.Data["CodeownersValidationErrors"] = validationMessages + } + + return nil +} + +// SetCodeownersOwnershipInfo gets the ownership information for the current file and sets the context data accordingly +func SetCodeownersOwnershipInfo(ctx *context.Context) error { + gitRepo, err := git.OpenRepository(ctx, ctx.Repo.Repository.RepoPath()) + if err != nil { + return err + } + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit(ctx.Repo.CommitID) + if err == nil { + codeownersContents, err := issue_service.GetCodeownersFileContents(ctx, commit, gitRepo) + if err == nil && codeownersContents != nil && len(codeownersContents) > 0 { + codeowners, err := GetFileCodeowners(ctx, ctx.Repo.TreePath, codeownersContents) + if err == nil && len(codeowners) > 0 { + ctx.Data["Codeowners"] = ctx.Locale.Tr("codeowners.codeownership", strings.Join(codeowners, ", ")) + } + } + } + + return nil +} + +// GetFileCodeowners gets the codeowners for the given file path and CODEOWNERS file contents +func GetFileCodeowners(ctx *context.Context, filePath string, codeownersContents []byte) (codeowners []string, err error) { + userOwners, teamOwners, _, err := issue_service.ParseCodeowners(ctx, ctx.Repo.Repository, ctx.Doer, []string{filePath}, codeownersContents) + if err == nil { + for _, userOwner := range userOwners { + codeowners = append(codeowners, userOwner.Name) + } + for _, teamOwner := range teamOwners { + codeowners = append(codeowners, teamOwner.Name) + } + } + return codeowners, err } func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input io.Reader) (escaped *charset.EscapeStatus, output string, err error) { diff --git a/services/issue/codeowners.go b/services/issue/codeowners.go new file mode 100644 index 0000000000000..7fee9018f901c --- /dev/null +++ b/services/issue/codeowners.go @@ -0,0 +1,447 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "bufio" + "context" + b64 "encoding/base64" + "errors" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + organization_model "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + + "gopkg.in/godo.v2/glob" +) + +type CodeownerRule struct { + glob string // Glob pattern for matching files to owners + users []*user_model.User // Users designated as owners of files matching the glob pattern + teams []*organization_model.Team // Teams designated as owners of files matching the glob pattern +} + +type InvalidSyntaxError struct { + owners []string +} + +func (e *InvalidSyntaxError) Error() string { + return fmt.Sprintf("owners: %v", e.owners) +} + +type ExistenceAndPermissionError struct { + owners []string +} + +func (e *ExistenceAndPermissionError) Error() string { + return fmt.Sprintf("owners: %v", e.owners) +} + +func (e *ExistenceAndPermissionError) Owners() []string { + return e.owners +} + +// ParseCodeowners parses the given CODEOWNERS file contents and returns all Users and Teams with write permissions (who own any of the changed files) and any +// validationErrors, which maps line numbers to the error (InvalidSyntaxError or ExistenceAndPermissionError) +func ParseCodeowners(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, changedFiles []string, codeownersContents []byte) ( + userOwners []*user_model.User, + teamOwners []*organization_model.Team, + validationErrors map[int]error, + err error, +) { + scanner := bufio.NewScanner(strings.NewReader(string(codeownersContents))) + codeownerMap, validationErrors, err := ScanAndParseCodeowners(ctx, repo, doer, *scanner) + + for _, file := range changedFiles { + users, teams := GetOwners(codeownerMap, file) + userOwners = append(userOwners, users...) + teamOwners = append(teamOwners, teams...) + } + userOwners = RemoveDuplicateUsers(userOwners) + teamOwners = RemoveDuplicateTeams(teamOwners) + + log.Trace("Final result of Codeowner Users: ") + for _, user := range userOwners { + log.Trace(user.Name) + } + + log.Trace("Final result of Codeowner Teams: ") + for _, team := range teamOwners { + log.Trace(team.Name) + } + + return userOwners, teamOwners, validationErrors, err +} + +// GetOwners returns the list of owners for a single file given the defined codeownership rules in codeownerMap +func GetOwners(codeownerMap []CodeownerRule, file string) ([]*user_model.User, []*organization_model.Team) { + for i := len(codeownerMap) - 1; i >= 0; i-- { + if glob.Globexp(codeownerMap[i].glob).MatchString(file) { + log.Trace("Codeowner file mappping. File: %s, Ownership map: %v", file, codeownerMap[i]) + return codeownerMap[i].users, codeownerMap[i].teams + } + } + log.Trace("Unmatched codeowners file: ", file) + return nil, nil +} + +// SeparateOwnerAndTeam separates user name/email and team names (org/team-name) based on format +func SeparateOwnerAndTeam(codeownersList []string) (codeownerIndividuals, codeOwnerTeams []string) { + for _, codeowner := range codeownersList { + if len(codeowner) > 0 { + + // We remove that @ sign from the codeowner because it's unnecessary for future checks -- they only need the username + if strings.Compare(codeowner[0:1], "@") == 0 { + codeowner = codeowner[1:] + } + + // If the string contains '/' it must be a team, based on format. Otherwise, it's an individual user + if strings.Contains(codeowner, "/") { + codeOwnerTeams = append(codeOwnerTeams, codeowner) + } else { + codeownerIndividuals = append(codeownerIndividuals, codeowner) + } + } + } + + return codeownerIndividuals, codeOwnerTeams +} + +// RemoveDuplicateUsers returns a list without any duplicate users +func RemoveDuplicateUsers(duplicatesPresent []*user_model.User) []*user_model.User { + // Make a map with all keys initialized to false + allKeys := make(map[*user_model.User]bool) + duplicatesRemoved := []*user_model.User{} + + // For each item in the list, add it and mark it "true" in the map, then skip it every time + for _, item := range duplicatesPresent { + if _, value := allKeys[item]; !value { + allKeys[item] = true + duplicatesRemoved = append(duplicatesRemoved, item) + } + } + + return duplicatesRemoved +} + +// RemoveDuplicateTeams returns a list without any duplicate teams +func RemoveDuplicateTeams(duplicatesPresent []*organization_model.Team) []*organization_model.Team { + // Make a map with all keys initialized to false + allKeys := make(map[*organization_model.Team]bool) + duplicatesRemoved := []*organization_model.Team{} + + // For each item in the list, add it and mark it "true" in the map, then skip it every time + for _, item := range duplicatesPresent { + if _, value := allKeys[item]; !value { + allKeys[item] = true + duplicatesRemoved = append(duplicatesRemoved, item) + } + } + + return duplicatesRemoved +} + +// ScanAndParseCodeowners parses the CODEOWNERS contents and extracts the rules/patterns and their associated user/teams along with any validation errors +func ScanAndParseCodeowners(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, scanner bufio.Scanner) (codeownerRules []CodeownerRule, validationErrors map[int]error, err error) { + validationErrors = make(map[int]error) + var lineCounter int + + for scanner.Scan() { + curLine := scanner.Text() + lineCounter++ + globString, globString2, curLineOwnerCandidates := ParseCodeownersLine(curLine) + + // If there are no users/teams listed, that is a valid rule, but we return empty user/team lists + if len(curLineOwnerCandidates) == 0 { + newCodeownerRule := CodeownerRule{ + glob: globString, + users: []*user_model.User{}, + teams: []*organization_model.Team{}, + } + codeownerRules = append(codeownerRules, newCodeownerRule) + } else { + if IsValidCodeownersLineSyntax(curLineOwnerCandidates) { + users, teams, usersAndTeamsExistWithCorrectPermissions := GetUsersAndTeamsWithWritePermissions(ctx, repo, doer, curLineOwnerCandidates) + if usersAndTeamsExistWithCorrectPermissions { + newCodeownerRule := CodeownerRule{ + glob: globString, + users: users, + teams: teams, + } + + codeownerRules = append(codeownerRules, newCodeownerRule) + + if globString2 != "" { + newCodeownersRule2 := CodeownerRule{ + glob: globString2, + users: users, + teams: teams, + } + + codeownerRules = append(codeownerRules, newCodeownersRule2) + } + } else { + validationErrors[lineCounter] = &ExistenceAndPermissionError{ + owners: curLineOwnerCandidates, + } + log.Trace("Invalid user/team/email given on line " + fmt.Sprint(lineCounter) + ":" + curLine) + } + } else { + validationErrors[lineCounter] = &InvalidSyntaxError{ + owners: curLineOwnerCandidates, + } + log.Trace("Invalid syntax given on line " + fmt.Sprint(lineCounter) + ":" + curLine) + } + } + + log.Trace("Line number " + fmt.Sprint(lineCounter) + ":") + log.Trace("Parsed as Glob string: " + globString + "," + globString2 + " Users: " + fmt.Sprint(curLineOwnerCandidates)) + } + + if scanner.Err() != nil { + log.Trace(scanner.Err().Error()) + return nil, validationErrors, scanner.Err() + } + + log.Trace("Parsed map from codeowners file: " + fmt.Sprint(codeownerRules)) + return codeownerRules, validationErrors, nil +} + +// ParseCodeownersLine extracts two potential globbing rule strings and the owners associated with those rules for a given line +// of a CODEOWNERS file. Note that there are two potential globbing rules for the following situation, when we can't identify +// whether it's a file name or a subdirectory: /docs/github can be either /docs/github or /docs/github/** +func ParseCodeownersLine(line string) (globString, globString2 string, currFileUsers []string) { + splitStrings := strings.Fields(line) + var userStopIndex int + + for i := 0; i < len(splitStrings); i++ { + // The first two checks here handle comments + if strings.Compare(splitStrings[i], "#") == 0 { + break + } else if strings.Contains(splitStrings[i], "#") { + commentStrings := strings.Split(splitStrings[i], "#") + if len(commentStrings[0]) > 0 { + if i == 0 { + globString = commentStrings[0] + } else { + splitStrings[i] = commentStrings[0] + userStopIndex = i + } + } + break + } else if i == 0 { + globString = splitStrings[i] + + // Note the logic here for mapping from Codeowners format to our current globbing library + if len(globString) < 1 { + // This should only occur if the first character is '/', which we don't consider a valid rule + } else if len(globString) == 1 { + if strings.Compare(globString[0:1], "*") == 0 { + globString = "**/**/**" + } + } else if strings.Compare(globString[0:1], "/") == 0 { + globString = globString[1:] + } else if strings.Compare(globString[0:1], "*") == 0 && + strings.Compare(globString[1:2], "*") != 0 { + globString = "**/" + globString + } else if strings.Compare(globString[0:1], "*") != 0 { + globString = "**/" + globString + } else if strings.Compare(globString[(len(globString)-1):], "/") == 0 { + globString = "**/" + globString + "**" + } + + if strings.Compare(globString[len(globString)-1:], "/") != 0 && + strings.Compare(globString[len(globString)-1:], "*") != 0 { + globString2 = globString + "/**" + } else if strings.Compare(globString[len(globString)-1:], "/") == 0 { + globString += "**" + } + } else { + userStopIndex = i + } + } + + if userStopIndex > 0 { + currFileUsers = splitStrings[1 : userStopIndex+1] + } + + return globString, globString2, currFileUsers +} + +// IsValidCodeownersLineSyntax returns true if the given line of the CODEOWNERS file (after the file pattern) is valid syntactically +func IsValidCodeownersLineSyntax(currFileOwnerCandidates []string) bool { + for _, user := range currFileOwnerCandidates { + if !glob.Globexp("@*").MatchString(user) && + !glob.Globexp("@*/*").MatchString(user) && + !glob.Globexp("*@*.*").MatchString(user) { + return false + } + } + return true +} + +// GetUsersAndTeamsWithWritePermissions gets the Users and Teams from the given array of owner candidates (emails, usernames, and team names). +// Returns nil arrays and false if any Users/Teams are not found or do not have write permissions for the given repository. +func GetUsersAndTeamsWithWritePermissions(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, ownerCandidates []string) (users []*user_model.User, teams []*organization_model.Team, isValidLine bool) { + currIndividualOwners, currTeamOwners := SeparateOwnerAndTeam(ownerCandidates) + + for _, individual := range currIndividualOwners { + user, err := GetUserByNameOrEmail(ctx, individual, repo) + if err == nil { + if UserHasWritePermissions(ctx, repo, user) { + users = append(users, user) + } else { + return nil, nil, false + } + } else { + return nil, nil, false + } + } + + for _, team := range currTeamOwners { + team, err := GetTeamFromFullName(ctx, team, doer) + if err == nil { + if TeamHasWritePermissions(ctx, repo, team) { + teams = append(teams, team) + } else { + return nil, nil, false + } + } else { + return nil, nil, false + } + } + + return users, teams, true +} + +// GetCodeownersFileContents gets the CODEOWNERS file from the top level,'.gitea', or 'docs' directory of the +// given repository. It uses whichever is found first if there are multiple (there should not be) +func GetCodeownersFileContents(ctx context.Context, commit *git.Commit, gitRepo *git.Repository) ([]byte, error) { + entry := GetCodeownersGitTreeEntry(commit) + if entry == nil { + return nil, nil + } + + if entry.IsRegular() { + gitBlob := entry.Blob() + data, err := gitBlob.GetBlobContentBase64() + if err != nil { + return nil, err + } + contentBytes, err := b64.StdEncoding.DecodeString(data) + if err != nil { + return nil, err + } + return contentBytes, nil + } else { + log.Warn("GetCodeownersFileContents [commit_id: %d, git_tree_entry_id: %d]: CODEOWNERS file found is not a regular file", commit.ID, entry.ID) + return nil, nil + } +} + +// TODO: Move to within parse function and create custom error type. Then can be used by calling function to handle error how it needs to. +// IsCodeownersWithinSizeLimit returns an error if the file is too big. Nil if acceptable. +func IsCodeownersWithinSizeLimit(contentBytes []byte) error { + byteLimit := 3 * 1024 * 1024 // 3 MB limit, per GitHub specs + if len(contentBytes) >= byteLimit { + return fmt.Errorf("CODEOWNERS file exceeds size limit. Is %d bytes but must be under %d", len(contentBytes), byteLimit) + } + return nil +} + +// GetCodeownersGitTreeEntry gets the git tree entry of the CODEOWNERS file. Nil if not found in an accepted location. +func GetCodeownersGitTreeEntry(commit *git.Commit) *git.TreeEntry { + // Accepted directories to search for the CODEOWNERS file + directoryOptions := []string{"", ".gitea/", "docs/"} + + for _, dir := range directoryOptions { + entry, _ := commit.GetTreeEntryByPath(dir + "CODEOWNERS") + if entry != nil { + return entry + } + } + return nil +} + +// GetUserByNameOrEmail gets the user by either its name or email depending on the format of the input +func GetUserByNameOrEmail(ctx context.Context, nameOrEmail string, repo *repo_model.Repository) (*user_model.User, error) { + var reviewer *user_model.User + var err error + if strings.Contains(nameOrEmail, "@") { + reviewer, err = user_model.GetUserByEmail(ctx, nameOrEmail) + if err != nil { + log.Info("GetUserByNameOrEmail [repo_id: %d, owner_email: %s]: user owner in CODEOWNERS file could not be found by email", repo.ID, nameOrEmail) + } + } else { + reviewer, err = user_model.GetUserByName(ctx, nameOrEmail) + if err != nil { + log.Info("GetUserByNameOrEmail [repo_id: %d, owner_username: %s]: user owner in CODEOWNERS file could not be found by name", repo.ID, nameOrEmail) + } + } + return reviewer, err +} + +// GetTeamFromFullName gets the team given its full name ('{organizationName}/{teamName}'). Nil if not found. +func GetTeamFromFullName(ctx context.Context, fullTeamName string, doer *user_model.User) (*organization_model.Team, error) { + teamNameSplit := strings.Split(fullTeamName, "/") + if len(teamNameSplit) != 2 { + return nil, errors.New("Team name must split into exactly 2 parts when split on '/'") + } + organizationName, teamName := teamNameSplit[0], teamNameSplit[1] + + opts := organization_model.FindOrgOptions{ + ListOptions: db.ListOptions{ + ListAll: true, + }, + UserID: doer.ID, + IncludePrivate: true, + } + organizations, err := organization_model.FindOrgs(opts) + if err != nil { + return nil, err + } + + var organization *organization_model.Organization + for _, org := range organizations { + if org.Name == organizationName { + organization = org + break + } + } + + var team *organization_model.Team + if organization != nil { + team, err = organization.GetTeam(ctx, teamName) + if err != nil { + return nil, err + } + } + return team, nil +} + +// UserHasWritePermissions returns true if the user has write permissions to the code in the repository +func UserHasWritePermissions(ctx context.Context, repo *repo_model.Repository, user *user_model.User) bool { + permission, err := access_model.GetUserRepoPermission(ctx, repo, user) + if err != nil { + log.Debug("models/perm/access/GetUserRepoPermission: %v", err) + return false + } + return permission.CanWrite(unit.TypeCode) +} + +// TeamHasWritePermissions returns true if the team has write permissions to the code in the repository +func TeamHasWritePermissions(ctx context.Context, repo *repo_model.Repository, team *organization_model.Team) bool { + if organization_model.HasTeamRepo(ctx, team.OrgID, team.ID, repo.ID) { + return team.UnitAccessMode(ctx, unit.TypeCode) == perm.AccessModeWrite + } + return false +} diff --git a/services/issue/custom/conf/app.ini b/services/issue/custom/conf/app.ini new file mode 100644 index 0000000000000..0dd880d9114eb --- /dev/null +++ b/services/issue/custom/conf/app.ini @@ -0,0 +1,75 @@ +RUN_USER = ihamm +I_AM_BEING_UNSAFE_RUNNING_AS_ROOT = false +RUN_MODE = prod +APP_NAME = Gitea: Git with a cup of tea + +[log] +LEVEL = info +STACKTRACE_LEVEL = None +ROOT_PATH = c:/Users/ihamm/Desktop/Gitea-Extension(Galgo)/gitea/services/issue/log +BUFFER_LEN = 10000 +ENABLE_SSH_LOG = false +ENABLE_ACCESS_LOG = false +ACCESS_LOG_TEMPLATE = {{.Ctx.RemoteHost}} - {{.Identity}} {{.Start.Format "[02/Jan/2006:15:04:05 -0700]" }} "{{.Ctx.Req.Method}} {{.Ctx.Req.URL.RequestURI}} {{.Ctx.Req.Proto}}" {{.ResponseWriter.Status}} {{.ResponseWriter.Size}} "{{.Ctx.Req.Referer}}" "{{.Ctx.Req.UserAgent}}" +REQUEST_ID_HEADERS = +ACCESS = file +ROUTER = console +DISABLE_ROUTER_LOG = false +ENABLE_XORM_LOG = true + +[server] +DOMAIN = localhost +HTTP_ADDR = 0.0.0.0 +HTTP_PORT = 3000 +PROTOCOL = +USE_PROXY_PROTOCOL = false +PROXY_PROTOCOL_TLS_BRIDGING = false +PROXY_PROTOCOL_HEADER_TIMEOUT = 5s +PROXY_PROTOCOL_ACCEPT_UNKNOWN = false +ALLOW_GRACEFUL_RESTARTS = true +GRACEFUL_HAMMER_TIME = 1m0s +STARTUP_TIMEOUT = 0s +PER_WRITE_TIMEOUT = 30s +PER_WRITE_PER_KB_TIMEOUT = 10s +ROOT_URL = http://localhost:3000 +STATIC_URL_PREFIX = +LOCAL_ROOT_URL = http://localhost:3000/ +LOCAL_USE_PROXY_PROTOCOL = false +REDIRECT_OTHER_PORT = false +PORT_TO_REDIRECT = 80 +REDIRECTOR_USE_PROXY_PROTOCOL = false +OFFLINE_MODE = +DISABLE_ROUTER_LOG = +STATIC_ROOT_PATH = c:/Users/ihamm/Desktop/Gitea-Extension(Galgo)/gitea/services/issue +STATIC_CACHE_TIME = 6h0m0s +APP_DATA_PATH = c:/Users/ihamm/Desktop/Gitea-Extension(Galgo)/gitea/services/issue/data +ENABLE_GZIP = +ENABLE_PPROF = false +PPROF_DATA_PATH = c:/Users/ihamm/Desktop/Gitea-Extension(Galgo)/gitea/services/issue/data/tmp/pprof +LANDING_PAGE = home +SSH_SERVER_CIPHERS = +SSH_SERVER_KEY_EXCHANGES = +SSH_SERVER_MACS = +SSH_KEYGEN_PATH = +SSH_PORT = 22 +SSH_LISTEN_PORT = 22 +SSH_SERVER_USE_PROXY_PROTOCOL = false +SSH_TRUSTED_USER_CA_KEYS_FILENAME = C:\Users\ihamm\.ssh\gitea-trusted-user-ca-keys.pem +SSH_AUTHORIZED_PRINCIPALS_ALLOW = off +MINIMUM_KEY_SIZE_CHECK = true +SSH_AUTHORIZED_KEYS_BACKUP = true +SSH_CREATE_AUTHORIZED_KEYS_FILE = true +SSH_EXPOSE_ANONYMOUS = false +SSH_AUTHORIZED_KEYS_COMMAND_TEMPLATE = {{.AppPath}} --config={{.CustomConf}} serv key-{{.Key.ID}} +SSH_PER_WRITE_TIMEOUT = 30s +SSH_PER_WRITE_PER_KB_TIMEOUT = 10s +BUILTIN_SSH_SERVER_USER = ihamm +SSH_USER = ihamm + +[ssh.minimum_key_sizes] + +[security] +INSTALL_LOCK = false + +[oauth2] +JWT_SECRET = lm6YuicotKveeedYjAR0h5IGQtOM8FtBdQsf_boFPwo diff --git a/services/issue/issue.go b/services/issue/issue.go index d4f827e99af56..5a8f30eb71d47 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -16,6 +16,7 @@ import ( system_model "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/storage" ) @@ -192,6 +193,82 @@ func AddAssigneeIfNotAssigned(ctx context.Context, issue *issues_model.Issue, do return nil } +// AddCodeownerReviewers gets all the code owners of the files changed in the pull request (as outlined in the base repository's +// CODEOWNERS file) and requests them for review if they exist and are eligible to do so. +func AddCodeownerReviewers(ctx context.Context, pr *issues_model.PullRequest, repo *repo_model.Repository) (err error) { + gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + if err != nil { + log.Error("git.OpenRepository: %v", err) + return err + } + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit(pr.BaseBranch) + if err != nil { + return err + } + + codeownersContents, err := GetCodeownersFileContents(ctx, commit, gitRepo) + if err != nil { + return err + } + + err = IsCodeownersWithinSizeLimit(codeownersContents) + if err != nil { + log.Warn("GetCodeownersFileContents [repo_id: %d, pr_id: %d]: %v", pr.Issue.RepoID, pr.ID, err) + } else if codeownersContents != nil { + changedFiles, err := gitRepo.GetFilesChangedBetween(pr.MergeBase, pr.HeadCommitID) + if err != nil { + log.Error("git.Repository.GetFilesChangedBetween: %v", err) + return err + } + + userOwners, teamOwners, _, err := ParseCodeowners(ctx, repo, pr.Issue.Poster, changedFiles, codeownersContents) + if err != nil { + log.Error("ParseCodeowners: %v", err) + return nil + } + + // Any errors at this point on should not cause the process to fail. + prAuthor := pr.Issue.Poster + isAdd := true + + for _, userOwner := range userOwners { + permDoer, err := access_model.GetUserRepoPermission(ctx, repo, prAuthor) + if err != nil { + log.Error("models/perm/access/GetUserRepoPermission: %v", err) + } else { + err = IsValidReviewRequest(ctx, userOwner, prAuthor, isAdd, pr.Issue, &permDoer) + if err != nil { + log.Warn("IsValidReviewRequest: %v", err) + } else { + _, err := ReviewRequest(ctx, pr.Issue, prAuthor, userOwner, isAdd) + if err != nil { + log.Error("AddValidReviewers [repo_id: %d, issue_id: %d, pull_request_poster_user_id: %d, user_reviewer_id: %d]: "+ + "Error adding user as a reviewer to the pull request: %v", repo.ID, pr.Issue.ID, prAuthor.ID, userOwner.ID, err) + } + } + } + } + + for _, teamOwner := range teamOwners { + err := IsValidTeamReviewRequest(ctx, teamOwner, prAuthor, isAdd, pr.Issue) + if err != nil { + log.Warn("IsValidTeamReviewRequest: %v", err) + } else { + _, err := TeamReviewRequest(ctx, pr.Issue, prAuthor, teamOwner, isAdd) + if err != nil { + log.Error("AddValidReviewers [repo_id: %d, issue_id: %d, pull_request_poster_user_id: %d, team_reviewer_id: %d]: "+ + "Error adding team as a reviewer to the pull request: %v", repo.ID, pr.Issue.ID, prAuthor.ID, teamOwner.ID, err) + } + } + + } + } + + return nil +} + // GetRefEndNamesAndURLs retrieves the ref end names (e.g. refs/heads/branch-name -> branch-name) // and their respective URLs. func GetRefEndNamesAndURLs(issues []*issues_model.Issue, repoLink string) (map[int64]string, map[int64]string) { diff --git a/services/pull/pull.go b/services/pull/pull.go index 8f2befa8ffc6c..0b6171751c84f 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -66,6 +66,10 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, pull *issu prCtx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("NewPullRequest: %s:%d", repo.FullName(), pr.Index)) defer finished() + if err := issue_service.AddCodeownerReviewers(prCtx, pr, repo); err != nil { + return err + } + if pr.Flow == issues_model.PullRequestFlowGithub { err = PushToBaseRepo(prCtx, pr) } else { diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 26b863aceae5c..9cff67c3194a0 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -113,6 +113,9 @@ {{if $file.IsVendored}} {{$.locale.Tr "repo.diff.vendored"}} {{end}} +