aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--models/repo/repo.go2
-rw-r--r--options/locale/locale_en-US.ini7
-rw-r--r--routers/web/repo/editor.go159
-rw-r--r--routers/web/repo/editor_apply_patch.go8
-rw-r--r--routers/web/repo/editor_cherry_pick.go8
-rw-r--r--routers/web/repo/editor_fork.go31
-rw-r--r--routers/web/repo/editor_util.go25
-rw-r--r--routers/web/repo/fork.go18
-rw-r--r--routers/web/repo/view_file.go4
-rw-r--r--routers/web/repo/view_readme.go2
-rw-r--r--routers/web/web.go42
-rw-r--r--services/context/repo.go108
-rw-r--r--services/context/upload/upload.go19
-rw-r--r--templates/repo/editor/cherry_pick.tmpl3
-rw-r--r--templates/repo/editor/commit_form.tmpl18
-rw-r--r--templates/repo/editor/common_top.tmpl6
-rw-r--r--templates/repo/editor/delete.tmpl3
-rw-r--r--templates/repo/editor/edit.tmpl3
-rw-r--r--templates/repo/editor/fork.tmpl18
-rw-r--r--templates/repo/editor/patch.tmpl3
-rw-r--r--templates/repo/editor/upload.tmpl3
-rw-r--r--templates/repo/view_content.tmpl4
-rw-r--r--tests/integration/editor_test.go627
-rw-r--r--tests/integration/html_helper.go2
-rw-r--r--tests/integration/pull_status_test.go2
-rw-r--r--web_src/js/features/common-fetch-action.ts34
26 files changed, 740 insertions, 419 deletions
diff --git a/models/repo/repo.go b/models/repo/repo.go
index 5aae02c6d8..34d1bf55f6 100644
--- a/models/repo/repo.go
+++ b/models/repo/repo.go
@@ -653,7 +653,7 @@ func (repo *Repository) AllowsPulls(ctx context.Context) bool {
// CanEnableEditor returns true if repository meets the requirements of web editor.
func (repo *Repository) CanEnableEditor() bool {
- return !repo.IsMirror
+ return !repo.IsMirror && !repo.IsArchived
}
// DescriptionHTML does special handles to description and return HTML string.
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 4d1f7e7bf4..dc50671335 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1399,6 +1399,13 @@ editor.revert = Revert %s onto:
editor.failed_to_commit = Failed to commit changes.
editor.failed_to_commit_summary = Error Message:
+editor.fork_create = Fork Repository to Propose Changes
+editor.fork_create_description = You can not edit this repository directly. Instead you can create a fork, make edits and create a pull request.
+editor.fork_edit_description = You can not edit this repository directly. The changes will be written to your fork <b>%s</b>, so you can create a pull request.
+editor.fork_not_editable = You have forked this repository but your fork is not editable.
+editor.fork_failed_to_push_branch = Failed to push branch %s to your repository.
+editor.fork_branch_exists = Branch "%s" already exists in your fork, please choose a new branch name.
+
commits.desc = Browse source code change history.
commits.commits = Commits
commits.no_commits = No commits in common. "%s" and "%s" have entirely different histories.
diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go
index e8ad3cceb5..ae0b74b019 100644
--- a/routers/web/repo/editor.go
+++ b/routers/web/repo/editor.go
@@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httplib"
+ "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
@@ -39,7 +40,7 @@ const (
editorCommitChoiceNewBranch string = "commit-to-new-branch"
)
-func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) {
+func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions {
cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
if cleanedTreePath != ctx.Repo.TreePath {
redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath))
@@ -47,18 +48,28 @@ func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) {
redirectTo += "?" + ctx.Req.URL.RawQuery
}
ctx.Redirect(redirectTo)
- return
+ return nil
}
- commitFormBehaviors, err := ctx.Repo.PrepareCommitFormBehaviors(ctx, ctx.Doer)
+ commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName)
if err != nil {
- ctx.ServerError("PrepareCommitFormBehaviors", err)
- return
+ ctx.ServerError("PrepareCommitFormOptions", err)
+ return nil
+ }
+
+ if commitFormOptions.NeedFork {
+ ForkToEdit(ctx)
+ return nil
+ }
+
+ if commitFormOptions.WillSubmitToFork && !commitFormOptions.TargetRepo.CanEnableEditor() {
+ ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.editor.fork_not_editable")
+ ctx.NotFound(nil)
}
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.Data["TreePath"] = ctx.Repo.TreePath
- ctx.Data["CommitFormBehaviors"] = commitFormBehaviors
+ ctx.Data["CommitFormOptions"] = commitFormOptions
// for online editor
ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",")
@@ -69,25 +80,27 @@ func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) {
// form fields
ctx.Data["commit_summary"] = ""
ctx.Data["commit_message"] = ""
- ctx.Data["commit_choice"] = util.Iif(commitFormBehaviors.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch)
- ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, ctx.Repo.Repository)
+ ctx.Data["commit_choice"] = util.Iif(commitFormOptions.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch)
+ ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, commitFormOptions.TargetRepo)
ctx.Data["last_commit"] = ctx.Repo.CommitID
+ return commitFormOptions
}
func prepareTreePathFieldsAndPaths(ctx *context.Context, treePath string) {
// show the tree path fields in the "breadcrumb" and help users to edit the target tree path
- ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(treePath)
+ ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(strings.TrimPrefix(treePath, "/"))
}
-type parsedEditorCommitForm[T any] struct {
- form T
- commonForm *forms.CommitCommonForm
- CommitFormBehaviors *context.CommitFormBehaviors
- TargetBranchName string
- GitCommitter *files_service.IdentityOptions
+type preparedEditorCommitForm[T any] struct {
+ form T
+ commonForm *forms.CommitCommonForm
+ CommitFormOptions *context.CommitFormOptions
+ OldBranchName string
+ NewBranchName string
+ GitCommitter *files_service.IdentityOptions
}
-func (f *parsedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string {
+func (f *preparedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string {
commitMessage := util.IfZero(strings.TrimSpace(f.commonForm.CommitSummary), defaultCommitMessage)
if body := strings.TrimSpace(f.commonForm.CommitMessage); body != "" {
commitMessage += "\n\n" + body
@@ -95,7 +108,7 @@ func (f *parsedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string
return commitMessage
}
-func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *parsedEditorCommitForm[T] {
+func prepareEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *preparedEditorCommitForm[T] {
form := web.GetForm(ctx).(T)
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
@@ -105,15 +118,22 @@ func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *cont
commonForm := form.GetCommitCommonForm()
commonForm.TreePath = files_service.CleanGitTreePath(commonForm.TreePath)
- commitFormBehaviors, err := ctx.Repo.PrepareCommitFormBehaviors(ctx, ctx.Doer)
+ commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName)
if err != nil {
- ctx.ServerError("PrepareCommitFormBehaviors", err)
+ ctx.ServerError("PrepareCommitFormOptions", err)
+ return nil
+ }
+ if commitFormOptions.NeedFork {
+ // It shouldn't happen, because we should have done the checks in the "GET" request. But just in case.
+ ctx.JSONError(ctx.Locale.TrString("error.not_found"))
return nil
}
// check commit behavior
- targetBranchName := util.Iif(commonForm.CommitChoice == editorCommitChoiceNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName)
- if targetBranchName == ctx.Repo.BranchName && !commitFormBehaviors.CanCommitToBranch {
+ fromBaseBranch := ctx.FormString("from_base_branch")
+ commitToNewBranch := commonForm.CommitChoice == editorCommitChoiceNewBranch || fromBaseBranch != ""
+ targetBranchName := util.Iif(commitToNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName)
+ if targetBranchName == ctx.Repo.BranchName && !commitFormOptions.CanCommitToBranch {
ctx.JSONError(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", targetBranchName))
return nil
}
@@ -125,28 +145,63 @@ func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *cont
return nil
}
- return &parsedEditorCommitForm[T]{
- form: form,
- commonForm: commonForm,
- CommitFormBehaviors: commitFormBehaviors,
- TargetBranchName: targetBranchName,
- GitCommitter: gitCommitter,
+ if commitToNewBranch {
+ // if target branch exists, we should stop
+ targetBranchExists, err := git_model.IsBranchExist(ctx, commitFormOptions.TargetRepo.ID, targetBranchName)
+ if err != nil {
+ ctx.ServerError("IsBranchExist", err)
+ return nil
+ } else if targetBranchExists {
+ if fromBaseBranch != "" {
+ ctx.JSONError(ctx.Tr("repo.editor.fork_branch_exists", targetBranchName))
+ } else {
+ ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", targetBranchName))
+ }
+ return nil
+ }
+ }
+
+ oldBranchName := ctx.Repo.BranchName
+ if fromBaseBranch != "" {
+ err = editorPushBranchToForkedRepository(ctx, ctx.Doer, ctx.Repo.Repository.BaseRepo, fromBaseBranch, commitFormOptions.TargetRepo, targetBranchName)
+ if err != nil {
+ log.Error("Unable to editorPushBranchToForkedRepository: %v", err)
+ ctx.JSONError(ctx.Tr("repo.editor.fork_failed_to_push_branch", targetBranchName))
+ return nil
+ }
+ // we have pushed the base branch as the new branch, now we need to commit the changes directly to the new branch
+ oldBranchName = targetBranchName
+ }
+
+ return &preparedEditorCommitForm[T]{
+ form: form,
+ commonForm: commonForm,
+ CommitFormOptions: commitFormOptions,
+ OldBranchName: oldBranchName,
+ NewBranchName: targetBranchName,
+ GitCommitter: gitCommitter,
}
}
// redirectForCommitChoice redirects after committing the edit to a branch
-func redirectForCommitChoice[T any](ctx *context.Context, parsed *parsedEditorCommitForm[T], treePath string) {
+func redirectForCommitChoice[T any](ctx *context.Context, parsed *preparedEditorCommitForm[T], treePath string) {
+ // when editing a file in a PR, it should return to the origin location
+ if returnURI := ctx.FormString("return_uri"); returnURI != "" && httplib.IsCurrentGiteaSiteURL(ctx, returnURI) {
+ ctx.JSONRedirect(returnURI)
+ return
+ }
+
if parsed.commonForm.CommitChoice == editorCommitChoiceNewBranch {
// Redirect to a pull request when possible
redirectToPullRequest := false
- repo, baseBranch, headBranch := ctx.Repo.Repository, ctx.Repo.BranchName, parsed.TargetBranchName
- if repo.UnitEnabled(ctx, unit.TypePullRequests) {
- redirectToPullRequest = true
- } else if parsed.CommitFormBehaviors.CanCreateBasePullRequest {
+ repo, baseBranch, headBranch := ctx.Repo.Repository, parsed.OldBranchName, parsed.NewBranchName
+ if ctx.Repo.Repository.IsFork && parsed.CommitFormOptions.CanCreateBasePullRequest {
redirectToPullRequest = true
baseBranch = repo.BaseRepo.DefaultBranch
headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch
repo = repo.BaseRepo
+ } else if repo.UnitEnabled(ctx, unit.TypePullRequests) {
+ redirectToPullRequest = true
}
if redirectToPullRequest {
ctx.JSONRedirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch))
@@ -154,11 +209,9 @@ func redirectForCommitChoice[T any](ctx *context.Context, parsed *parsedEditorCo
}
}
- returnURI := ctx.FormString("return_uri")
- if returnURI == "" || !httplib.IsCurrentGiteaSiteURL(ctx, returnURI) {
- returnURI = util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.TargetBranchName), util.PathEscapeSegments(treePath))
- }
- ctx.JSONRedirect(returnURI)
+ // redirect to the newly updated file
+ redirectTo := util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.NewBranchName), util.PathEscapeSegments(treePath))
+ ctx.JSONRedirect(redirectTo)
}
func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.ReadCloser, fInfo *fileInfo) {
@@ -268,7 +321,7 @@ func EditFile(ctx *context.Context) {
func EditFilePost(ctx *context.Context) {
editorAction := ctx.PathParam("editor_action")
isNewFile := editorAction == "_new"
- parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
+ parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
if ctx.Written() {
return
}
@@ -292,8 +345,8 @@ func EditFilePost(ctx *context.Context) {
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: parsed.form.LastCommit,
- OldBranch: ctx.Repo.BranchName,
- NewBranch: parsed.TargetBranchName,
+ OldBranch: parsed.OldBranchName,
+ NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage),
Files: []*files_service.ChangeRepoFile{
{
@@ -308,7 +361,7 @@ func EditFilePost(ctx *context.Context) {
Committer: parsed.GitCommitter,
})
if err != nil {
- editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
+ editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return
}
@@ -327,7 +380,7 @@ func DeleteFile(ctx *context.Context) {
// DeleteFilePost response for deleting file
func DeleteFilePost(ctx *context.Context) {
- parsed := parseEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
+ parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
if ctx.Written() {
return
}
@@ -335,8 +388,8 @@ func DeleteFilePost(ctx *context.Context) {
treePath := ctx.Repo.TreePath
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: parsed.form.LastCommit,
- OldBranch: ctx.Repo.BranchName,
- NewBranch: parsed.TargetBranchName,
+ OldBranch: parsed.OldBranchName,
+ NewBranch: parsed.NewBranchName,
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
@@ -349,29 +402,29 @@ func DeleteFilePost(ctx *context.Context) {
Committer: parsed.GitCommitter,
})
if err != nil {
- editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
+ editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return
}
ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath))
- redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.TargetBranchName, treePath)
+ redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath)
redirectForCommitChoice(ctx, parsed, redirectTreePath)
}
func UploadFile(ctx *context.Context) {
ctx.Data["PageIsUpload"] = true
- upload.AddUploadContext(ctx, "repo")
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
-
- prepareEditorCommitFormOptions(ctx, "_upload")
+ opts := prepareEditorCommitFormOptions(ctx, "_upload")
if ctx.Written() {
return
}
+ upload.AddUploadContextForRepo(ctx, opts.TargetRepo)
+
ctx.HTML(http.StatusOK, tplUploadFile)
}
func UploadFilePost(ctx *context.Context) {
- parsed := parseEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx)
+ parsed := prepareEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx)
if ctx.Written() {
return
}
@@ -379,8 +432,8 @@ func UploadFilePost(ctx *context.Context) {
defaultCommitMessage := ctx.Locale.TrString("repo.editor.upload_files_to_dir", util.IfZero(parsed.form.TreePath, "/"))
err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{
LastCommitID: parsed.form.LastCommit,
- OldBranch: ctx.Repo.BranchName,
- NewBranch: parsed.TargetBranchName,
+ OldBranch: parsed.OldBranchName,
+ NewBranch: parsed.NewBranchName,
TreePath: parsed.form.TreePath,
Message: parsed.GetCommitMessage(defaultCommitMessage),
Files: parsed.form.Files,
@@ -389,7 +442,7 @@ func UploadFilePost(ctx *context.Context) {
Committer: parsed.GitCommitter,
})
if err != nil {
- editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
+ editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return
}
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
diff --git a/routers/web/repo/editor_apply_patch.go b/routers/web/repo/editor_apply_patch.go
index 9fd7a9468b..bd2811cc5f 100644
--- a/routers/web/repo/editor_apply_patch.go
+++ b/routers/web/repo/editor_apply_patch.go
@@ -25,7 +25,7 @@ func NewDiffPatch(ctx *context.Context) {
// NewDiffPatchPost response for sending patch page
func NewDiffPatchPost(ctx *context.Context) {
- parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
+ parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
if ctx.Written() {
return
}
@@ -33,8 +33,8 @@ func NewDiffPatchPost(ctx *context.Context) {
defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch")
_, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{
LastCommitID: parsed.form.LastCommit,
- OldBranch: ctx.Repo.BranchName,
- NewBranch: parsed.TargetBranchName,
+ OldBranch: parsed.OldBranchName,
+ NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage),
Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"),
Author: parsed.GitCommitter,
@@ -44,7 +44,7 @@ func NewDiffPatchPost(ctx *context.Context) {
err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch")
}
if err != nil {
- editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
+ editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return
}
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
diff --git a/routers/web/repo/editor_cherry_pick.go b/routers/web/repo/editor_cherry_pick.go
index 4c93d610cc..10c2741b1c 100644
--- a/routers/web/repo/editor_cherry_pick.go
+++ b/routers/web/repo/editor_cherry_pick.go
@@ -45,7 +45,7 @@ func CherryPick(ctx *context.Context) {
func CherryPickPost(ctx *context.Context) {
fromCommitID := ctx.PathParam("sha")
- parsed := parseEditorCommitSubmittedForm[*forms.CherryPickForm](ctx)
+ parsed := prepareEditorCommitSubmittedForm[*forms.CherryPickForm](ctx)
if ctx.Written() {
return
}
@@ -53,8 +53,8 @@ func CherryPickPost(ctx *context.Context) {
defaultCommitMessage := util.Iif(parsed.form.Revert, ctx.Locale.TrString("repo.commit.revert-header", fromCommitID), ctx.Locale.TrString("repo.commit.cherry-pick-header", fromCommitID))
opts := &files.ApplyDiffPatchOptions{
LastCommitID: parsed.form.LastCommit,
- OldBranch: ctx.Repo.BranchName,
- NewBranch: parsed.TargetBranchName,
+ OldBranch: parsed.OldBranchName,
+ NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage),
Author: parsed.GitCommitter,
Committer: parsed.GitCommitter,
@@ -78,7 +78,7 @@ func CherryPickPost(ctx *context.Context) {
}
}
if err != nil {
- editorHandleFileOperationError(ctx, parsed.TargetBranchName, err)
+ editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return
}
}
diff --git a/routers/web/repo/editor_fork.go b/routers/web/repo/editor_fork.go
new file mode 100644
index 0000000000..b78a634c00
--- /dev/null
+++ b/routers/web/repo/editor_fork.go
@@ -0,0 +1,31 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/services/context"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+const tplEditorFork templates.TplName = "repo/editor/fork"
+
+func ForkToEdit(ctx *context.Context) {
+ ctx.HTML(http.StatusOK, tplEditorFork)
+}
+
+func ForkToEditPost(ctx *context.Context) {
+ ForkRepoTo(ctx, ctx.Doer, repo_service.ForkRepoOptions{
+ BaseRepo: ctx.Repo.Repository,
+ Name: getUniqueRepositoryName(ctx, ctx.Doer.ID, ctx.Repo.Repository.Name),
+ Description: ctx.Repo.Repository.Description,
+ SingleBranch: ctx.Repo.Repository.DefaultBranch, // maybe we only need the default branch in the fork?
+ })
+ if ctx.Written() {
+ return
+ }
+ ctx.JSONRedirect("") // reload the page, the new fork should be editable now
+}
diff --git a/routers/web/repo/editor_util.go b/routers/web/repo/editor_util.go
index 8744b4479e..f910f0bd40 100644
--- a/routers/web/repo/editor_util.go
+++ b/routers/web/repo/editor_util.go
@@ -11,9 +11,11 @@ import (
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
+ repo_module "code.gitea.io/gitea/modules/repository"
context_service "code.gitea.io/gitea/services/context"
)
@@ -83,3 +85,26 @@ func getParentTreeFields(treePath string) (treeNames, treePaths []string) {
}
return treeNames, treePaths
}
+
+// getUniqueRepositoryName Gets a unique repository name for a user
+// It will append a -<num> postfix if the name is already taken
+func getUniqueRepositoryName(ctx context.Context, ownerID int64, name string) string {
+ uniqueName := name
+ for i := 1; i < 1000; i++ {
+ _, err := repo_model.GetRepositoryByName(ctx, ownerID, uniqueName)
+ if err != nil || repo_model.IsErrRepoNotExist(err) {
+ return uniqueName
+ }
+ uniqueName = fmt.Sprintf("%s-%d", name, i)
+ i++
+ }
+ return ""
+}
+
+func editorPushBranchToForkedRepository(ctx context.Context, doer *user_model.User, baseRepo *repo_model.Repository, baseBranchName string, targetRepo *repo_model.Repository, targetBranchName string) error {
+ return git.Push(ctx, baseRepo.RepoPath(), git.PushOptions{
+ Remote: targetRepo.RepoPath(),
+ Branch: baseBranchName + ":" + targetBranchName,
+ Env: repo_module.PushingEnvironment(doer, targetRepo),
+ })
+}
diff --git a/routers/web/repo/fork.go b/routers/web/repo/fork.go
index 9f5cda10c2..c2694e540f 100644
--- a/routers/web/repo/fork.go
+++ b/routers/web/repo/fork.go
@@ -189,17 +189,25 @@ func ForkPost(ctx *context.Context) {
}
}
- repo, err := repo_service.ForkRepository(ctx, ctx.Doer, ctxUser, repo_service.ForkRepoOptions{
+ repo := ForkRepoTo(ctx, ctxUser, repo_service.ForkRepoOptions{
BaseRepo: forkRepo,
Name: form.RepoName,
Description: form.Description,
SingleBranch: form.ForkSingleBranch,
})
+ if ctx.Written() {
+ return
+ }
+ ctx.JSONRedirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name))
+}
+
+func ForkRepoTo(ctx *context.Context, owner *user_model.User, forkOpts repo_service.ForkRepoOptions) *repo_model.Repository {
+ repo, err := repo_service.ForkRepository(ctx, ctx.Doer, owner, forkOpts)
if err != nil {
ctx.Data["Err_RepoName"] = true
switch {
case repo_model.IsErrReachLimitOfRepo(err):
- maxCreationLimit := ctxUser.MaxCreationLimit()
+ maxCreationLimit := owner.MaxCreationLimit()
msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit)
ctx.JSONError(msg)
case repo_model.IsErrRepoAlreadyExist(err):
@@ -224,9 +232,7 @@ func ForkPost(ctx *context.Context) {
default:
ctx.ServerError("ForkPost", err)
}
- return
+ return nil
}
-
- log.Trace("Repository forked[%d]: %s/%s", forkRepo.ID, ctxUser.Name, repo.Name)
- ctx.JSONRedirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name))
+ return repo
}
diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go
index ec0ad02828..5606a8e6ec 100644
--- a/routers/web/repo/view_file.go
+++ b/routers/web/repo/view_file.go
@@ -290,7 +290,7 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
// archived or mirror repository, the buttons should not be shown
- if ctx.Repo.Repository.IsArchived || !ctx.Repo.Repository.CanEnableEditor() {
+ if !ctx.Repo.Repository.CanEnableEditor() {
return
}
@@ -302,7 +302,9 @@ func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
}
if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) {
+ ctx.Data["CanEditFile"] = true
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
+ ctx.Data["CanDeleteFile"] = true
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access")
return
}
diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go
index 7af6ad450e..4ce22d79db 100644
--- a/routers/web/repo/view_readme.go
+++ b/routers/web/repo/view_readme.go
@@ -212,7 +212,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale)
}
- if !fInfo.isLFSFile && ctx.Repo.CanEnableEditor(ctx, ctx.Doer) {
+ if !fInfo.isLFSFile && ctx.Repo.Repository.CanEnableEditor() {
ctx.Data["CanEditReadmeFile"] = true
}
}
diff --git a/routers/web/web.go b/routers/web/web.go
index 3040375def..4b5d68b260 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1313,23 +1313,35 @@ func registerWebRoutes(m *web.Router) {
}, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived())
// end "/{username}/{reponame}": create or edit issues, pulls, labels, milestones
- m.Group("/{username}/{reponame}", func() { // repo code
+ m.Group("/{username}/{reponame}", func() { // repo code (at least "code reader")
m.Group("", func() {
m.Group("", func() {
- m.Post("/_preview/*", repo.DiffPreviewPost)
- m.Combo("/{editor_action:_edit}/*").Get(repo.EditFile).
- Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost)
- m.Combo("/{editor_action:_new}/*").Get(repo.EditFile).
- Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost)
- m.Combo("/_delete/*").Get(repo.DeleteFile).
- Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost)
- m.Combo("/_upload/*", repo.MustBeAbleToUpload).Get(repo.UploadFile).
- Post(web.Bind(forms.UploadRepoFileForm{}), repo.UploadFilePost)
- m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch).
- Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost)
- m.Combo("/_cherrypick/{sha:([a-f0-9]{7,64})}/*").Get(repo.CherryPick).
- Post(web.Bind(forms.CherryPickForm{}), repo.CherryPickPost)
- }, context.RepoRefByType(git.RefTypeBranch), context.CanWriteToBranch(), repo.WebGitOperationCommonData)
+ // "GET" requests only need "code reader" permission, "POST" requests need "code writer" permission.
+ // Because reader can "fork and edit"
+ canWriteToBranch := context.CanWriteToBranch()
+ m.Post("/_preview/*", repo.DiffPreviewPost) // read-only, fine with "code reader"
+ m.Post("/_fork/*", repo.ForkToEditPost) // read-only, fork to own repo, fine with "code reader"
+
+ // the path params are used in PrepareCommitFormOptions to construct the correct form action URL
+ m.Combo("/{editor_action:_edit}/*").
+ Get(repo.EditFile).
+ Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.EditFilePost)
+ m.Combo("/{editor_action:_new}/*").
+ Get(repo.EditFile).
+ Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.EditFilePost)
+ m.Combo("/{editor_action:_delete}/*").
+ Get(repo.DeleteFile).
+ Post(web.Bind(forms.DeleteRepoFileForm{}), canWriteToBranch, repo.DeleteFilePost)
+ m.Combo("/{editor_action:_upload}/*", repo.MustBeAbleToUpload).
+ Get(repo.UploadFile).
+ Post(web.Bind(forms.UploadRepoFileForm{}), canWriteToBranch, repo.UploadFilePost)
+ m.Combo("/{editor_action:_diffpatch}/*").
+ Get(repo.NewDiffPatch).
+ Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.NewDiffPatchPost)
+ m.Combo("/{editor_action:_cherrypick}/{sha:([a-f0-9]{7,64})}/*").
+ Get(repo.CherryPick).
+ Post(web.Bind(forms.CherryPickForm{}), canWriteToBranch, repo.CherryPickPost)
+ }, context.RepoRefByType(git.RefTypeBranch), repo.WebGitOperationCommonData)
m.Group("", func() {
m.Post("/upload-file", repo.UploadFileToServer)
m.Post("/upload-remove", repo.RemoveUploadFileFromServer)
diff --git a/services/context/repo.go b/services/context/repo.go
index c28ae7e8fd..572211712b 100644
--- a/services/context/repo.go
+++ b/services/context/repo.go
@@ -71,11 +71,6 @@ func (r *Repository) CanWriteToBranch(ctx context.Context, user *user_model.User
return issues_model.CanMaintainerWriteToBranch(ctx, r.Permission, branch, user)
}
-// CanEnableEditor returns true if repository is editable and user has proper access level.
-func (r *Repository) CanEnableEditor(ctx context.Context, user *user_model.User) bool {
- return r.RefFullName.IsBranch() && r.CanWriteToBranch(ctx, user, r.BranchName) && r.Repository.CanEnableEditor() && !r.Repository.IsArchived
-}
-
// CanCreateBranch returns true if repository is editable and user has proper access level.
func (r *Repository) CanCreateBranch() bool {
return r.Permission.CanWrite(unit_model.TypeCode) && r.Repository.CanCreateBranch()
@@ -94,9 +89,13 @@ func RepoMustNotBeArchived() func(ctx *Context) {
}
}
-type CommitFormBehaviors struct {
+type CommitFormOptions struct {
+ NeedFork bool
+
+ TargetRepo *repo_model.Repository
+ TargetFormAction string
+ WillSubmitToFork bool
CanCommitToBranch bool
- EditorEnabled bool
UserCanPush bool
RequireSigned bool
WillSign bool
@@ -106,51 +105,84 @@ type CommitFormBehaviors struct {
CanCreateBasePullRequest bool
}
-func (r *Repository) PrepareCommitFormBehaviors(ctx *Context, doer *user_model.User) (*CommitFormBehaviors, error) {
- protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, r.Repository.ID, r.BranchName)
+func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *repo_model.Repository, doerRepoPerm access_model.Permission, refName git.RefName) (*CommitFormOptions, error) {
+ if !refName.IsBranch() {
+ // it shouldn't happen because middleware already checks
+ return nil, util.NewInvalidArgumentErrorf("ref %q is not a branch", refName)
+ }
+
+ originRepo := targetRepo
+ branchName := refName.ShortName()
+ // TODO: CanMaintainerWriteToBranch is a bad name, but it really does what "CanWriteToBranch" does
+ if !issues_model.CanMaintainerWriteToBranch(ctx, doerRepoPerm, branchName, doer) {
+ targetRepo = repo_model.GetForkedRepo(ctx, doer.ID, targetRepo.ID)
+ if targetRepo == nil {
+ return &CommitFormOptions{NeedFork: true}, nil
+ }
+ // now, we get our own forked repo; it must be writable by us.
+ }
+ submitToForkedRepo := targetRepo.ID != originRepo.ID
+ err := targetRepo.GetBaseRepo(ctx)
if err != nil {
return nil, err
}
- userCanPush := true
- requireSigned := false
+
+ protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, targetRepo.ID, branchName)
+ if err != nil {
+ return nil, err
+ }
+ canPushWithProtection := true
+ protectionRequireSigned := false
if protectedBranch != nil {
- protectedBranch.Repo = r.Repository
- userCanPush = protectedBranch.CanUserPush(ctx, doer)
- requireSigned = protectedBranch.RequireSignedCommits
+ protectedBranch.Repo = targetRepo
+ canPushWithProtection = protectedBranch.CanUserPush(ctx, doer)
+ protectionRequireSigned = protectedBranch.RequireSignedCommits
}
- sign, keyID, _, err := asymkey_service.SignCRUDAction(ctx, r.Repository.RepoPath(), doer, r.Repository.RepoPath(), git.BranchPrefix+r.BranchName)
-
- canEnableEditor := r.CanEnableEditor(ctx, doer)
- canCommit := canEnableEditor && userCanPush
- if requireSigned {
- canCommit = canCommit && sign
- }
+ willSign, signKeyID, _, err := asymkey_service.SignCRUDAction(ctx, targetRepo.RepoPath(), doer, targetRepo.RepoPath(), refName.String())
wontSignReason := ""
- if err != nil {
- if asymkey_service.IsErrWontSign(err) {
- wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason)
- err = nil
- } else {
- wontSignReason = "error"
- }
+ if asymkey_service.IsErrWontSign(err) {
+ wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason)
+ } else if err != nil {
+ return nil, err
+ }
+
+ canCommitToBranch := !submitToForkedRepo /* same repo */ && targetRepo.CanEnableEditor() && canPushWithProtection
+ if protectionRequireSigned {
+ canCommitToBranch = canCommitToBranch && willSign
}
- canCreateBasePullRequest := ctx.Repo.Repository.BaseRepo != nil && ctx.Repo.Repository.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests)
- canCreatePullRequest := ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypePullRequests) || canCreateBasePullRequest
+ canCreateBasePullRequest := targetRepo.BaseRepo != nil && targetRepo.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests)
+ canCreatePullRequest := targetRepo.UnitEnabled(ctx, unit_model.TypePullRequests) || canCreateBasePullRequest
- return &CommitFormBehaviors{
- CanCommitToBranch: canCommit,
- EditorEnabled: canEnableEditor,
- UserCanPush: userCanPush,
- RequireSigned: requireSigned,
- WillSign: sign,
- SigningKey: keyID,
+ opts := &CommitFormOptions{
+ TargetRepo: targetRepo,
+ WillSubmitToFork: submitToForkedRepo,
+ CanCommitToBranch: canCommitToBranch,
+ UserCanPush: canPushWithProtection,
+ RequireSigned: protectionRequireSigned,
+ WillSign: willSign,
+ SigningKey: signKeyID,
WontSignReason: wontSignReason,
CanCreatePullRequest: canCreatePullRequest,
CanCreateBasePullRequest: canCreateBasePullRequest,
- }, err
+ }
+ editorAction := ctx.PathParam("editor_action")
+ editorPathParamRemaining := util.PathEscapeSegments(branchName) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
+ if submitToForkedRepo {
+ // there is only "default branch" in forked repo, we will use "from_base_branch" to get a new branch from base repo
+ editorPathParamRemaining = util.PathEscapeSegments(targetRepo.DefaultBranch) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + "?from_base_branch=" + url.QueryEscape(branchName)
+ }
+ if editorAction == "_cherrypick" {
+ opts.TargetFormAction = targetRepo.Link() + "/" + editorAction + "/" + ctx.PathParam("sha") + "/" + editorPathParamRemaining
+ } else {
+ opts.TargetFormAction = targetRepo.Link() + "/" + editorAction + "/" + editorPathParamRemaining
+ }
+ if ctx.Req.URL.RawQuery != "" {
+ opts.TargetFormAction += util.Iif(strings.Contains(opts.TargetFormAction, "?"), "&", "?") + ctx.Req.URL.RawQuery
+ }
+ return opts, nil
}
// CanUseTimetracker returns whether a user can use the timetracker.
diff --git a/services/context/upload/upload.go b/services/context/upload/upload.go
index 303e7da38b..23707950d4 100644
--- a/services/context/upload/upload.go
+++ b/services/context/upload/upload.go
@@ -11,7 +11,9 @@ import (
"regexp"
"strings"
+ repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
)
@@ -106,14 +108,17 @@ func AddUploadContext(ctx *context.Context, uploadType string) {
ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Attachment.AllowedTypes, "|", ",")
ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles
ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize
- case "repo":
- ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/upload-file"
- ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/upload-remove"
- ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/upload-file"
- ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Upload.AllowedTypes, "|", ",")
- ctx.Data["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles
- ctx.Data["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize
default:
setting.PanicInDevOrTesting("Invalid upload type: %s", uploadType)
}
}
+
+func AddUploadContextForRepo(ctx reqctx.RequestContext, repo *repo_model.Repository) {
+ ctxData, repoLink := ctx.GetData(), repo.Link()
+ ctxData["UploadUrl"] = repoLink + "/upload-file"
+ ctxData["UploadRemoveUrl"] = repoLink + "/upload-remove"
+ ctxData["UploadLinkUrl"] = repoLink + "/upload-file"
+ ctxData["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Upload.AllowedTypes, "|", ",")
+ ctxData["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles
+ ctxData["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize
+}
diff --git a/templates/repo/editor/cherry_pick.tmpl b/templates/repo/editor/cherry_pick.tmpl
index f850ebf916..7981fd0761 100644
--- a/templates/repo/editor/cherry_pick.tmpl
+++ b/templates/repo/editor/cherry_pick.tmpl
@@ -3,8 +3,9 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
- <form class="ui edit form form-fetch-action" method="post" action="{{.RepoLink}}/_cherrypick/{{.FromCommitID}}/{{.BranchName | PathEscapeSegments}}">
+ <form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}}
+ {{template "repo/editor/common_top" .}}
<input type="hidden" name="revert" value="{{if eq .CherryPickType "revert"}}true{{else}}false{{end}}">
<div class="repo-editor-header">
<div class="breadcrumb">
diff --git a/templates/repo/editor/commit_form.tmpl b/templates/repo/editor/commit_form.tmpl
index df1b9ac554..7067614444 100644
--- a/templates/repo/editor/commit_form.tmpl
+++ b/templates/repo/editor/commit_form.tmpl
@@ -1,11 +1,11 @@
<div class="commit-form-wrapper">
{{ctx.AvatarUtils.Avatar .SignedUser 40 "commit-avatar"}}
<div class="commit-form">
- <h3>{{- if .CommitFormBehaviors.WillSign}}
- <span title="{{ctx.Locale.Tr "repo.signing.will_sign" .CommitFormBehaviors.SigningKey}}">{{svg "octicon-lock" 24}}</span>
+ <h3>{{- if .CommitFormOptions.WillSign}}
+ <span title="{{ctx.Locale.Tr "repo.signing.will_sign" .CommitFormOptions.SigningKey}}">{{svg "octicon-lock" 24}}</span>
{{ctx.Locale.Tr "repo.editor.commit_signed_changes"}}
{{- else}}
- <span title="{{ctx.Locale.Tr (printf "repo.signing.wont_sign.%s" .CommitFormBehaviors.WontSignReason)}}">{{svg "octicon-unlock" 24}}</span>
+ <span title="{{ctx.Locale.Tr (printf "repo.signing.wont_sign.%s" .CommitFormOptions.WontSignReason)}}">{{svg "octicon-unlock" 24}}</span>
{{ctx.Locale.Tr "repo.editor.commit_changes"}}
{{- end}}</h3>
<div class="field">
@@ -22,17 +22,17 @@
</div>
<div class="quick-pull-choice js-quick-pull-choice">
<div class="field">
- <div class="ui radio checkbox {{if not .CommitFormBehaviors.CanCommitToBranch}}disabled{{end}}">
+ <div class="ui radio checkbox {{if not .CommitFormOptions.CanCommitToBranch}}disabled{{end}}">
<input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="direct" data-button-text="{{ctx.Locale.Tr "repo.editor.commit_changes"}}" {{if eq .commit_choice "direct"}}checked{{end}}>
<label>
{{svg "octicon-git-commit"}}
{{ctx.Locale.Tr "repo.editor.commit_directly_to_this_branch" .BranchName}}
- {{if not .CommitFormBehaviors.CanCommitToBranch}}
+ {{if not .CommitFormOptions.CanCommitToBranch}}
<div class="ui visible small warning message">
{{ctx.Locale.Tr "repo.editor.no_commit_to_branch"}}
<ul>
- {{if not .CommitFormBehaviors.UserCanPush}}<li>{{ctx.Locale.Tr "repo.editor.user_no_push_to_branch"}}</li>{{end}}
- {{if and .CommitFormBehaviors.RequireSigned (not .CommitFormBehaviors.WillSign)}}<li>{{ctx.Locale.Tr "repo.editor.require_signed_commit"}}</li>{{end}}
+ {{if not .CommitFormOptions.UserCanPush}}<li>{{ctx.Locale.Tr "repo.editor.user_no_push_to_branch"}}</li>{{end}}
+ {{if and .CommitFormOptions.RequireSigned (not .CommitFormOptions.WillSign)}}<li>{{ctx.Locale.Tr "repo.editor.require_signed_commit"}}</li>{{end}}
</ul>
</div>
{{end}}
@@ -42,14 +42,14 @@
{{if and (not .Repository.IsEmpty) (not .IsEditingFileOnly)}}
<div class="field">
<div class="ui radio checkbox">
- {{if .CommitFormBehaviors.CanCreatePullRequest}}
+ {{if .CommitFormOptions.CanCreatePullRequest}}
<input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="commit-to-new-branch" data-button-text="{{ctx.Locale.Tr "repo.editor.propose_file_change"}}" {{if eq .commit_choice "commit-to-new-branch"}}checked{{end}}>
{{else}}
<input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="commit-to-new-branch" data-button-text="{{ctx.Locale.Tr "repo.editor.commit_changes"}}" {{if eq .commit_choice "commit-to-new-branch"}}checked{{end}}>
{{end}}
<label>
{{svg "octicon-git-pull-request"}}
- {{if .CommitFormBehaviors.CanCreatePullRequest}}
+ {{if .CommitFormOptions.CanCreatePullRequest}}
{{ctx.Locale.Tr "repo.editor.create_new_branch"}}
{{else}}
{{ctx.Locale.Tr "repo.editor.create_new_branch_np"}}
diff --git a/templates/repo/editor/common_top.tmpl b/templates/repo/editor/common_top.tmpl
new file mode 100644
index 0000000000..23280ed565
--- /dev/null
+++ b/templates/repo/editor/common_top.tmpl
@@ -0,0 +1,6 @@
+{{if .CommitFormOptions.WillSubmitToFork}}
+<div class="ui blue message">
+ {{$repoLinkHTML := HTMLFormat `<a href="%s">%s</a>` .CommitFormOptions.TargetRepo.Link .CommitFormOptions.TargetRepo.FullName}}
+ {{ctx.Locale.Tr "repo.editor.fork_edit_description" $repoLinkHTML}}
+</div>
+{{end}}
diff --git a/templates/repo/editor/delete.tmpl b/templates/repo/editor/delete.tmpl
index 6f9963a6bd..bf6143f1cb 100644
--- a/templates/repo/editor/delete.tmpl
+++ b/templates/repo/editor/delete.tmpl
@@ -3,8 +3,9 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
- <form class="ui form form-fetch-action" method="post">
+ <form class="ui form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}}
+ {{template "repo/editor/common_top" .}}
{{template "repo/editor/commit_form" .}}
</form>
</div>
diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl
index 536ed07ca7..0911d02e1f 100644
--- a/templates/repo/editor/edit.tmpl
+++ b/templates/repo/editor/edit.tmpl
@@ -3,11 +3,12 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
- <form class="ui edit form form-fetch-action" method="post"
+ <form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"
data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}"
data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}"
>
{{.CsrfTokenHtml}}
+ {{template "repo/editor/common_top" .}}
<div class="repo-editor-header">
{{template "repo/editor/common_breadcrumb" .}}
</div>
diff --git a/templates/repo/editor/fork.tmpl b/templates/repo/editor/fork.tmpl
new file mode 100644
index 0000000000..e28b2ba7a2
--- /dev/null
+++ b/templates/repo/editor/fork.tmpl
@@ -0,0 +1,18 @@
+{{template "base/head" .}}
+<div role="main" aria-label="{{.Title}}" class="page-content repository">
+ {{template "repo/header" .}}
+ <div class="ui container">
+ {{template "base/alert" .}}
+ <form class="ui form form-fetch-action" method="post" action="{{.RepoLink}}/_fork/{{.BranchName | PathEscapeSegments}}">
+ {{.CsrfTokenHtml}}
+ <div class="tw-text-center">
+ <div class="tw-my-[40px]">
+ <h3>{{ctx.Locale.Tr "repo.editor.fork_create"}}</h3>
+ <p>{{ctx.Locale.Tr "repo.editor.fork_create_description"}}</p>
+ </div>
+ <button class="ui primary button">{{ctx.Locale.Tr "repo.fork_repo"}}</button>
+ </div>
+ </form>
+ </div>
+</div>
+{{template "base/footer" .}}
diff --git a/templates/repo/editor/patch.tmpl b/templates/repo/editor/patch.tmpl
index 7f9571b4ae..fa00edd92e 100644
--- a/templates/repo/editor/patch.tmpl
+++ b/templates/repo/editor/patch.tmpl
@@ -3,11 +3,12 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
- <form class="ui edit form form-fetch-action" method="post" action="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}"
+ <form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"
data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}"
data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}"
>
{{.CsrfTokenHtml}}
+ {{template "repo/editor/common_top" .}}
<div class="repo-editor-header">
<div class="breadcrumb">
{{ctx.Locale.Tr "repo.editor.patching"}}
diff --git a/templates/repo/editor/upload.tmpl b/templates/repo/editor/upload.tmpl
index 2e9280e9cd..3e36c77b3b 100644
--- a/templates/repo/editor/upload.tmpl
+++ b/templates/repo/editor/upload.tmpl
@@ -3,8 +3,9 @@
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
- <form class="ui comment form form-fetch-action" method="post">
+ <form class="ui comment form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}}
+ {{template "repo/editor/common_top" .}}
<div class="repo-editor-header">
{{template "repo/editor/common_breadcrumb" .}}
</div>
diff --git a/templates/repo/view_content.tmpl b/templates/repo/view_content.tmpl
index 2418f24d89..3ba04a9974 100644
--- a/templates/repo/view_content.tmpl
+++ b/templates/repo/view_content.tmpl
@@ -41,8 +41,8 @@
<a href="{{.Repository.Link}}/find/{{.RefTypeNameSubURL}}" class="ui compact basic button">{{ctx.Locale.Tr "repo.find_file.go_to_file"}}</a>
{{end}}
- {{if and .CanWriteCode .RefFullName.IsBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}}
- <button class="ui dropdown basic compact jump button"{{if not .Repository.CanEnableEditor}} disabled{{end}}>
+ {{if and .RefFullName.IsBranch (not .IsViewFile)}}
+ <button class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanEnableEditor}}disabled{{end}}>
{{ctx.Locale.Tr "repo.editor.add_file"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
diff --git a/tests/integration/editor_test.go b/tests/integration/editor_test.go
index 9e4ebb573f..ac47ed0094 100644
--- a/tests/integration/editor_test.go
+++ b/tests/integration/editor_test.go
@@ -7,6 +7,7 @@ import (
"bytes"
"fmt"
"io"
+ "maps"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -19,292 +20,278 @@ import (
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
- "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestCreateFile(t *testing.T) {
+func TestEditor(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
- session := loginUser(t, "user2")
- testCreateFile(t, session, "user2", "repo1", "master", "test.txt", "Content")
+ sessionUser2 := loginUser(t, "user2")
+ t.Run("EditFileNotAllowed", testEditFileNotAllowed)
+ t.Run("DiffPreview", testEditorDiffPreview)
+ t.Run("CreateFile", testEditorCreateFile)
+ t.Run("EditFile", func(t *testing.T) {
+ testEditFile(t, sessionUser2, "user2", "repo1", "master", "README.md", "Hello, World (direct)\n")
+ testEditFileToNewBranch(t, sessionUser2, "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (commit-to-new-branch)\n")
+ })
+ t.Run("PatchFile", testEditorPatchFile)
+ t.Run("DeleteFile", func(t *testing.T) {
+ viewLink := "/user2/repo1/src/branch/branch2/README.md"
+ sessionUser2.MakeRequest(t, NewRequest(t, "GET", viewLink), http.StatusOK)
+ testEditorActionPostRequest(t, sessionUser2, "/user2/repo1/_delete/branch2/README.md", map[string]string{"commit_choice": "direct"})
+ sessionUser2.MakeRequest(t, NewRequest(t, "GET", viewLink), http.StatusNotFound)
+ })
+ t.Run("ForkToEditFile", func(t *testing.T) {
+ testForkToEditFile(t, loginUser(t, "user4"), "user4", "user2", "repo1", "master", "README.md")
+ })
+ t.Run("WebGitCommitEmail", testEditorWebGitCommitEmail)
+ t.Run("ProtectedBranch", testEditorProtectedBranch)
})
}
-func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) {
- // Request editor page
- newURL := fmt.Sprintf("/%s/%s/_new/%s/", user, repo, branch)
- req := NewRequest(t, "GET", newURL)
- resp := session.MakeRequest(t, req, http.StatusOK)
-
- doc := NewHTMLParser(t, resp.Body)
- lastCommit := doc.GetInputValueByName("last_commit")
- assert.NotEmpty(t, lastCommit)
+func testEditorCreateFile(t *testing.T) {
+ session := loginUser(t, "user2")
+ testCreateFile(t, session, "user2", "repo1", "master", "test.txt", "Content")
+ testEditorActionPostRequestError(t, session, "/user2/repo1/_new/master/", map[string]string{
+ "tree_path": "test.txt",
+ "commit_choice": "direct",
+ "new_branch_name": "master",
+ }, `A file named "test.txt" already exists in this repository.`)
+ testEditorActionPostRequestError(t, session, "/user2/repo1/_new/master/", map[string]string{
+ "tree_path": "test.txt",
+ "commit_choice": "commit-to-new-branch",
+ "new_branch_name": "master",
+ }, `Branch "master" already exists in this repository.`)
+}
- // Save new file to master branch
- req = NewRequestWithValues(t, "POST", newURL, map[string]string{
- "_csrf": doc.GetCSRF(),
- "last_commit": lastCommit,
+func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) {
+ testEditorActionEdit(t, session, user, repo, "_new", branch, "", map[string]string{
"tree_path": filePath,
"content": content,
"commit_choice": "direct",
})
- resp = session.MakeRequest(t, req, http.StatusOK)
- assert.NotEmpty(t, test.RedirectURL(resp))
}
-func TestCreateFileOnProtectedBranch(t *testing.T) {
- onGiteaRun(t, func(t *testing.T, u *url.URL) {
- session := loginUser(t, "user2")
-
- csrf := GetUserCSRFToken(t, session)
- // Change master branch to protected
- req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
- "_csrf": csrf,
- "rule_name": "master",
- "enable_push": "true",
- })
- session.MakeRequest(t, req, http.StatusSeeOther)
- // Check if master branch has been locked successfully
- flashMsg := session.GetCookieFlashMessage()
- assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg)
-
- // Request editor page
- req = NewRequest(t, "GET", "/user2/repo1/_new/master/")
- resp := session.MakeRequest(t, req, http.StatusOK)
-
- doc := NewHTMLParser(t, resp.Body)
- lastCommit := doc.GetInputValueByName("last_commit")
- assert.NotEmpty(t, lastCommit)
-
- // Save new file to master branch
- req = NewRequestWithValues(t, "POST", "/user2/repo1/_new/master/", map[string]string{
- "_csrf": doc.GetCSRF(),
- "last_commit": lastCommit,
- "tree_path": "test.txt",
- "content": "Content",
- "commit_choice": "direct",
- })
-
- resp = session.MakeRequest(t, req, http.StatusBadRequest)
- respErr := test.ParseJSONError(resp.Body.Bytes())
- assert.Equal(t, `Cannot commit to protected branch "master".`, respErr.ErrorMessage)
-
- // remove the protected branch
- csrf = GetUserCSRFToken(t, session)
-
- // Change master branch to protected
- req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/1/delete", map[string]string{
- "_csrf": csrf,
- })
-
- resp = session.MakeRequest(t, req, http.StatusOK)
-
- res := make(map[string]string)
- assert.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
- assert.Equal(t, "/user2/repo1/settings/branches", res["redirect"])
-
- // Check if master branch has been locked successfully
- flashMsg = session.GetCookieFlashMessage()
- assert.Equal(t, `Removing branch protection rule "1" failed.`, flashMsg.ErrorMsg)
+func testEditorProtectedBranch(t *testing.T) {
+ session := loginUser(t, "user2")
+ // Change the "master" branch to "protected"
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
+ "_csrf": GetUserCSRFToken(t, session),
+ "rule_name": "master",
+ "enable_push": "true",
})
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ flashMsg := session.GetCookieFlashMessage()
+ assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg)
+
+ // Try to commit a file to the "master" branch and it should fail
+ resp := testEditorActionPostRequest(t, session, "/user2/repo1/_new/master/", map[string]string{"tree_path": "test-protected-branch.txt", "commit_choice": "direct"})
+ assert.Equal(t, http.StatusBadRequest, resp.Code)
+ assert.Equal(t, `Cannot commit to protected branch "master".`, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
}
-func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath, newContent string) *httptest.ResponseRecorder {
- // Get to the 'edit this file' page
- req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath))
+func testEditorActionPostRequest(t *testing.T, session *TestSession, requestPath string, params map[string]string) *httptest.ResponseRecorder {
+ req := NewRequest(t, "GET", requestPath)
resp := session.MakeRequest(t, req, http.StatusOK)
-
htmlDoc := NewHTMLParser(t, resp.Body)
- lastCommit := htmlDoc.GetInputValueByName("last_commit")
- assert.NotEmpty(t, lastCommit)
-
- // Submit the edits
- req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath),
- map[string]string{
- "_csrf": htmlDoc.GetCSRF(),
- "last_commit": lastCommit,
- "tree_path": filePath,
- "content": newContent,
- "commit_choice": "direct",
- },
- )
- resp = session.MakeRequest(t, req, http.StatusOK)
- assert.NotEmpty(t, test.RedirectURL(resp))
-
- // Verify the change
- req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", branch, filePath))
- resp = session.MakeRequest(t, req, http.StatusOK)
- assert.Equal(t, newContent, resp.Body.String())
-
- return resp
+ form := map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "last_commit": htmlDoc.GetInputValueByName("last_commit"),
+ }
+ maps.Copy(form, params)
+ req = NewRequestWithValues(t, "POST", requestPath, form)
+ return session.MakeRequest(t, req, NoExpectedStatus)
}
-func testEditFileToNewBranch(t *testing.T, session *TestSession, user, repo, branch, targetBranch, filePath, newContent string) *httptest.ResponseRecorder {
- // Get to the 'edit this file' page
- req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath))
- resp := session.MakeRequest(t, req, http.StatusOK)
+func testEditorActionPostRequestError(t *testing.T, session *TestSession, requestPath string, params map[string]string, errorMessage string) {
+ resp := testEditorActionPostRequest(t, session, requestPath, params)
+ assert.Equal(t, http.StatusBadRequest, resp.Code)
+ assert.Equal(t, errorMessage, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
+}
- htmlDoc := NewHTMLParser(t, resp.Body)
- lastCommit := htmlDoc.GetInputValueByName("last_commit")
- assert.NotEmpty(t, lastCommit)
-
- // Submit the edits
- req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath),
- map[string]string{
- "_csrf": htmlDoc.GetCSRF(),
- "last_commit": lastCommit,
- "tree_path": filePath,
- "content": newContent,
- "commit_choice": "commit-to-new-branch",
- "new_branch_name": targetBranch,
- },
- )
- resp = session.MakeRequest(t, req, http.StatusOK)
+func testEditorActionEdit(t *testing.T, session *TestSession, user, repo, editorAction, branch, filePath string, params map[string]string) *httptest.ResponseRecorder {
+ params["tree_path"] = util.IfZero(params["tree_path"], filePath)
+ newBranchName := util.Iif(params["commit_choice"] == "direct", branch, params["new_branch_name"])
+ resp := testEditorActionPostRequest(t, session, fmt.Sprintf("/%s/%s/%s/%s/%s", user, repo, editorAction, branch, filePath), params)
+ assert.Equal(t, http.StatusOK, resp.Code)
assert.NotEmpty(t, test.RedirectURL(resp))
-
- // Verify the change
- req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", targetBranch, filePath))
+ req := NewRequest(t, "GET", path.Join(user, repo, "raw/branch", newBranchName, params["tree_path"]))
resp = session.MakeRequest(t, req, http.StatusOK)
- assert.Equal(t, newContent, resp.Body.String())
-
+ assert.Equal(t, params["content"], resp.Body.String())
return resp
}
-func TestEditFile(t *testing.T) {
- onGiteaRun(t, func(t *testing.T, u *url.URL) {
- session := loginUser(t, "user2")
- testEditFile(t, session, "user2", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath, newContent string) {
+ testEditorActionEdit(t, session, user, repo, "_edit", branch, filePath, map[string]string{
+ "content": newContent,
+ "commit_choice": "direct",
})
}
-func TestEditFileToNewBranch(t *testing.T) {
- onGiteaRun(t, func(t *testing.T, u *url.URL) {
- session := loginUser(t, "user2")
- testEditFileToNewBranch(t, session, "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited)\n")
+func testEditFileToNewBranch(t *testing.T, session *TestSession, user, repo, branch, targetBranch, filePath, newContent string) {
+ testEditorActionEdit(t, session, user, repo, "_edit", branch, filePath, map[string]string{
+ "content": newContent,
+ "commit_choice": "commit-to-new-branch",
+ "new_branch_name": targetBranch,
})
}
-func TestWebGitCommitEmail(t *testing.T) {
- onGiteaRun(t, func(t *testing.T, _ *url.URL) {
- user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
- require.True(t, user.KeepEmailPrivate)
-
- repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
- gitRepo, _ := git.OpenRepository(git.DefaultContext, repo1.RepoPath())
- defer gitRepo.Close()
- getLastCommit := func(t *testing.T) *git.Commit {
- c, err := gitRepo.GetBranchCommit("master")
- require.NoError(t, err)
- return c
- }
+func testEditorDiffPreview(t *testing.T) {
+ session := loginUser(t, "user2")
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/_preview/master/README.md", map[string]string{
+ "_csrf": GetUserCSRFToken(t, session),
+ "content": "Hello, World (Edited)\n",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), `<span class="added-code">Hello, World (Edited)</span>`)
+}
- session := loginUser(t, user.Name)
-
- makeReq := func(t *testing.T, link string, params map[string]string, expectedUserName, expectedEmail string) *httptest.ResponseRecorder {
- lastCommit := getLastCommit(t)
- params["_csrf"] = GetUserCSRFToken(t, session)
- params["last_commit"] = lastCommit.ID.String()
- params["commit_choice"] = "direct"
- req := NewRequestWithValues(t, "POST", link, params)
- resp := session.MakeRequest(t, req, NoExpectedStatus)
- newCommit := getLastCommit(t)
- if expectedUserName == "" {
- require.Equal(t, lastCommit.ID.String(), newCommit.ID.String())
- respErr := test.ParseJSONError(resp.Body.Bytes())
- assert.Equal(t, translation.NewLocale("en-US").TrString("repo.editor.invalid_commit_email"), respErr.ErrorMessage)
- } else {
- require.NotEqual(t, lastCommit.ID.String(), newCommit.ID.String())
- assert.Equal(t, expectedUserName, newCommit.Author.Name)
- assert.Equal(t, expectedEmail, newCommit.Author.Email)
- assert.Equal(t, expectedUserName, newCommit.Committer.Name)
- assert.Equal(t, expectedEmail, newCommit.Committer.Email)
- }
- return resp
- }
+func testEditorPatchFile(t *testing.T) {
+ session := loginUser(t, "user2")
+ pathContentCommon := `diff --git a/patch-file-1.txt b/patch-file-1.txt
+new file mode 100644
+index 0000000000..aaaaaaaaaa
+--- /dev/null
++++ b/patch-file-1.txt
+@@ -0,0 +1 @@
++`
+ testEditorActionPostRequest(t, session, "/user2/repo1/_diffpatch/master/", map[string]string{
+ "content": pathContentCommon + "patched content\n",
+ "commit_choice": "commit-to-new-branch",
+ "new_branch_name": "patched-branch",
+ })
+ resp := MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/raw/branch/patched-branch/patch-file-1.txt"), http.StatusOK)
+ assert.Equal(t, "patched content\n", resp.Body.String())
+
+ // patch again, it should fail
+ resp = testEditorActionPostRequest(t, session, "/user2/repo1/_diffpatch/patched-branch/", map[string]string{
+ "content": pathContentCommon + "another patched content\n",
+ "commit_choice": "commit-to-new-branch",
+ "new_branch_name": "patched-branch-1",
+ })
+ assert.Equal(t, "Unable to apply patch", test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
+}
- uploadFile := func(t *testing.T, name, content string) string {
- body := &bytes.Buffer{}
- uploadForm := multipart.NewWriter(body)
- file, _ := uploadForm.CreateFormFile("file", name)
- _, _ = io.Copy(file, strings.NewReader(content))
- _ = uploadForm.WriteField("_csrf", GetUserCSRFToken(t, session))
- _ = uploadForm.Close()
-
- req := NewRequestWithBody(t, "POST", "/user2/repo1/upload-file", body)
- req.Header.Add("Content-Type", uploadForm.FormDataContentType())
- resp := session.MakeRequest(t, req, http.StatusOK)
-
- respMap := map[string]string{}
- DecodeJSON(t, resp, &respMap)
- return respMap["uuid"]
+func testEditorWebGitCommitEmail(t *testing.T) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ require.True(t, user.KeepEmailPrivate)
+
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ gitRepo, _ := git.OpenRepository(git.DefaultContext, repo1.RepoPath())
+ defer gitRepo.Close()
+ getLastCommit := func(t *testing.T) *git.Commit {
+ c, err := gitRepo.GetBranchCommit("master")
+ require.NoError(t, err)
+ return c
+ }
+
+ session := loginUser(t, user.Name)
+
+ makeReq := func(t *testing.T, link string, params map[string]string, expectedUserName, expectedEmail string) *httptest.ResponseRecorder {
+ lastCommit := getLastCommit(t)
+ params["_csrf"] = GetUserCSRFToken(t, session)
+ params["last_commit"] = lastCommit.ID.String()
+ params["commit_choice"] = "direct"
+ req := NewRequestWithValues(t, "POST", link, params)
+ resp := session.MakeRequest(t, req, NoExpectedStatus)
+ newCommit := getLastCommit(t)
+ if expectedUserName == "" {
+ require.Equal(t, lastCommit.ID.String(), newCommit.ID.String())
+ respErr := test.ParseJSONError(resp.Body.Bytes())
+ assert.Equal(t, translation.NewLocale("en-US").TrString("repo.editor.invalid_commit_email"), respErr.ErrorMessage)
+ } else {
+ require.NotEqual(t, lastCommit.ID.String(), newCommit.ID.String())
+ assert.Equal(t, expectedUserName, newCommit.Author.Name)
+ assert.Equal(t, expectedEmail, newCommit.Author.Email)
+ assert.Equal(t, expectedUserName, newCommit.Committer.Name)
+ assert.Equal(t, expectedEmail, newCommit.Committer.Email)
}
+ return resp
+ }
+
+ uploadFile := func(t *testing.T, name, content string) string {
+ body := &bytes.Buffer{}
+ uploadForm := multipart.NewWriter(body)
+ file, _ := uploadForm.CreateFormFile("file", name)
+ _, _ = io.Copy(file, strings.NewReader(content))
+ _ = uploadForm.WriteField("_csrf", GetUserCSRFToken(t, session))
+ _ = uploadForm.Close()
+
+ req := NewRequestWithBody(t, "POST", "/user2/repo1/upload-file", body)
+ req.Header.Add("Content-Type", uploadForm.FormDataContentType())
+ resp := session.MakeRequest(t, req, http.StatusOK)
- t.Run("EmailInactive", func(t *testing.T) {
- defer tests.PrintCurrentTest(t)()
- email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID})
- require.False(t, email.IsActivated)
- makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
- "tree_path": "README.md",
- "content": "test content",
- "commit_email": email.Email,
- }, "", "")
- })
+ respMap := map[string]string{}
+ DecodeJSON(t, resp, &respMap)
+ return respMap["uuid"]
+ }
+
+ t.Run("EmailInactive", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID})
+ require.False(t, email.IsActivated)
+ makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
+ "tree_path": "README.md",
+ "content": "test content",
+ "commit_email": email.Email,
+ }, "", "")
+ })
+
+ t.Run("EmailInvalid", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true})
+ require.NotEqual(t, email.UID, user.ID)
+ makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
+ "tree_path": "README.md",
+ "content": "test content",
+ "commit_email": email.Email,
+ }, "", "")
+ })
- t.Run("EmailInvalid", func(t *testing.T) {
+ testWebGit := func(t *testing.T, linkForKeepPrivate string, paramsForKeepPrivate map[string]string, linkForChosenEmail string, paramsForChosenEmail map[string]string) (resp1, resp2 *httptest.ResponseRecorder) {
+ t.Run("DefaultEmailKeepPrivate", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
- email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true})
- require.NotEqual(t, email.UID, user.ID)
- makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
- "tree_path": "README.md",
- "content": "test content",
- "commit_email": email.Email,
- }, "", "")
+ paramsForKeepPrivate["commit_email"] = ""
+ resp1 = makeReq(t, linkForKeepPrivate, paramsForKeepPrivate, "User Two", "user2@noreply.example.org")
})
-
- testWebGit := func(t *testing.T, linkForKeepPrivate string, paramsForKeepPrivate map[string]string, linkForChosenEmail string, paramsForChosenEmail map[string]string) (resp1, resp2 *httptest.ResponseRecorder) {
- t.Run("DefaultEmailKeepPrivate", func(t *testing.T) {
- defer tests.PrintCurrentTest(t)()
- paramsForKeepPrivate["commit_email"] = ""
- resp1 = makeReq(t, linkForKeepPrivate, paramsForKeepPrivate, "User Two", "user2@noreply.example.org")
- })
- t.Run("ChooseEmail", func(t *testing.T) {
- defer tests.PrintCurrentTest(t)()
- paramsForChosenEmail["commit_email"] = "user2@example.com"
- resp2 = makeReq(t, linkForChosenEmail, paramsForChosenEmail, "User Two", "user2@example.com")
- })
- return resp1, resp2
- }
-
- t.Run("Edit", func(t *testing.T) {
- testWebGit(t,
- "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for keep private"},
- "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for chosen email"},
- )
+ t.Run("ChooseEmail", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ paramsForChosenEmail["commit_email"] = "user2@example.com"
+ resp2 = makeReq(t, linkForChosenEmail, paramsForChosenEmail, "User Two", "user2@example.com")
})
+ return resp1, resp2
+ }
+
+ t.Run("Edit", func(t *testing.T) {
+ testWebGit(t,
+ "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for keep private"},
+ "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for chosen email"},
+ )
+ })
- t.Run("UploadDelete", func(t *testing.T) {
- file1UUID := uploadFile(t, "file1", "File 1")
- file2UUID := uploadFile(t, "file2", "File 2")
- testWebGit(t,
- "/user2/repo1/_upload/master", map[string]string{"files": file1UUID},
- "/user2/repo1/_upload/master", map[string]string{"files": file2UUID},
- )
- testWebGit(t,
- "/user2/repo1/_delete/master/file1", map[string]string{},
- "/user2/repo1/_delete/master/file2", map[string]string{},
- )
- })
+ t.Run("UploadDelete", func(t *testing.T) {
+ file1UUID := uploadFile(t, "file1", "File 1")
+ file2UUID := uploadFile(t, "file2", "File 2")
+ testWebGit(t,
+ "/user2/repo1/_upload/master", map[string]string{"files": file1UUID},
+ "/user2/repo1/_upload/master", map[string]string{"files": file2UUID},
+ )
+ testWebGit(t,
+ "/user2/repo1/_delete/master/file1", map[string]string{},
+ "/user2/repo1/_delete/master/file2", map[string]string{},
+ )
+ })
- t.Run("ApplyPatchCherryPick", func(t *testing.T) {
- testWebGit(t,
- "/user2/repo1/_diffpatch/master", map[string]string{
- "tree_path": "__dummy__",
- "content": `diff --git a/patch-file-1.txt b/patch-file-1.txt
+ t.Run("ApplyPatchCherryPick", func(t *testing.T) {
+ testWebGit(t,
+ "/user2/repo1/_diffpatch/master", map[string]string{
+ "tree_path": "__dummy__",
+ "content": `diff --git a/patch-file-1.txt b/patch-file-1.txt
new file mode 100644
index 0000000000..aaaaaaaaaa
--- /dev/null
@@ -312,10 +299,10 @@ index 0000000000..aaaaaaaaaa
@@ -0,0 +1 @@
+File 1
`,
- },
- "/user2/repo1/_diffpatch/master", map[string]string{
- "tree_path": "__dummy__",
- "content": `diff --git a/patch-file-2.txt b/patch-file-2.txt
+ },
+ "/user2/repo1/_diffpatch/master", map[string]string{
+ "tree_path": "__dummy__",
+ "content": `diff --git a/patch-file-2.txt b/patch-file-2.txt
new file mode 100644
index 0000000000..bbbbbbbbbb
--- /dev/null
@@ -323,20 +310,146 @@ index 0000000000..bbbbbbbbbb
@@ -0,0 +1 @@
+File 2
`,
- },
- )
-
- commit1, err := gitRepo.GetCommitByPath("patch-file-1.txt")
- require.NoError(t, err)
- commit2, err := gitRepo.GetCommitByPath("patch-file-2.txt")
- require.NoError(t, err)
- resp1, _ := testWebGit(t,
- "/user2/repo1/_cherrypick/"+commit1.ID.String()+"/master", map[string]string{"revert": "true"},
- "/user2/repo1/_cherrypick/"+commit2.ID.String()+"/master", map[string]string{"revert": "true"},
- )
-
- // By the way, test the "cherrypick" page: a successful revert redirects to the main branch
- assert.Equal(t, "/user2/repo1/src/branch/master", test.RedirectURL(resp1))
- })
+ },
+ )
+
+ commit1, err := gitRepo.GetCommitByPath("patch-file-1.txt")
+ require.NoError(t, err)
+ commit2, err := gitRepo.GetCommitByPath("patch-file-2.txt")
+ require.NoError(t, err)
+ resp1, _ := testWebGit(t,
+ "/user2/repo1/_cherrypick/"+commit1.ID.String()+"/master", map[string]string{"revert": "true"},
+ "/user2/repo1/_cherrypick/"+commit2.ID.String()+"/master", map[string]string{"revert": "true"},
+ )
+
+ // By the way, test the "cherrypick" page: a successful revert redirects to the main branch
+ assert.Equal(t, "/user2/repo1/src/branch/master", test.RedirectURL(resp1))
+ })
+}
+
+func testForkToEditFile(t *testing.T, session *TestSession, user, owner, repo, branch, filePath string) {
+ forkToEdit := func(t *testing.T, session *TestSession, owner, repo, operation, branch, filePath string) {
+ // visit the base repo, see the "Add File" button
+ req := NewRequest(t, "GET", path.Join(owner, repo))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ AssertHTMLElement(t, htmlDoc, ".repo-add-file", 1)
+
+ // attempt to edit a file, see the guideline page
+ req = NewRequest(t, "GET", path.Join(owner, repo, operation, branch, filePath))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), "Fork Repository to Propose Changes")
+
+ // fork the repository
+ req = NewRequestWithValues(t, "POST", path.Join(owner, repo, "_fork", branch), map[string]string{"_csrf": GetUserCSRFToken(t, session)})
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.JSONEq(t, `{"redirect":""}`, resp.Body.String())
+ }
+
+ t.Run("ForkButArchived", func(t *testing.T) {
+ // Fork repository because we can't edit it
+ forkToEdit(t, session, owner, repo, "_edit", branch, filePath)
+
+ // Archive the repository
+ req := NewRequestWithValues(t, "POST", path.Join(user, repo, "settings"),
+ map[string]string{
+ "_csrf": GetUserCSRFToken(t, session),
+ "repo_name": repo,
+ "action": "archive",
+ },
+ )
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Check editing archived repository is disabled
+ req = NewRequest(t, "GET", path.Join(owner, repo, "_edit", branch, filePath)).SetHeader("Accept", "text/html")
+ resp := session.MakeRequest(t, req, http.StatusNotFound)
+ assert.Contains(t, resp.Body.String(), "You have forked this repository but your fork is not editable.")
+
+ // Unfork the repository
+ req = NewRequestWithValues(t, "POST", path.Join(user, repo, "settings"),
+ map[string]string{
+ "_csrf": GetUserCSRFToken(t, session),
+ "repo_name": repo,
+ "action": "convert_fork",
+ },
+ )
+ session.MakeRequest(t, req, http.StatusSeeOther)
})
+
+ // Fork repository again, and check the existence of the forked repo with unique name
+ forkToEdit(t, session, owner, repo, "_edit", branch, filePath)
+ session.MakeRequest(t, NewRequestf(t, "GET", "/%s/%s-1", user, repo), http.StatusOK)
+
+ t.Run("CheckBaseRepoForm", func(t *testing.T) {
+ // the base repo's edit form should have the correct action and upload links (pointing to the forked repo)
+ req := NewRequest(t, "GET", path.Join(owner, repo, "_upload", branch, filePath)+"?foo=bar")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ uploadForm := htmlDoc.doc.Find(".form-fetch-action")
+ formAction := uploadForm.AttrOr("action", "")
+ assert.Equal(t, fmt.Sprintf("/%s/%s-1/_upload/%s/%s?from_base_branch=%s&foo=bar", user, repo, branch, filePath, branch), formAction)
+ uploadLink := uploadForm.Find(".dropzone").AttrOr("data-link-url", "")
+ assert.Equal(t, fmt.Sprintf("/%s/%s-1/upload-file", user, repo), uploadLink)
+ newBranchName := uploadForm.Find("input[name=new_branch_name]").AttrOr("value", "")
+ assert.Equal(t, user+"-patch-1", newBranchName)
+ commitChoice := uploadForm.Find("input[name=commit_choice][checked]").AttrOr("value", "")
+ assert.Equal(t, "commit-to-new-branch", commitChoice)
+ lastCommit := uploadForm.Find("input[name=last_commit]").AttrOr("value", "")
+ assert.NotEmpty(t, lastCommit)
+ })
+
+ t.Run("ViewBaseEditFormAndCommitToFork", func(t *testing.T) {
+ req := NewRequest(t, "GET", path.Join(owner, repo, "_edit", branch, filePath))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ editRequestForm := map[string]string{
+ "_csrf": GetUserCSRFToken(t, session),
+ "last_commit": htmlDoc.GetInputValueByName("last_commit"),
+ "tree_path": filePath,
+ "content": "new content in fork",
+ "commit_choice": "commit-to-new-branch",
+ }
+ // change a file in the forked repo with existing branch name (should fail)
+ editRequestForm["new_branch_name"] = "master"
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s-1/_edit/%s/%s?from_base_branch=%s", user, repo, branch, filePath, branch), editRequestForm)
+ resp = session.MakeRequest(t, req, http.StatusBadRequest)
+ respJSON := test.ParseJSONError(resp.Body.Bytes())
+ assert.Equal(t, `Branch "master" already exists in your fork, please choose a new branch name.`, respJSON.ErrorMessage)
+
+ // change a file in the forked repo (should succeed)
+ newBranchName := htmlDoc.GetInputValueByName("new_branch_name")
+ editRequestForm["new_branch_name"] = newBranchName
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s-1/_edit/%s/%s?from_base_branch=%s", user, repo, branch, filePath, branch), editRequestForm)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, fmt.Sprintf("/%s/%s/compare/%s...%s/%s-1:%s", owner, repo, branch, user, repo, newBranchName), test.RedirectURL(resp))
+
+ // check the file in the fork's branch is changed
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s-1/src/branch/%s/%s", user, repo, newBranchName, filePath))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), "new content in fork")
+ })
+}
+
+func testEditFileNotAllowed(t *testing.T) {
+ sessionUser1 := loginUser(t, "user1") // admin, all access
+ sessionUser4 := loginUser(t, "user4")
+ // "_cherrypick" has a different route pattern, so skip its test
+ operations := []string{"_new", "_edit", "_delete", "_upload", "_diffpatch"}
+ for _, operation := range operations {
+ t.Run(operation, func(t *testing.T) {
+ // Branch does not exist
+ targetLink := path.Join("user2", "repo1", operation, "missing", "README.md")
+ sessionUser1.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusNotFound)
+
+ // Private repository
+ targetLink = path.Join("user2", "repo2", operation, "master", "Home.md")
+ sessionUser1.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusOK)
+ sessionUser4.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusNotFound)
+
+ // Empty repository
+ targetLink = path.Join("org41", "repo61", operation, "master", "README.md")
+ sessionUser1.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusNotFound)
+ })
+ }
}
diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go
index 874fc32228..4d589b32e7 100644
--- a/tests/integration/html_helper.go
+++ b/tests/integration/html_helper.go
@@ -42,7 +42,7 @@ func (doc *HTMLDoc) GetCSRF() string {
return doc.GetInputValueByName("_csrf")
}
-// AssertHTMLElement check if element by selector exists or does not exist depending on checkExists
+// AssertHTMLElement check if the element by selector exists or does not exist depending on checkExists
func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string, checkExists T) {
sel := doc.doc.Find(selector)
switch v := any(checkExists).(type) {
diff --git a/tests/integration/pull_status_test.go b/tests/integration/pull_status_test.go
index bfcb97b082..49326a594a 100644
--- a/tests/integration/pull_status_test.go
+++ b/tests/integration/pull_status_test.go
@@ -129,7 +129,7 @@ func TestPullCreate_EmptyChangesWithDifferentCommits(t *testing.T) {
session := loginUser(t, "user1")
testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "")
testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1")
- testEditFileToNewBranch(t, session, "user1", "repo1", "status1", "status1", "README.md", "# repo1\n\nDescription for repo1")
+ testEditFile(t, session, "user1", "repo1", "status1", "README.md", "# repo1\n\nDescription for repo1")
url := path.Join("user1", "repo1", "compare", "master...status1")
req := NewRequestWithValues(t, "POST", url,
diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts
index 9359713454..a372216ae6 100644
--- a/web_src/js/features/common-fetch-action.ts
+++ b/web_src/js/features/common-fetch-action.ts
@@ -5,7 +5,7 @@ import {confirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
-const {appSubUrl, i18n} = window.config;
+const {appSubUrl} = window.config;
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler
@@ -23,11 +23,20 @@ function fetchActionDoRedirect(redirect: string) {
}
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
+ const showErrorForResponse = (code: number, message: string) => {
+ showErrorToast(`Error ${code || 'request'}: ${message}`);
+ };
+
+ let respStatus = 0;
+ let respText = '';
try {
hideToastsAll();
const resp = await request(url, opt);
- if (resp.status === 200) {
- let {redirect} = await resp.json();
+ respStatus = resp.status;
+ respText = await resp.text();
+ const respJson = JSON.parse(respText);
+ if (respStatus === 200) {
+ let {redirect} = respJson;
redirect = redirect || actionElem.getAttribute('data-redirect');
ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading
if (redirect) {
@@ -38,22 +47,19 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R
return;
}
- if (resp.status >= 400 && resp.status < 500) {
- const data = await resp.json();
+ if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
- if (data.errorMessage) {
- showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'});
- } else {
- showErrorToast(`server error: ${resp.status}`);
- }
+ showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
- showErrorToast(`server error: ${resp.status}`);
+ showErrorForResponse(respStatus, respText);
}
} catch (e) {
- if (e.name !== 'AbortError') {
- console.error('error when doRequest', e);
- showErrorToast(`${i18n.network_error} ${e}`);
+ if (e.name === 'SyntaxError') {
+ showErrorForResponse(respStatus, (respText || '').substring(0, 100));
+ } else if (e.name !== 'AbortError') {
+ console.error('fetchActionDoRequest error', e);
+ showErrorForResponse(respStatus, `${e}`);
}
}
actionElem.classList.remove('is-loading', 'loading-icon-2px');