]> source.dussan.org Git - gitea.git/commitdiff
Add "Allow edits from maintainer" feature (#18002)
authorqwerty287 <80460567+qwerty287@users.noreply.github.com>
Thu, 28 Apr 2022 15:45:33 +0000 (17:45 +0200)
committerGitHub <noreply@github.com>
Thu, 28 Apr 2022 15:45:33 +0000 (17:45 +0200)
Adds a feature [like GitHub has](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) (step 7).
If you create a new PR from a forked repo, you can select (and change later, but only if you are the PR creator/poster) the "Allow edits from maintainers" option.
Then users with write access to the base branch get more permissions on this branch:
* use the update pull request button
* push directly from the command line (`git push`)
* edit/delete/upload files via web UI
* use related API endpoints

You can't merge PRs to this branch with this enabled, you'll need "full" code write permissions.

This feature has a pretty big impact on the permission system. I might forgot changing some things or didn't find security vulnerabilities. In this case, please leave a review or comment on this PR.

Closes #17728

Co-authored-by: 6543 <6543@obermui.de>
29 files changed:
models/migrations/migrations.go
models/migrations/v213.go [new file with mode: 0644]
models/pull.go
models/repo_permission.go
modules/context/permission.go
modules/context/repo.go
modules/convert/convert.go
modules/convert/pull.go
modules/structs/pull.go
modules/structs/repo_file.go
options/locale/locale_en-US.ini
routers/api/v1/api.go
routers/api/v1/repo/file.go
routers/api/v1/repo/patch.go
routers/api/v1/repo/pull.go
routers/private/hook_pre_receive.go
routers/web/repo/compare.go
routers/web/repo/issue.go
routers/web/repo/pull.go
routers/web/repo/view.go
routers/web/web.go
services/forms/repo_form.go
services/pull/edits.go [new file with mode: 0644]
services/pull/update.go
templates/repo/issue/new_form.tmpl
templates/repo/issue/view_content/sidebar.tmpl
templates/swagger/v1_json.tmpl
web_src/js/features/repo-issue.js
web_src/js/index.js

index ba8ca85bc8d930e0d37606ace3af81ce0ed686bc..9e46791ec607b9c68a7d744db37ab770d65c30e7 100644 (file)
@@ -61,6 +61,7 @@ type Version struct {
 // update minDBVersion accordingly
 var migrations = []Migration{
        // Gitea 1.5.0 ends at v69
+
        // v70 -> v71
        NewMigration("add issue_dependencies", addIssueDependencies),
        // v71 -> v72
@@ -380,6 +381,8 @@ var migrations = []Migration{
        NewMigration("Create ForeignReference table", createForeignReferenceTable),
        // v212 -> v213
        NewMigration("Add package tables", addPackageTables),
+       // v213 -> v214
+       NewMigration("Add allow edits from maintainers to PullRequest table", addAllowMaintainerEdit),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v213.go b/models/migrations/v213.go
new file mode 100644 (file)
index 0000000..b1dbf98
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package migrations
+
+import (
+       "xorm.io/xorm"
+)
+
+func addAllowMaintainerEdit(x *xorm.Engine) error {
+       // PullRequest represents relation between pull request and repositories.
+       type PullRequest struct {
+               AllowMaintainerEdit bool `xorm:"NOT NULL DEFAULT false"`
+       }
+
+       return x.Sync2(new(PullRequest))
+}
index e04263af9ffe7b031fcbfa3116ff1e1536f5c81e..437b202d3feb41f9a3268750889d26ee02c55162 100644 (file)
@@ -69,15 +69,16 @@ type PullRequest struct {
        Issue   *Issue `xorm:"-"`
        Index   int64
 
-       HeadRepoID      int64                  `xorm:"INDEX"`
-       HeadRepo        *repo_model.Repository `xorm:"-"`
-       BaseRepoID      int64                  `xorm:"INDEX"`
-       BaseRepo        *repo_model.Repository `xorm:"-"`
-       HeadBranch      string
-       HeadCommitID    string `xorm:"-"`
-       BaseBranch      string
-       ProtectedBranch *ProtectedBranch `xorm:"-"`
-       MergeBase       string           `xorm:"VARCHAR(40)"`
+       HeadRepoID          int64                  `xorm:"INDEX"`
+       HeadRepo            *repo_model.Repository `xorm:"-"`
+       BaseRepoID          int64                  `xorm:"INDEX"`
+       BaseRepo            *repo_model.Repository `xorm:"-"`
+       HeadBranch          string
+       HeadCommitID        string `xorm:"-"`
+       BaseBranch          string
+       ProtectedBranch     *ProtectedBranch `xorm:"-"`
+       MergeBase           string           `xorm:"VARCHAR(40)"`
+       AllowMaintainerEdit bool             `xorm:"NOT NULL DEFAULT false"`
 
        HasMerged      bool               `xorm:"INDEX"`
        MergedCommitID string             `xorm:"VARCHAR(40)"`
@@ -711,6 +712,14 @@ func (pr *PullRequest) GetHeadBranchHTMLURL() string {
        return pr.HeadRepo.HTMLURL() + "/src/branch/" + util.PathEscapeSegments(pr.HeadBranch)
 }
 
+// UpdateAllowEdits update if PR can be edited from maintainers
+func UpdateAllowEdits(ctx context.Context, pr *PullRequest) error {
+       if _, err := db.GetEngine(ctx).ID(pr.ID).Cols("allow_maintainer_edit").Update(pr); err != nil {
+               return err
+       }
+       return nil
+}
+
 // Mergeable returns if the pullrequest is mergeable.
 func (pr *PullRequest) Mergeable() bool {
        // If a pull request isn't mergable if it's:
index b4291f2362d63b8253c410a355b0d99633c92844..bc31b873f2e973f64ef2a3b0c50e96eb5fdda24a 100644 (file)
@@ -103,6 +103,39 @@ func (p *Permission) CanWriteIssuesOrPulls(isPull bool) bool {
        return p.CanWrite(unit.TypeIssues)
 }
 
+// CanWriteToBranch checks if the branch is writable by the user
+func (p *Permission) CanWriteToBranch(user *user_model.User, branch string) bool {
+       if p.CanWrite(unit.TypeCode) {
+               return true
+       }
+
+       if len(p.Units) < 1 {
+               return false
+       }
+
+       prs, err := GetUnmergedPullRequestsByHeadInfo(p.Units[0].RepoID, branch)
+       if err != nil {
+               return false
+       }
+
+       for _, pr := range prs {
+               if pr.AllowMaintainerEdit {
+                       err = pr.LoadBaseRepo()
+                       if err != nil {
+                               continue
+                       }
+                       prPerm, err := GetUserRepoPermission(db.DefaultContext, pr.BaseRepo, user)
+                       if err != nil {
+                               continue
+                       }
+                       if prPerm.CanWrite(unit.TypeCode) {
+                               return true
+                       }
+               }
+       }
+       return false
+}
+
 // ColorFormat writes a colored string for these Permissions
 func (p *Permission) ColorFormat(s fmt.State) {
        noColor := log.ColorBytes(log.Reset)
@@ -160,6 +193,7 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
                                perm)
                }()
        }
+
        // anonymous user visit private repo.
        // TODO: anonymous user visit public unit of private repo???
        if user == nil && repo.IsPrivate {
index 142b86faeaf5e633836b624fac1cd72b47d1bbb5..8dc3b3cd46ec1795afcbf3a91bd8226f692197d8 100644 (file)
@@ -29,6 +29,16 @@ func RequireRepoWriter(unitType unit.Type) func(ctx *Context) {
        }
 }
 
+// CanEnableEditor checks if the user is allowed to write to the branch of the repo
+func CanEnableEditor() func(ctx *Context) {
+       return func(ctx *Context) {
+               if !ctx.Repo.Permission.CanWriteToBranch(ctx.Doer, ctx.Repo.BranchName) {
+                       ctx.NotFound("CanWriteToBranch denies permission", nil)
+                       return
+               }
+       }
+}
+
 // RequireRepoWriterOr returns a middleware for requiring repository write to one of the unit permission
 func RequireRepoWriterOr(unitTypes ...unit.Type) func(ctx *Context) {
        return func(ctx *Context) {
index a02bb7e8691164600e197131da63fddf53b79ef2..b2c9a21f8e54f02a2ff6af44884e0824c04c5f04 100644 (file)
@@ -78,8 +78,8 @@ type Repository struct {
 }
 
 // CanEnableEditor returns true if repository is editable and user has proper access level.
-func (r *Repository) CanEnableEditor() bool {
-       return r.Permission.CanWrite(unit_model.TypeCode) && r.Repository.CanEnableEditor() && r.IsViewBranch && !r.Repository.IsArchived
+func (r *Repository) CanEnableEditor(user *user_model.User) bool {
+       return r.IsViewBranch && r.Permission.CanWriteToBranch(user, r.BranchName) && r.Repository.CanEnableEditor() && !r.Repository.IsArchived
 }
 
 // CanCreateBranch returns true if repository is editable and user has proper access level.
@@ -123,7 +123,7 @@ func (r *Repository) CanCommitToBranch(ctx context.Context, doer *user_model.Use
 
        sign, keyID, _, err := asymkey_service.SignCRUDAction(ctx, r.Repository.RepoPath(), doer, r.Repository.RepoPath(), git.BranchPrefix+r.BranchName)
 
-       canCommit := r.CanEnableEditor() && userCanPush
+       canCommit := r.CanEnableEditor(doer) && userCanPush
        if requireSigned {
                canCommit = canCommit && sign
        }
@@ -139,7 +139,7 @@ func (r *Repository) CanCommitToBranch(ctx context.Context, doer *user_model.Use
 
        return CanCommitToBranchResults{
                CanCommitToBranch: canCommit,
-               EditorEnabled:     r.CanEnableEditor(),
+               EditorEnabled:     r.CanEnableEditor(doer),
                UserCanPush:       userCanPush,
                RequireSigned:     requireSigned,
                WillSign:          sign,
index 3f565f76e087c690b7fcc9b786b125b6b000ef28..bd06f4dbf4bb3cf4b2c647d43b5b1b6f65e3d60e 100644 (file)
@@ -41,12 +41,19 @@ func ToEmail(email *user_model.EmailAddress) *api.Email {
 func ToBranch(repo *repo_model.Repository, b *git.Branch, c *git.Commit, bp *models.ProtectedBranch, user *user_model.User, isRepoAdmin bool) (*api.Branch, error) {
        if bp == nil {
                var hasPerm bool
+               var canPush bool
                var err error
                if user != nil {
                        hasPerm, err = models.HasAccessUnit(user, repo, unit.TypeCode, perm.AccessModeWrite)
                        if err != nil {
                                return nil, err
                        }
+
+                       perms, err := models.GetUserRepoPermission(db.DefaultContext, repo, user)
+                       if err != nil {
+                               return nil, err
+                       }
+                       canPush = perms.CanWriteToBranch(user, b.Name)
                }
 
                return &api.Branch{
@@ -56,7 +63,7 @@ func ToBranch(repo *repo_model.Repository, b *git.Branch, c *git.Commit, bp *mod
                        RequiredApprovals:   0,
                        EnableStatusCheck:   false,
                        StatusCheckContexts: []string{},
-                       UserCanPush:         hasPerm,
+                       UserCanPush:         canPush,
                        UserCanMerge:        hasPerm,
                }, nil
        }
index 6034327a9d25fced87778c0a6fcf22f09b95817a..a2f54270e4ffe1f34bc841ff62eee66df3cf2796 100644 (file)
@@ -73,6 +73,8 @@ func ToAPIPullRequest(ctx context.Context, pr *models.PullRequest, doer *user_mo
                Created:   pr.Issue.CreatedUnix.AsTimePtr(),
                Updated:   pr.Issue.UpdatedUnix.AsTimePtr(),
 
+               AllowMaintainerEdit: pr.AllowMaintainerEdit,
+
                Base: &api.PRBranchInfo{
                        Name:       pr.BaseBranch,
                        Ref:        pr.BaseBranch,
index 653091b2f432184869c362ca48c2c745144ca353..b63b3edfd30a42b762aa20874220dbca38f0ca33 100644 (file)
@@ -31,9 +31,10 @@ type PullRequest struct {
        Mergeable bool `json:"mergeable"`
        HasMerged bool `json:"merged"`
        // swagger:strfmt date-time
-       Merged         *time.Time `json:"merged_at"`
-       MergedCommitID *string    `json:"merge_commit_sha"`
-       MergedBy       *User      `json:"merged_by"`
+       Merged              *time.Time `json:"merged_at"`
+       MergedCommitID      *string    `json:"merge_commit_sha"`
+       MergedBy            *User      `json:"merged_by"`
+       AllowMaintainerEdit bool       `json:"allow_maintainer_edit"`
 
        Base      *PRBranchInfo `json:"base"`
        Head      *PRBranchInfo `json:"head"`
@@ -90,6 +91,7 @@ type EditPullRequestOption struct {
        Labels    []int64  `json:"labels"`
        State     *string  `json:"state"`
        // swagger:strfmt date-time
-       Deadline       *time.Time `json:"due_date"`
-       RemoveDeadline *bool      `json:"unset_due_date"`
+       Deadline            *time.Time `json:"due_date"`
+       RemoveDeadline      *bool      `json:"unset_due_date"`
+       AllowMaintainerEdit *bool      `json:"allow_maintainer_edit"`
 }
index e2947bf7ac7b047e418fc2ba353613793ef11ec4..135e6484cd69bde75dc7895ae869d7de10fd28c9 100644 (file)
@@ -30,6 +30,11 @@ type CreateFileOptions struct {
        Content string `json:"content"`
 }
 
+// Branch returns branch name
+func (o *CreateFileOptions) Branch() string {
+       return o.FileOptions.BranchName
+}
+
 // DeleteFileOptions options for deleting files (used for other File structs below)
 // Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
 type DeleteFileOptions struct {
@@ -39,6 +44,11 @@ type DeleteFileOptions struct {
        SHA string `json:"sha" binding:"Required"`
 }
 
+// Branch returns branch name
+func (o *DeleteFileOptions) Branch() string {
+       return o.FileOptions.BranchName
+}
+
 // UpdateFileOptions options for updating files
 // Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
 type UpdateFileOptions struct {
@@ -50,6 +60,16 @@ type UpdateFileOptions struct {
        FromPath string `json:"from_path" binding:"MaxSize(500)"`
 }
 
+// Branch returns branch name
+func (o *UpdateFileOptions) Branch() string {
+       return o.FileOptions.BranchName
+}
+
+// FileOptionInterface provides a unified interface for the different file options
+type FileOptionInterface interface {
+       Branch() string
+}
+
 // ApplyDiffPatchFileOptions options for applying a diff patch
 // Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
 type ApplyDiffPatchFileOptions struct {
index 5ec001b6d46cbd5de77e311fb19b803557c30f94..0175c8bfc8b00eb3d84075969111c24100b6bc99 100644 (file)
@@ -1488,6 +1488,9 @@ pulls.desc = Enable pull requests and code reviews.
 pulls.new = New Pull Request
 pulls.view = View Pull Request
 pulls.compare_changes = New Pull Request
+pulls.allow_edits_from_maintainers = Allow edits from maintainers
+pulls.allow_edits_from_maintainers_desc = Users with write access to the base branch can also push to this branch
+pulls.allow_edits_from_maintainers_err = Updating failed
 pulls.compare_changes_desc = Select the branch to merge into and the branch to pull from.
 pulls.compare_base = merge into
 pulls.compare_compare = pull from
index cca0f37ba107f894b943ff6bc609b0b2a4f11a6a..782500e6c86f73ee10c4f76a81149775667cc61c 100644 (file)
@@ -283,6 +283,15 @@ func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) {
        }
 }
 
+// reqRepoBranchWriter user should have a permission to write to a branch, or be a site admin
+func reqRepoBranchWriter(ctx *context.APIContext) {
+       options, ok := web.GetForm(ctx).(api.FileOptionInterface)
+       if !ok || (!ctx.Repo.CanWriteToBranch(ctx.Doer, options.Branch()) && !ctx.IsUserSiteAdmin()) {
+               ctx.Error(http.StatusForbidden, "reqRepoBranchWriter", "user should have a permission to write to this branch")
+               return
+       }
+}
+
 // reqRepoReader user should have specific read permission or be a repo admin or a site admin
 func reqRepoReader(unitType unit.Type) func(ctx *context.APIContext) {
        return func(ctx *context.APIContext) {
@@ -1021,10 +1030,10 @@ func Routes() *web.Route {
                                        m.Get("", repo.GetContentsList)
                                        m.Get("/*", repo.GetContents)
                                        m.Group("/*", func() {
-                                               m.Post("", bind(api.CreateFileOptions{}), repo.CreateFile)
-                                               m.Put("", bind(api.UpdateFileOptions{}), repo.UpdateFile)
-                                               m.Delete("", bind(api.DeleteFileOptions{}), repo.DeleteFile)
-                                       }, reqRepoWriter(unit.TypeCode), reqToken())
+                                               m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, repo.CreateFile)
+                                               m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, repo.UpdateFile)
+                                               m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, repo.DeleteFile)
+                                       }, reqToken())
                                }, reqRepoReader(unit.TypeCode))
                                m.Get("/signing-key.gpg", misc.SigningKey)
                                m.Group("/topics", func() {
index d907e770ae117fe3bec51a62a1c525881af80ae3..b8b72f95ebabe32e1a49c039f558fcb46563faa7 100644 (file)
@@ -173,8 +173,10 @@ func GetEditorconfig(ctx *context.APIContext) {
 }
 
 // canWriteFiles returns true if repository is editable and user has proper access level.
-func canWriteFiles(r *context.Repository) bool {
-       return r.Permission.CanWrite(unit.TypeCode) && !r.Repository.IsMirror && !r.Repository.IsArchived
+func canWriteFiles(ctx *context.APIContext, branch string) bool {
+       return ctx.Repo.Permission.CanWriteToBranch(ctx.Doer, branch) &&
+               !ctx.Repo.Repository.IsMirror &&
+               !ctx.Repo.Repository.IsArchived
 }
 
 // canReadFiles returns true if repository is readable and user has proper access level.
@@ -376,7 +378,7 @@ func handleCreateOrUpdateFileError(ctx *context.APIContext, err error) {
 
 // Called from both CreateFile or UpdateFile to handle both
 func createOrUpdateFile(ctx *context.APIContext, opts *files_service.UpdateRepoFileOptions) (*api.FileResponse, error) {
-       if !canWriteFiles(ctx.Repo) {
+       if !canWriteFiles(ctx, opts.OldBranch) {
                return nil, models.ErrUserDoesNotHaveAccessToRepo{
                        UserID:   ctx.Doer.ID,
                        RepoName: ctx.Repo.Repository.LowerName,
@@ -433,7 +435,7 @@ func DeleteFile(ctx *context.APIContext) {
        //     "$ref": "#/responses/error"
 
        apiOpts := web.GetForm(ctx).(*api.DeleteFileOptions)
-       if !canWriteFiles(ctx.Repo) {
+       if !canWriteFiles(ctx, apiOpts.BranchName) {
                ctx.Error(http.StatusForbidden, "DeleteFile", models.ErrUserDoesNotHaveAccessToRepo{
                        UserID:   ctx.Doer.ID,
                        RepoName: ctx.Repo.Repository.LowerName,
index ae64c6efe3aa373b25e62c8971ba94fe6b35a32d..6dbf979701fc5fd18008e69011fc83ac8d1dbc81 100644 (file)
@@ -77,7 +77,7 @@ func ApplyDiffPatch(ctx *context.APIContext) {
                opts.Message = "apply-patch"
        }
 
-       if !canWriteFiles(ctx.Repo) {
+       if !canWriteFiles(ctx, apiOpts.BranchName) {
                ctx.Error(http.StatusInternalServerError, "ApplyPatch", models.ErrUserDoesNotHaveAccessToRepo{
                        UserID:   ctx.Doer.ID,
                        RepoName: ctx.Repo.Repository.LowerName,
index 6b076eff8fad380007c00f86f59f38f0c735cb0b..95178021838ca4e7a92f22269b239912501c2ad6 100644 (file)
@@ -616,6 +616,18 @@ func EditPullRequest(ctx *context.APIContext) {
                notification.NotifyPullRequestChangeTargetBranch(ctx.Doer, pr, form.Base)
        }
 
+       // update allow edits
+       if form.AllowMaintainerEdit != nil {
+               if err := pull_service.SetAllowEdits(ctx, ctx.Doer, pr, *form.AllowMaintainerEdit); err != nil {
+                       if errors.Is(pull_service.ErrUserHasNoPermissionForAction, err) {
+                               ctx.Error(http.StatusForbidden, "SetAllowEdits", fmt.Sprintf("SetAllowEdits: %s", err))
+                               return
+                       }
+                       ctx.ServerError("SetAllowEdits", err)
+                       return
+               }
+       }
+
        // Refetch from database
        pr, err = models.GetPullRequestByIndex(ctx.Repo.Repository.ID, pr.Index)
        if err != nil {
index ccb6933787a1b7bad09e9940ae2afd347edea378..8824d9cc3984176bb608711afe414cf10ec2c972 100644 (file)
@@ -45,6 +45,8 @@ type preReceiveContext struct {
        env []string
 
        opts *private.HookOptions
+
+       branchName string
 }
 
 // CanWriteCode returns true if pusher can write code
@@ -53,7 +55,7 @@ func (ctx *preReceiveContext) CanWriteCode() bool {
                if !ctx.loadPusherAndPermission() {
                        return false
                }
-               ctx.canWriteCode = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
+               ctx.canWriteCode = ctx.userPerm.CanWriteToBranch(ctx.user, ctx.branchName) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
                ctx.checkedCanWriteCode = true
        }
        return ctx.canWriteCode
@@ -134,13 +136,15 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
 }
 
 func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID, refFullName string) {
+       branchName := strings.TrimPrefix(refFullName, git.BranchPrefix)
+       ctx.branchName = branchName
+
        if !ctx.AssertCanWriteCode() {
                return
        }
 
        repo := ctx.Repo.Repository
        gitRepo := ctx.Repo.GitRepo
-       branchName := strings.TrimPrefix(refFullName, git.BranchPrefix)
 
        if branchName == repo.DefaultBranch && newCommitID == git.EmptySHA {
                log.Warn("Forbidden: Branch: %s is the default branch in %-v and cannot be deleted", branchName, repo)
index 9f7bef43ef7ebc506cd2192165f14ca6480538d3..7721507baea936e8aa012e860697acbca2249060 100644 (file)
@@ -398,6 +398,7 @@ func ParseCompareInfo(ctx *context.Context) *CompareInfo {
        }
 
        ctx.Data["HeadRepo"] = ci.HeadRepo
+       ctx.Data["BaseCompareRepo"] = ctx.Repo.Repository
 
        // Now we need to assert that the ctx.Doer has permission to read
        // the baseRepo's code and pulls
@@ -436,6 +437,7 @@ func ParseCompareInfo(ctx *context.Context) *CompareInfo {
                        ctx.NotFound("ParseCompareInfo", nil)
                        return nil
                }
+               ctx.Data["CanWriteToHeadRepo"] = permHead.CanWrite(unit.TypeCode)
        }
 
        // If we have a rootRepo and it's different from:
index 8e865e448fc185c4416a2dd46b897eb380a8760b..a54ad3306bb3b646cd1056fa5c4942078001adaf 100644 (file)
@@ -1529,7 +1529,7 @@ func ViewIssue(ctx *context.Context) {
                if ctx.IsSigned {
                        if err := pull.LoadHeadRepoCtx(ctx); err != nil {
                                log.Error("LoadHeadRepo: %v", err)
-                       } else if pull.HeadRepo != nil && pull.HeadBranch != pull.HeadRepo.DefaultBranch {
+                       } else if pull.HeadRepo != nil {
                                perm, err := models.GetUserRepoPermission(ctx, pull.HeadRepo, ctx.Doer)
                                if err != nil {
                                        ctx.ServerError("GetUserRepoPermission", err)
@@ -1537,12 +1537,15 @@ func ViewIssue(ctx *context.Context) {
                                }
                                if perm.CanWrite(unit.TypeCode) {
                                        // Check if branch is not protected
-                                       if protected, err := models.IsProtectedBranch(pull.HeadRepo.ID, pull.HeadBranch); err != nil {
-                                               log.Error("IsProtectedBranch: %v", err)
-                                       } else if !protected {
-                                               canDelete = true
-                                               ctx.Data["DeleteBranchLink"] = issue.Link() + "/cleanup"
+                                       if pull.HeadBranch != pull.HeadRepo.DefaultBranch {
+                                               if protected, err := models.IsProtectedBranch(pull.HeadRepo.ID, pull.HeadBranch); err != nil {
+                                                       log.Error("IsProtectedBranch: %v", err)
+                                               } else if !protected {
+                                                       canDelete = true
+                                                       ctx.Data["DeleteBranchLink"] = issue.Link() + "/cleanup"
+                                               }
                                        }
+                                       ctx.Data["CanWriteToHeadRepo"] = true
                                }
                        }
 
index a03e16f39a2b3e3a36bb5035c812334fed6971a6..99faa5413828a365a2eeec8a7fb95d38106892bd 100644 (file)
@@ -1132,14 +1132,15 @@ func CompareAndPullRequestPost(ctx *context.Context) {
                Content:     form.Content,
        }
        pullRequest := &models.PullRequest{
-               HeadRepoID: ci.HeadRepo.ID,
-               BaseRepoID: repo.ID,
-               HeadBranch: ci.HeadBranch,
-               BaseBranch: ci.BaseBranch,
-               HeadRepo:   ci.HeadRepo,
-               BaseRepo:   repo,
-               MergeBase:  ci.CompareInfo.MergeBase,
-               Type:       models.PullRequestGitea,
+               HeadRepoID:          ci.HeadRepo.ID,
+               BaseRepoID:          repo.ID,
+               HeadBranch:          ci.HeadBranch,
+               BaseBranch:          ci.BaseBranch,
+               HeadRepo:            ci.HeadRepo,
+               BaseRepo:            repo,
+               MergeBase:           ci.CompareInfo.MergeBase,
+               Type:                models.PullRequestGitea,
+               AllowMaintainerEdit: form.AllowMaintainerEdit,
        }
        // FIXME: check error in the case two people send pull request at almost same time, give nice error prompt
        // instead of 500.
@@ -1411,3 +1412,31 @@ func UpdatePullRequestTarget(ctx *context.Context) {
                "base_branch": pr.BaseBranch,
        })
 }
+
+// SetAllowEdits allow edits from maintainers to PRs
+func SetAllowEdits(ctx *context.Context) {
+       form := web.GetForm(ctx).(*forms.UpdateAllowEditsForm)
+
+       pr, err := models.GetPullRequestByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+       if err != nil {
+               if models.IsErrPullRequestNotExist(err) {
+                       ctx.NotFound("GetPullRequestByIndex", err)
+               } else {
+                       ctx.ServerError("GetPullRequestByIndex", err)
+               }
+               return
+       }
+
+       if err := pull_service.SetAllowEdits(ctx, ctx.Doer, pr, form.AllowMaintainerEdit); err != nil {
+               if errors.Is(pull_service.ErrUserHasNoPermissionForAction, err) {
+                       ctx.Error(http.StatusForbidden)
+                       return
+               }
+               ctx.ServerError("SetAllowEdits", err)
+               return
+       }
+
+       ctx.JSON(http.StatusOK, map[string]interface{}{
+               "allow_maintainer_edit": pr.AllowMaintainerEdit,
+       })
+}
index 168927d101ae4d6a8261be29872db2c69cfc45d7..86fc36fad7c8c69c965592db0194cda4747956f1 100644 (file)
@@ -579,7 +579,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
                        ctx.Data["LineEscapeStatus"] = statuses
                }
                if !isLFSFile {
-                       if ctx.Repo.CanEnableEditor() {
+                       if ctx.Repo.CanEnableEditor(ctx.Doer) {
                                if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID {
                                        ctx.Data["CanEditFile"] = false
                                        ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked")
@@ -589,7 +589,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
                                }
                        } else if !ctx.Repo.IsViewBranch {
                                ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch")
-                       } else if !ctx.Repo.CanWrite(unit_model.TypeCode) {
+                       } else if !ctx.Repo.CanWriteToBranch(ctx.Doer, ctx.Repo.BranchName) {
                                ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
                        }
                }
@@ -629,7 +629,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
                }
        }
 
-       if ctx.Repo.CanEnableEditor() {
+       if ctx.Repo.CanEnableEditor(ctx.Doer) {
                if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID {
                        ctx.Data["CanDeleteFile"] = false
                        ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.this_file_locked")
@@ -639,7 +639,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
                }
        } else if !ctx.Repo.IsViewBranch {
                ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch")
-       } else if !ctx.Repo.CanWrite(unit_model.TypeCode) {
+       } else if !ctx.Repo.CanWriteToBranch(ctx.Doer, ctx.Repo.BranchName) {
                ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access")
        }
 }
index 0de6f1372275c9de282a51ed6d5b26252f4e749a..22b8e7cdf3abc3b2ed9c0a46604de90c91541122 100644 (file)
@@ -574,6 +574,7 @@ func RegisterRoutes(m *web.Route) {
 
        reqRepoAdmin := context.RequireRepoAdmin()
        reqRepoCodeWriter := context.RequireRepoWriter(unit.TypeCode)
+       canEnableEditor := context.CanEnableEditor()
        reqRepoCodeReader := context.RequireRepoReader(unit.TypeCode)
        reqRepoReleaseWriter := context.RequireRepoWriter(unit.TypeReleases)
        reqRepoReleaseReader := context.RequireRepoReader(unit.TypeReleases)
@@ -925,12 +926,12 @@ func RegisterRoutes(m *web.Route) {
                                        Post(bindIgnErr(forms.EditRepoFileForm{}), repo.NewDiffPatchPost)
                                m.Combo("/_cherrypick/{sha:([a-f0-9]{7,40})}/*").Get(repo.CherryPick).
                                        Post(bindIgnErr(forms.CherryPickForm{}), repo.CherryPickPost)
-                       }, context.RepoRefByType(context.RepoRefBranch), repo.MustBeEditable)
+                       }, repo.MustBeEditable)
                        m.Group("", func() {
                                m.Post("/upload-file", repo.UploadFileToServer)
                                m.Post("/upload-remove", bindIgnErr(forms.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer)
-                       }, context.RepoRef(), repo.MustBeEditable, repo.MustBeAbleToUpload)
-               }, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty)
+                       }, repo.MustBeEditable, repo.MustBeAbleToUpload)
+               }, context.RepoRef(), canEnableEditor, context.RepoMustNotBeArchived(), repo.MustBeNotEmpty)
 
                m.Group("/branches", func() {
                        m.Group("/_new", func() {
@@ -1105,6 +1106,7 @@ func RegisterRoutes(m *web.Route) {
                        m.Get("/commits", context.RepoRef(), repo.ViewPullCommits)
                        m.Post("/merge", context.RepoMustNotBeArchived(), bindIgnErr(forms.MergePullRequestForm{}), repo.MergePullRequest)
                        m.Post("/update", repo.UpdatePullRequest)
+                       m.Post("/set_allow_maintainer_edit", bindIgnErr(forms.UpdateAllowEditsForm{}), repo.SetAllowEdits)
                        m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest)
                        m.Group("/files", func() {
                                m.Get("", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.ViewPullFiles)
index 80123e9af3228e6016062f1d49b25084802369a8..f40e99a0440a577f16618ca5be76eb47ee270bff 100644 (file)
@@ -422,15 +422,16 @@ func (f *NewPackagistHookForm) Validate(req *http.Request, errs binding.Errors)
 
 // CreateIssueForm form for creating issue
 type CreateIssueForm struct {
-       Title       string `binding:"Required;MaxSize(255)"`
-       LabelIDs    string `form:"label_ids"`
-       AssigneeIDs string `form:"assignee_ids"`
-       Ref         string `form:"ref"`
-       MilestoneID int64
-       ProjectID   int64
-       AssigneeID  int64
-       Content     string
-       Files       []string
+       Title               string `binding:"Required;MaxSize(255)"`
+       LabelIDs            string `form:"label_ids"`
+       AssigneeIDs         string `form:"assignee_ids"`
+       Ref                 string `form:"ref"`
+       MilestoneID         int64
+       ProjectID           int64
+       AssigneeID          int64
+       Content             string
+       Files               []string
+       AllowMaintainerEdit bool
 }
 
 // Validate validates the fields
@@ -684,6 +685,11 @@ type DismissReviewForm struct {
        Message  string
 }
 
+// UpdateAllowEditsForm form for changing if PR allows edits from maintainers
+type UpdateAllowEditsForm struct {
+       AllowMaintainerEdit bool
+}
+
 // __________       .__
 // \______   \ ____ |  |   ____ _____    ______ ____
 //  |       _// __ \|  | _/ __ \\__  \  /  ___// __ \
diff --git a/services/pull/edits.go b/services/pull/edits.go
new file mode 100644 (file)
index 0000000..68515ec
--- /dev/null
@@ -0,0 +1,40 @@
+// Copyright 2022 The Gitea Authors.
+// All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package pull
+
+import (
+       "context"
+       "errors"
+
+       "code.gitea.io/gitea/models"
+       unit_model "code.gitea.io/gitea/models/unit"
+       user_model "code.gitea.io/gitea/models/user"
+)
+
+var ErrUserHasNoPermissionForAction = errors.New("user not allowed to do this action")
+
+// SetAllowEdits allow edits from maintainers to PRs
+func SetAllowEdits(ctx context.Context, doer *user_model.User, pr *models.PullRequest, allow bool) error {
+       if doer == nil || !pr.Issue.IsPoster(doer.ID) {
+               return ErrUserHasNoPermissionForAction
+       }
+
+       if err := pr.LoadHeadRepo(); err != nil {
+               return err
+       }
+
+       permission, err := models.GetUserRepoPermission(ctx, pr.HeadRepo, doer)
+       if err != nil {
+               return err
+       }
+
+       if !permission.CanWrite(unit_model.TypeCode) {
+               return ErrUserHasNoPermissionForAction
+       }
+
+       pr.AllowMaintainerEdit = allow
+       return models.UpdateAllowEdits(ctx, pr)
+}
index 899bee1f19770b53a2fb07d174e00eab73a8938f..2d845e8504df09a439b862dfb8beb44a87623602 100644 (file)
@@ -111,11 +111,25 @@ func IsUserAllowedToUpdate(ctx context.Context, pull *models.PullRequest, user *
                return false, false, nil
        }
 
+       baseRepoPerm, err := models.GetUserRepoPermission(ctx, pull.BaseRepo, user)
+       if err != nil {
+               return false, false, err
+       }
+
        mergeAllowed, err = IsUserAllowedToMerge(pr, headRepoPerm, user)
        if err != nil {
                return false, false, err
        }
 
+       if pull.AllowMaintainerEdit {
+               mergeAllowedMaintainer, err := IsUserAllowedToMerge(pr, baseRepoPerm, user)
+               if err != nil {
+                       return false, false, err
+               }
+
+               mergeAllowed = mergeAllowed || mergeAllowedMaintainer
+       }
+
        return mergeAllowed, rebaseAllowed, nil
 }
 
index 1089c82415ab8c50fb82edeca8335a3c2d79f8cb..9a4548643fb991d0f11421fa91bb19d2ba894cc1 100644 (file)
                                                </a>
                                        {{end}}
                                </div>
+                       {{if and .PageIsComparePull (not (eq .HeadRepo.FullName .BaseCompareRepo.FullName)) .CanWriteToHeadRepo}}
+                               <div class="ui divider"></div>
+                               <div class="inline field">
+                                       <div class="ui checkbox">
+                                               <label class="tooltip" data-content="{{.i18n.Tr "repo.pulls.allow_edits_from_maintainers_desc"}}"><strong>{{.i18n.Tr "repo.pulls.allow_edits_from_maintainers"}}</strong></label>
+                                               <input name="allow_maintainer_edit" type="checkbox">
+                                       </div>
+                               </div>
+                       {{end}}
                </div>
                <input type="hidden" name="redirect_after_creation" value="{{.redirect_after_creation}}">
        </div>
index e673add812a83312dada3a45866638c130b53179..9e9bc670a0d7a5df0674c264f5d6039e4e37b353 100644 (file)
                                </form>
                        </div>
                {{end}}
+
+               {{if and .Issue.IsPull .IsIssuePoster (not .Issue.IsClosed)}}
+                       {{if and (not (eq .Issue.PullRequest.HeadRepo.FullName .Issue.PullRequest.BaseRepo.FullName)) .CanWriteToHeadRepo}}
+                               <div class="ui divider"></div>
+                               <div class="inline field">
+                                       <div class="ui checkbox" id="allow-edits-from-maintainers"
+                                                       data-url="{{.Issue.Link}}"
+                                                       data-prompt-tip="{{.i18n.Tr "repo.pulls.allow_edits_from_maintainers_desc"}}"
+                                                       data-prompt-error="{{.i18n.Tr "repo.pulls.allow_edits_from_maintainers_err"}}"
+                                               >
+                                               <label><strong>{{.i18n.Tr "repo.pulls.allow_edits_from_maintainers"}}</strong></label>
+                                               <input type="checkbox" {{if .Issue.PullRequest.AllowMaintainerEdit}}checked{{end}}>
+                                       </div>
+                               </div>
+                       {{end}}
+               {{end}}
        </div>
 </div>
index ea03380d433b193e741ad1387c7528a57d6cd35b..d57a3a580b92f06ab089933dcc6c24d4ac664e7f 100644 (file)
       "description": "EditPullRequestOption options when modify pull request",
       "type": "object",
       "properties": {
+        "allow_maintainer_edit": {
+          "type": "boolean",
+          "x-go-name": "AllowMaintainerEdit"
+        },
         "assignee": {
           "type": "string",
           "x-go-name": "Assignee"
       "description": "PullRequest represents a pull request",
       "type": "object",
       "properties": {
+        "allow_maintainer_edit": {
+          "type": "boolean",
+          "x-go-name": "AllowMaintainerEdit"
+        },
         "assignee": {
           "$ref": "#/definitions/User"
         },
index 14b1976bbbd647810836bd6bb8e493d825d77d90..a39a704f4793a90c918def6da4b39c9ef84f4b14 100644 (file)
@@ -288,6 +288,39 @@ export function initRepoPullRequestMergeInstruction() {
   });
 }
 
+export function initRepoPullRequestAllowMaintainerEdit() {
+  const $checkbox = $('#allow-edits-from-maintainers');
+  if (!$checkbox.length) return;
+
+  const promptTip = $checkbox.attr('data-prompt-tip');
+  const promptError = $checkbox.attr('data-prompt-error');
+  $checkbox.popup({content: promptTip});
+  $checkbox.checkbox({
+    'onChange': () => {
+      const checked = $checkbox.checkbox('is checked');
+      let url = $checkbox.attr('data-url');
+      url += '/set_allow_maintainer_edit';
+      $checkbox.checkbox('set disabled');
+      $.ajax({url, type: 'POST',
+        data: {_csrf: csrfToken, allow_maintainer_edit: checked},
+        error: () => {
+          $checkbox.popup({
+            content: promptError,
+            onHidden: () => {
+              // the error popup should be shown only once, then we restore the popup to the default message
+              $checkbox.popup({content: promptTip});
+            },
+          });
+          $checkbox.popup('show');
+        },
+        complete: () => {
+          $checkbox.checkbox('set enabled');
+        },
+      });
+    },
+  });
+}
+
 export function initRepoIssueReferenceRepositorySearch() {
   $('.issue_reference_repository_search')
     .dropdown({
index f5d72aff93d4d39b45210aa557fd1bed716e6568..5b95a0d8efa88b41622e6eb5632a39664aa4e378 100644 (file)
@@ -36,6 +36,7 @@ import {
   initRepoIssueTimeTracking,
   initRepoIssueWipTitle,
   initRepoPullRequestMergeInstruction,
+  initRepoPullRequestAllowMaintainerEdit,
   initRepoPullRequestReview,
 } from './features/repo-issue.js';
 import {
@@ -158,6 +159,7 @@ $(document).ready(() => {
   initRepoMigrationStatusChecker();
   initRepoProject();
   initRepoPullRequestMergeInstruction();
+  initRepoPullRequestAllowMaintainerEdit();
   initRepoPullRequestReview();
   initRepoRelease();
   initRepoReleaseEditor();