0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-06-24 19:24:07 +02:00

Edit file workflow for creating a fork and proposing changes (#34240)

When viewing a file that the user can't edit because they can't write to
the branch, the new, upload, patch, edit and delete functionality is no
longer disabled.

If no user fork of the repository exists, there is now a page to create one.
It will automatically create a fork with a single branch matching the one
being viewed, and a unique repository name will be automatically picked.

When a fork exists, but it's archived, a mirror or the user can't write
code to it, there will instead be a message explaining the situation.

If the usable fork exists, a message will appear at the top of the edit page
explaining that the changes will be applied to a branch in the fork. The
base repository branch will be pushed to a new branch to the fork, and
then the edits will be applied on top.

The suggestion to fork happens when accessing /_edit/, so that for
example online documentation can have an "edit this page" link to
the base repository that does the right thing.

Also includes changes to properly report errors when trying to commit
to a new branch that is protected, and when trying to commit to an
existing branch when choosing the new branch option.

Resolves #9017, #20882

---------

Co-authored-by: Brecht Van Lommel <brecht@blender.org>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Brecht Van Lommel 2025-06-22 14:43:43 +02:00 committed by GitHub
parent 1748045285
commit a46b16f10f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 753 additions and 432 deletions

View File

@ -653,7 +653,7 @@ func (repo *Repository) AllowsPulls(ctx context.Context) bool {
// CanEnableEditor returns true if repository meets the requirements of web editor. // CanEnableEditor returns true if repository meets the requirements of web editor.
func (repo *Repository) CanEnableEditor() bool { func (repo *Repository) CanEnableEditor() bool {
return !repo.IsMirror return !repo.IsMirror && !repo.IsArchived
} }
// DescriptionHTML does special handles to description and return HTML string. // DescriptionHTML does special handles to description and return HTML string.

View File

@ -1399,6 +1399,13 @@ editor.revert = Revert %s onto:
editor.failed_to_commit = Failed to commit changes. editor.failed_to_commit = Failed to commit changes.
editor.failed_to_commit_summary = Error Message: 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.desc = Browse source code change history.
commits.commits = Commits commits.commits = Commits
commits.no_commits = No commits in common. "%s" and "%s" have entirely different histories. commits.no_commits = No commits in common. "%s" and "%s" have entirely different histories.

View File

@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
@ -39,7 +40,7 @@ const (
editorCommitChoiceNewBranch string = "commit-to-new-branch" 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) cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
if cleanedTreePath != 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)) 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 redirectTo += "?" + ctx.Req.URL.RawQuery
} }
ctx.Redirect(redirectTo) 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 { if err != nil {
ctx.ServerError("PrepareCommitFormBehaviors", err) ctx.ServerError("PrepareCommitFormOptions", err)
return 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["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.Data["TreePath"] = ctx.Repo.TreePath ctx.Data["TreePath"] = ctx.Repo.TreePath
ctx.Data["CommitFormBehaviors"] = commitFormBehaviors ctx.Data["CommitFormOptions"] = commitFormOptions
// for online editor // for online editor
ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",") ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",")
@ -69,25 +80,27 @@ func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) {
// form fields // form fields
ctx.Data["commit_summary"] = "" ctx.Data["commit_summary"] = ""
ctx.Data["commit_message"] = "" ctx.Data["commit_message"] = ""
ctx.Data["commit_choice"] = util.Iif(commitFormBehaviors.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch) ctx.Data["commit_choice"] = util.Iif(commitFormOptions.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch)
ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, ctx.Repo.Repository) ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, commitFormOptions.TargetRepo)
ctx.Data["last_commit"] = ctx.Repo.CommitID ctx.Data["last_commit"] = ctx.Repo.CommitID
return commitFormOptions
} }
func prepareTreePathFieldsAndPaths(ctx *context.Context, treePath string) { func prepareTreePathFieldsAndPaths(ctx *context.Context, treePath string) {
// show the tree path fields in the "breadcrumb" and help users to edit the target tree path // 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 { type preparedEditorCommitForm[T any] struct {
form T form T
commonForm *forms.CommitCommonForm commonForm *forms.CommitCommonForm
CommitFormBehaviors *context.CommitFormBehaviors CommitFormOptions *context.CommitFormOptions
TargetBranchName string OldBranchName string
GitCommitter *files_service.IdentityOptions 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) commitMessage := util.IfZero(strings.TrimSpace(f.commonForm.CommitSummary), defaultCommitMessage)
if body := strings.TrimSpace(f.commonForm.CommitMessage); body != "" { if body := strings.TrimSpace(f.commonForm.CommitMessage); body != "" {
commitMessage += "\n\n" + body commitMessage += "\n\n" + body
@ -95,7 +108,7 @@ func (f *parsedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string
return commitMessage 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) form := web.GetForm(ctx).(T)
if ctx.HasError() { if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg()) ctx.JSONError(ctx.GetErrMsg())
@ -105,15 +118,22 @@ func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *cont
commonForm := form.GetCommitCommonForm() commonForm := form.GetCommitCommonForm()
commonForm.TreePath = files_service.CleanGitTreePath(commonForm.TreePath) 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 { 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 return nil
} }
// check commit behavior // check commit behavior
targetBranchName := util.Iif(commonForm.CommitChoice == editorCommitChoiceNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName) fromBaseBranch := ctx.FormString("from_base_branch")
if targetBranchName == ctx.Repo.BranchName && !commitFormBehaviors.CanCommitToBranch { 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)) ctx.JSONError(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", targetBranchName))
return nil return nil
} }
@ -125,28 +145,63 @@ func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *cont
return nil return nil
} }
return &parsedEditorCommitForm[T]{ if commitToNewBranch {
form: form, // if target branch exists, we should stop
commonForm: commonForm, targetBranchExists, err := git_model.IsBranchExist(ctx, commitFormOptions.TargetRepo.ID, targetBranchName)
CommitFormBehaviors: commitFormBehaviors, if err != nil {
TargetBranchName: targetBranchName, ctx.ServerError("IsBranchExist", err)
GitCommitter: gitCommitter, 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 // 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 { if parsed.commonForm.CommitChoice == editorCommitChoiceNewBranch {
// Redirect to a pull request when possible // Redirect to a pull request when possible
redirectToPullRequest := false redirectToPullRequest := false
repo, baseBranch, headBranch := ctx.Repo.Repository, ctx.Repo.BranchName, parsed.TargetBranchName repo, baseBranch, headBranch := ctx.Repo.Repository, parsed.OldBranchName, parsed.NewBranchName
if repo.UnitEnabled(ctx, unit.TypePullRequests) { if ctx.Repo.Repository.IsFork && parsed.CommitFormOptions.CanCreateBasePullRequest {
redirectToPullRequest = true
} else if parsed.CommitFormBehaviors.CanCreateBasePullRequest {
redirectToPullRequest = true redirectToPullRequest = true
baseBranch = repo.BaseRepo.DefaultBranch baseBranch = repo.BaseRepo.DefaultBranch
headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch
repo = repo.BaseRepo repo = repo.BaseRepo
} else if repo.UnitEnabled(ctx, unit.TypePullRequests) {
redirectToPullRequest = true
} }
if redirectToPullRequest { if redirectToPullRequest {
ctx.JSONRedirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch)) 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") // redirect to the newly updated file
if returnURI == "" || !httplib.IsCurrentGiteaSiteURL(ctx, returnURI) { redirectTo := util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.NewBranchName), util.PathEscapeSegments(treePath))
returnURI = util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.TargetBranchName), util.PathEscapeSegments(treePath)) ctx.JSONRedirect(redirectTo)
}
ctx.JSONRedirect(returnURI)
} }
func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.ReadCloser, fInfo *fileInfo) { 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) { func EditFilePost(ctx *context.Context) {
editorAction := ctx.PathParam("editor_action") editorAction := ctx.PathParam("editor_action")
isNewFile := editorAction == "_new" isNewFile := editorAction == "_new"
parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -292,8 +345,8 @@ func EditFilePost(ctx *context.Context) {
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.TargetBranchName, NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage), Message: parsed.GetCommitMessage(defaultCommitMessage),
Files: []*files_service.ChangeRepoFile{ Files: []*files_service.ChangeRepoFile{
{ {
@ -308,7 +361,7 @@ func EditFilePost(ctx *context.Context) {
Committer: parsed.GitCommitter, Committer: parsed.GitCommitter,
}) })
if err != nil { if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return return
} }
@ -327,7 +380,7 @@ func DeleteFile(ctx *context.Context) {
// DeleteFilePost response for deleting file // DeleteFilePost response for deleting file
func DeleteFilePost(ctx *context.Context) { func DeleteFilePost(ctx *context.Context) {
parsed := parseEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -335,8 +388,8 @@ func DeleteFilePost(ctx *context.Context) {
treePath := ctx.Repo.TreePath treePath := ctx.Repo.TreePath
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.TargetBranchName, NewBranch: parsed.NewBranchName,
Files: []*files_service.ChangeRepoFile{ Files: []*files_service.ChangeRepoFile{
{ {
Operation: "delete", Operation: "delete",
@ -349,29 +402,29 @@ func DeleteFilePost(ctx *context.Context) {
Committer: parsed.GitCommitter, Committer: parsed.GitCommitter,
}) })
if err != nil { if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return return
} }
ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath)) 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) redirectForCommitChoice(ctx, parsed, redirectTreePath)
} }
func UploadFile(ctx *context.Context) { func UploadFile(ctx *context.Context) {
ctx.Data["PageIsUpload"] = true ctx.Data["PageIsUpload"] = true
upload.AddUploadContext(ctx, "repo")
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath) prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
opts := prepareEditorCommitFormOptions(ctx, "_upload")
prepareEditorCommitFormOptions(ctx, "_upload")
if ctx.Written() { if ctx.Written() {
return return
} }
upload.AddUploadContextForRepo(ctx, opts.TargetRepo)
ctx.HTML(http.StatusOK, tplUploadFile) ctx.HTML(http.StatusOK, tplUploadFile)
} }
func UploadFilePost(ctx *context.Context) { func UploadFilePost(ctx *context.Context) {
parsed := parseEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx)
if ctx.Written() { if ctx.Written() {
return 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, "/")) 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{ err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.TargetBranchName, NewBranch: parsed.NewBranchName,
TreePath: parsed.form.TreePath, TreePath: parsed.form.TreePath,
Message: parsed.GetCommitMessage(defaultCommitMessage), Message: parsed.GetCommitMessage(defaultCommitMessage),
Files: parsed.form.Files, Files: parsed.form.Files,
@ -389,7 +442,7 @@ func UploadFilePost(ctx *context.Context) {
Committer: parsed.GitCommitter, Committer: parsed.GitCommitter,
}) })
if err != nil { if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return return
} }
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath) redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)

View File

@ -25,7 +25,7 @@ func NewDiffPatch(ctx *context.Context) {
// NewDiffPatchPost response for sending patch page // NewDiffPatchPost response for sending patch page
func NewDiffPatchPost(ctx *context.Context) { func NewDiffPatchPost(ctx *context.Context) {
parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -33,8 +33,8 @@ func NewDiffPatchPost(ctx *context.Context) {
defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch") defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch")
_, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{ _, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.TargetBranchName, NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage), Message: parsed.GetCommitMessage(defaultCommitMessage),
Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"), Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"),
Author: parsed.GitCommitter, Author: parsed.GitCommitter,
@ -44,7 +44,7 @@ func NewDiffPatchPost(ctx *context.Context) {
err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch") err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch")
} }
if err != nil { if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return return
} }
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath) redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)

View File

@ -45,7 +45,7 @@ func CherryPick(ctx *context.Context) {
func CherryPickPost(ctx *context.Context) { func CherryPickPost(ctx *context.Context) {
fromCommitID := ctx.PathParam("sha") fromCommitID := ctx.PathParam("sha")
parsed := parseEditorCommitSubmittedForm[*forms.CherryPickForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.CherryPickForm](ctx)
if ctx.Written() { if ctx.Written() {
return 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)) 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{ opts := &files.ApplyDiffPatchOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.TargetBranchName, NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage), Message: parsed.GetCommitMessage(defaultCommitMessage),
Author: parsed.GitCommitter, Author: parsed.GitCommitter,
Committer: parsed.GitCommitter, Committer: parsed.GitCommitter,
@ -78,7 +78,7 @@ func CherryPickPost(ctx *context.Context) {
} }
} }
if err != nil { if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return return
} }
} }

View File

@ -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
}

View File

@ -11,9 +11,11 @@ import (
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo" 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/git"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository"
context_service "code.gitea.io/gitea/services/context" context_service "code.gitea.io/gitea/services/context"
) )
@ -83,3 +85,26 @@ func getParentTreeFields(treePath string) (treeNames, treePaths []string) {
} }
return treeNames, treePaths 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),
})
}

View File

@ -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, BaseRepo: forkRepo,
Name: form.RepoName, Name: form.RepoName,
Description: form.Description, Description: form.Description,
SingleBranch: form.ForkSingleBranch, 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 { if err != nil {
ctx.Data["Err_RepoName"] = true ctx.Data["Err_RepoName"] = true
switch { switch {
case repo_model.IsErrReachLimitOfRepo(err): 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) msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit)
ctx.JSONError(msg) ctx.JSONError(msg)
case repo_model.IsErrRepoAlreadyExist(err): case repo_model.IsErrRepoAlreadyExist(err):
@ -224,9 +232,7 @@ func ForkPost(ctx *context.Context) {
default: default:
ctx.ServerError("ForkPost", err) ctx.ServerError("ForkPost", err)
} }
return return nil
} }
return repo
log.Trace("Repository forked[%d]: %s/%s", forkRepo.ID, ctxUser.Name, repo.Name)
ctx.JSONRedirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name))
} }

View File

@ -290,7 +290,7 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) { func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
// archived or mirror repository, the buttons should not be shown // 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 return
} }
@ -302,7 +302,9 @@ func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
} }
if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { 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["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
ctx.Data["CanDeleteFile"] = true
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access")
return return
} }

View File

@ -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) 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 ctx.Data["CanEditReadmeFile"] = true
} }
} }

View File

@ -1313,23 +1313,35 @@ func registerWebRoutes(m *web.Router) {
}, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived()) }, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived())
// end "/{username}/{reponame}": create or edit issues, pulls, labels, milestones // 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.Group("", func() { m.Group("", func() {
m.Post("/_preview/*", repo.DiffPreviewPost) // "GET" requests only need "code reader" permission, "POST" requests need "code writer" permission.
m.Combo("/{editor_action:_edit}/*").Get(repo.EditFile). // Because reader can "fork and edit"
Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost) canWriteToBranch := context.CanWriteToBranch()
m.Combo("/{editor_action:_new}/*").Get(repo.EditFile). m.Post("/_preview/*", repo.DiffPreviewPost) // read-only, fine with "code reader"
Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost) m.Post("/_fork/*", repo.ForkToEditPost) // read-only, fork to own repo, fine with "code reader"
m.Combo("/_delete/*").Get(repo.DeleteFile).
Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost) // the path params are used in PrepareCommitFormOptions to construct the correct form action URL
m.Combo("/_upload/*", repo.MustBeAbleToUpload).Get(repo.UploadFile). m.Combo("/{editor_action:_edit}/*").
Post(web.Bind(forms.UploadRepoFileForm{}), repo.UploadFilePost) Get(repo.EditFile).
m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch). Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.EditFilePost)
Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost) m.Combo("/{editor_action:_new}/*").
m.Combo("/_cherrypick/{sha:([a-f0-9]{7,64})}/*").Get(repo.CherryPick). Get(repo.EditFile).
Post(web.Bind(forms.CherryPickForm{}), repo.CherryPickPost) Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.EditFilePost)
}, context.RepoRefByType(git.RefTypeBranch), context.CanWriteToBranch(), repo.WebGitOperationCommonData) 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.Group("", func() {
m.Post("/upload-file", repo.UploadFileToServer) m.Post("/upload-file", repo.UploadFileToServer)
m.Post("/upload-remove", repo.RemoveUploadFileFromServer) m.Post("/upload-remove", repo.RemoveUploadFileFromServer)

View File

@ -71,11 +71,6 @@ func (r *Repository) CanWriteToBranch(ctx context.Context, user *user_model.User
return issues_model.CanMaintainerWriteToBranch(ctx, r.Permission, branch, 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. // CanCreateBranch returns true if repository is editable and user has proper access level.
func (r *Repository) CanCreateBranch() bool { func (r *Repository) CanCreateBranch() bool {
return r.Permission.CanWrite(unit_model.TypeCode) && r.Repository.CanCreateBranch() 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 CanCommitToBranch bool
EditorEnabled bool
UserCanPush bool UserCanPush bool
RequireSigned bool RequireSigned bool
WillSign bool WillSign bool
@ -106,51 +105,84 @@ type CommitFormBehaviors struct {
CanCreateBasePullRequest bool CanCreateBasePullRequest bool
} }
func (r *Repository) PrepareCommitFormBehaviors(ctx *Context, doer *user_model.User) (*CommitFormBehaviors, error) { func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *repo_model.Repository, doerRepoPerm access_model.Permission, refName git.RefName) (*CommitFormOptions, error) {
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, r.Repository.ID, r.BranchName) 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 { if err != nil {
return nil, err return nil, err
} }
userCanPush := true
requireSigned := false
if protectedBranch != nil {
protectedBranch.Repo = r.Repository
userCanPush = protectedBranch.CanUserPush(ctx, doer)
requireSigned = protectedBranch.RequireSignedCommits
}
sign, keyID, _, err := asymkey_service.SignCRUDAction(ctx, r.Repository.RepoPath(), doer, r.Repository.RepoPath(), git.BranchPrefix+r.BranchName) protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, targetRepo.ID, branchName)
canEnableEditor := r.CanEnableEditor(ctx, doer)
canCommit := canEnableEditor && userCanPush
if requireSigned {
canCommit = canCommit && sign
}
wontSignReason := ""
if err != nil { if err != nil {
if asymkey_service.IsErrWontSign(err) { return nil, err
wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason) }
err = nil canPushWithProtection := true
} else { protectionRequireSigned := false
wontSignReason = "error" if protectedBranch != nil {
} protectedBranch.Repo = targetRepo
canPushWithProtection = protectedBranch.CanUserPush(ctx, doer)
protectionRequireSigned = protectedBranch.RequireSignedCommits
} }
canCreateBasePullRequest := ctx.Repo.Repository.BaseRepo != nil && ctx.Repo.Repository.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) willSign, signKeyID, _, err := asymkey_service.SignCRUDAction(ctx, targetRepo.RepoPath(), doer, targetRepo.RepoPath(), refName.String())
canCreatePullRequest := ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypePullRequests) || canCreateBasePullRequest wontSignReason := ""
if asymkey_service.IsErrWontSign(err) {
wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason)
} else if err != nil {
return nil, err
}
return &CommitFormBehaviors{ canCommitToBranch := !submitToForkedRepo /* same repo */ && targetRepo.CanEnableEditor() && canPushWithProtection
CanCommitToBranch: canCommit, if protectionRequireSigned {
EditorEnabled: canEnableEditor, canCommitToBranch = canCommitToBranch && willSign
UserCanPush: userCanPush, }
RequireSigned: requireSigned,
WillSign: sign, canCreateBasePullRequest := targetRepo.BaseRepo != nil && targetRepo.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests)
SigningKey: keyID, canCreatePullRequest := targetRepo.UnitEnabled(ctx, unit_model.TypePullRequests) || canCreateBasePullRequest
opts := &CommitFormOptions{
TargetRepo: targetRepo,
WillSubmitToFork: submitToForkedRepo,
CanCommitToBranch: canCommitToBranch,
UserCanPush: canPushWithProtection,
RequireSigned: protectionRequireSigned,
WillSign: willSign,
SigningKey: signKeyID,
WontSignReason: wontSignReason, WontSignReason: wontSignReason,
CanCreatePullRequest: canCreatePullRequest, CanCreatePullRequest: canCreatePullRequest,
CanCreateBasePullRequest: canCreateBasePullRequest, 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. // CanUseTimetracker returns whether a user can use the timetracker.

View File

@ -11,7 +11,9 @@ import (
"regexp" "regexp"
"strings" "strings"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context" "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["UploadAccepts"] = strings.ReplaceAll(setting.Attachment.AllowedTypes, "|", ",")
ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles
ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize 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: default:
setting.PanicInDevOrTesting("Invalid upload type: %s", uploadType) 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
}

View File

@ -3,8 +3,9 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{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}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<input type="hidden" name="revert" value="{{if eq .CherryPickType "revert"}}true{{else}}false{{end}}"> <input type="hidden" name="revert" value="{{if eq .CherryPickType "revert"}}true{{else}}false{{end}}">
<div class="repo-editor-header"> <div class="repo-editor-header">
<div class="breadcrumb"> <div class="breadcrumb">

View File

@ -1,11 +1,11 @@
<div class="commit-form-wrapper"> <div class="commit-form-wrapper">
{{ctx.AvatarUtils.Avatar .SignedUser 40 "commit-avatar"}} {{ctx.AvatarUtils.Avatar .SignedUser 40 "commit-avatar"}}
<div class="commit-form"> <div class="commit-form">
<h3>{{- if .CommitFormBehaviors.WillSign}} <h3>{{- if .CommitFormOptions.WillSign}}
<span title="{{ctx.Locale.Tr "repo.signing.will_sign" .CommitFormBehaviors.SigningKey}}">{{svg "octicon-lock" 24}}</span> <span title="{{ctx.Locale.Tr "repo.signing.will_sign" .CommitFormOptions.SigningKey}}">{{svg "octicon-lock" 24}}</span>
{{ctx.Locale.Tr "repo.editor.commit_signed_changes"}} {{ctx.Locale.Tr "repo.editor.commit_signed_changes"}}
{{- else}} {{- 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"}} {{ctx.Locale.Tr "repo.editor.commit_changes"}}
{{- end}}</h3> {{- end}}</h3>
<div class="field"> <div class="field">
@ -22,17 +22,17 @@
</div> </div>
<div class="quick-pull-choice js-quick-pull-choice"> <div class="quick-pull-choice js-quick-pull-choice">
<div class="field"> <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}}> <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> <label>
{{svg "octicon-git-commit"}} {{svg "octicon-git-commit"}}
{{ctx.Locale.Tr "repo.editor.commit_directly_to_this_branch" .BranchName}} {{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"> <div class="ui visible small warning message">
{{ctx.Locale.Tr "repo.editor.no_commit_to_branch"}} {{ctx.Locale.Tr "repo.editor.no_commit_to_branch"}}
<ul> <ul>
{{if not .CommitFormBehaviors.UserCanPush}}<li>{{ctx.Locale.Tr "repo.editor.user_no_push_to_branch"}}</li>{{end}} {{if not .CommitFormOptions.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 and .CommitFormOptions.RequireSigned (not .CommitFormOptions.WillSign)}}<li>{{ctx.Locale.Tr "repo.editor.require_signed_commit"}}</li>{{end}}
</ul> </ul>
</div> </div>
{{end}} {{end}}
@ -42,14 +42,14 @@
{{if and (not .Repository.IsEmpty) (not .IsEditingFileOnly)}} {{if and (not .Repository.IsEmpty) (not .IsEditingFileOnly)}}
<div class="field"> <div class="field">
<div class="ui radio checkbox"> <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}}> <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}} {{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}}> <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}} {{end}}
<label> <label>
{{svg "octicon-git-pull-request"}} {{svg "octicon-git-pull-request"}}
{{if .CommitFormBehaviors.CanCreatePullRequest}} {{if .CommitFormOptions.CanCreatePullRequest}}
{{ctx.Locale.Tr "repo.editor.create_new_branch"}} {{ctx.Locale.Tr "repo.editor.create_new_branch"}}
{{else}} {{else}}
{{ctx.Locale.Tr "repo.editor.create_new_branch_np"}} {{ctx.Locale.Tr "repo.editor.create_new_branch_np"}}

View File

@ -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}}

View File

@ -3,8 +3,9 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{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}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
{{template "repo/editor/commit_form" .}} {{template "repo/editor/commit_form" .}}
</form> </form>
</div> </div>

View File

@ -3,11 +3,12 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{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-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}"
data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}" data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}"
> >
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header"> <div class="repo-editor-header">
{{template "repo/editor/common_breadcrumb" .}} {{template "repo/editor/common_breadcrumb" .}}
</div> </div>

View File

@ -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" .}}

View File

@ -3,11 +3,12 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{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-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}"
data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}" data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}"
> >
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header"> <div class="repo-editor-header">
<div class="breadcrumb"> <div class="breadcrumb">
{{ctx.Locale.Tr "repo.editor.patching"}} {{ctx.Locale.Tr "repo.editor.patching"}}

View File

@ -3,8 +3,9 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{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}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header"> <div class="repo-editor-header">
{{template "repo/editor/common_breadcrumb" .}} {{template "repo/editor/common_breadcrumb" .}}
</div> </div>

View File

@ -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> <a href="{{.Repository.Link}}/find/{{.RefTypeNameSubURL}}" class="ui compact basic button">{{ctx.Locale.Tr "repo.find_file.go_to_file"}}</a>
{{end}} {{end}}
{{if and .CanWriteCode .RefFullName.IsBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} {{if and .RefFullName.IsBranch (not .IsViewFile)}}
<button class="ui dropdown basic compact jump button"{{if not .Repository.CanEnableEditor}} disabled{{end}}> <button class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanEnableEditor}}disabled{{end}}>
{{ctx.Locale.Tr "repo.editor.add_file"}} {{ctx.Locale.Tr "repo.editor.add_file"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu"> <div class="menu">

View File

@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"maps"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -19,292 +20,278 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestCreateFile(t *testing.T) { func TestEditor(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user2") sessionUser2 := loginUser(t, "user2")
testCreateFile(t, session, "user2", "repo1", "master", "test.txt", "Content") 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 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.`)
}
func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) { func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) {
// Request editor page testEditorActionEdit(t, session, user, repo, "_new", branch, "", map[string]string{
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)
// Save new file to master branch
req = NewRequestWithValues(t, "POST", newURL, map[string]string{
"_csrf": doc.GetCSRF(),
"last_commit": lastCommit,
"tree_path": filePath, "tree_path": filePath,
"content": content, "content": content,
"commit_choice": "direct", "commit_choice": "direct",
}) })
resp = session.MakeRequest(t, req, http.StatusOK)
assert.NotEmpty(t, test.RedirectURL(resp))
} }
func TestCreateFileOnProtectedBranch(t *testing.T) { func testEditorProtectedBranch(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user2")
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)
csrf := GetUserCSRFToken(t, session) // Try to commit a file to the "master" branch and it should fail
// Change master branch to protected resp := testEditorActionPostRequest(t, session, "/user2/repo1/_new/master/", map[string]string{"tree_path": "test-protected-branch.txt", "commit_choice": "direct"})
req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ assert.Equal(t, http.StatusBadRequest, resp.Code)
"_csrf": csrf, assert.Equal(t, `Cannot commit to protected branch "master".`, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
"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 func testEditorActionPostRequest(t *testing.T, session *TestSession, requestPath string, params map[string]string) *httptest.ResponseRecorder {
req = NewRequest(t, "GET", "/user2/repo1/_new/master/") req := NewRequest(t, "GET", requestPath)
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
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 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)
}
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))
req := NewRequest(t, "GET", path.Join(user, repo, "raw/branch", newBranchName, params["tree_path"]))
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, params["content"], resp.Body.String())
return resp
}
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, 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 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>`)
}
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)
}
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) resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body) respMap := map[string]string{}
lastCommit := doc.GetInputValueByName("last_commit") DecodeJSON(t, resp, &respMap)
assert.NotEmpty(t, lastCommit) return respMap["uuid"]
}
// Save new file to master branch t.Run("EmailInactive", func(t *testing.T) {
req = NewRequestWithValues(t, "POST", "/user2/repo1/_new/master/", map[string]string{ defer tests.PrintCurrentTest(t)()
"_csrf": doc.GetCSRF(), email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID})
"last_commit": lastCommit, require.False(t, email.IsActivated)
"tree_path": "test.txt", makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
"content": "Content", "tree_path": "README.md",
"commit_choice": "direct", "content": "test content",
}) "commit_email": email.Email,
}, "", "")
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 testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath, newContent string) *httptest.ResponseRecorder { t.Run("EmailInvalid", func(t *testing.T) {
// Get to the 'edit this file' page defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath)) email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true})
resp := session.MakeRequest(t, req, http.StatusOK) require.NotEqual(t, email.UID, user.ID)
makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
htmlDoc := NewHTMLParser(t, resp.Body) "tree_path": "README.md",
lastCommit := htmlDoc.GetInputValueByName("last_commit") "content": "test content",
assert.NotEmpty(t, lastCommit) "commit_email": email.Email,
}, "", "")
// 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
}
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)
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)
assert.NotEmpty(t, test.RedirectURL(resp))
// Verify the change
req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", targetBranch, filePath))
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, newContent, 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 TestEditFileToNewBranch(t *testing.T) { testWebGit := func(t *testing.T, linkForKeepPrivate string, paramsForKeepPrivate map[string]string, linkForChosenEmail string, paramsForChosenEmail map[string]string) (resp1, resp2 *httptest.ResponseRecorder) {
onGiteaRun(t, func(t *testing.T, u *url.URL) { t.Run("DefaultEmailKeepPrivate", func(t *testing.T) {
session := loginUser(t, "user2")
testEditFileToNewBranch(t, session, "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited)\n")
})
}
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
}
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)
respMap := map[string]string{}
DecodeJSON(t, resp, &respMap)
return respMap["uuid"]
}
t.Run("EmailInactive", func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}) paramsForKeepPrivate["commit_email"] = ""
require.False(t, email.IsActivated) resp1 = makeReq(t, linkForKeepPrivate, paramsForKeepPrivate, "User Two", "user2@noreply.example.org")
makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
"tree_path": "README.md",
"content": "test content",
"commit_email": email.Email,
}, "", "")
}) })
t.Run("ChooseEmail", func(t *testing.T) {
t.Run("EmailInvalid", func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true}) paramsForChosenEmail["commit_email"] = "user2@example.com"
require.NotEqual(t, email.UID, user.ID) resp2 = makeReq(t, linkForChosenEmail, paramsForChosenEmail, "User Two", "user2@example.com")
makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
"tree_path": "README.md",
"content": "test content",
"commit_email": email.Email,
}, "", "")
}) })
return resp1, resp2
}
testWebGit := func(t *testing.T, linkForKeepPrivate string, paramsForKeepPrivate map[string]string, linkForChosenEmail string, paramsForChosenEmail map[string]string) (resp1, resp2 *httptest.ResponseRecorder) { t.Run("Edit", func(t *testing.T) {
t.Run("DefaultEmailKeepPrivate", func(t *testing.T) { testWebGit(t,
defer tests.PrintCurrentTest(t)() "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for keep private"},
paramsForKeepPrivate["commit_email"] = "" "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for chosen 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) { t.Run("UploadDelete", func(t *testing.T) {
testWebGit(t, file1UUID := uploadFile(t, "file1", "File 1")
"/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for keep private"}, file2UUID := uploadFile(t, "file2", "File 2")
"/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for chosen email"}, 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) { t.Run("ApplyPatchCherryPick", func(t *testing.T) {
file1UUID := uploadFile(t, "file1", "File 1") testWebGit(t,
file2UUID := uploadFile(t, "file2", "File 2") "/user2/repo1/_diffpatch/master", map[string]string{
testWebGit(t, "tree_path": "__dummy__",
"/user2/repo1/_upload/master", map[string]string{"files": file1UUID}, "content": `diff --git a/patch-file-1.txt b/patch-file-1.txt
"/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
new file mode 100644 new file mode 100644
index 0000000000..aaaaaaaaaa index 0000000000..aaaaaaaaaa
--- /dev/null --- /dev/null
@ -312,10 +299,10 @@ index 0000000000..aaaaaaaaaa
@@ -0,0 +1 @@ @@ -0,0 +1 @@
+File 1 +File 1
`, `,
}, },
"/user2/repo1/_diffpatch/master", map[string]string{ "/user2/repo1/_diffpatch/master", map[string]string{
"tree_path": "__dummy__", "tree_path": "__dummy__",
"content": `diff --git a/patch-file-2.txt b/patch-file-2.txt "content": `diff --git a/patch-file-2.txt b/patch-file-2.txt
new file mode 100644 new file mode 100644
index 0000000000..bbbbbbbbbb index 0000000000..bbbbbbbbbb
--- /dev/null --- /dev/null
@ -323,20 +310,146 @@ index 0000000000..bbbbbbbbbb
@@ -0,0 +1 @@ @@ -0,0 +1 @@
+File 2 +File 2
`, `,
}, },
) )
commit1, err := gitRepo.GetCommitByPath("patch-file-1.txt") commit1, err := gitRepo.GetCommitByPath("patch-file-1.txt")
require.NoError(t, err) require.NoError(t, err)
commit2, err := gitRepo.GetCommitByPath("patch-file-2.txt") commit2, err := gitRepo.GetCommitByPath("patch-file-2.txt")
require.NoError(t, err) require.NoError(t, err)
resp1, _ := testWebGit(t, resp1, _ := testWebGit(t,
"/user2/repo1/_cherrypick/"+commit1.ID.String()+"/master", map[string]string{"revert": "true"}, "/user2/repo1/_cherrypick/"+commit1.ID.String()+"/master", map[string]string{"revert": "true"},
"/user2/repo1/_cherrypick/"+commit2.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 // 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)) 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)
})
}
}

View File

@ -42,7 +42,7 @@ func (doc *HTMLDoc) GetCSRF() string {
return doc.GetInputValueByName("_csrf") 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) { func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string, checkExists T) {
sel := doc.doc.Find(selector) sel := doc.doc.Find(selector)
switch v := any(checkExists).(type) { switch v := any(checkExists).(type) {

View File

@ -129,7 +129,7 @@ func TestPullCreate_EmptyChangesWithDifferentCommits(t *testing.T) {
session := loginUser(t, "user1") session := loginUser(t, "user1")
testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "")
testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1") 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") url := path.Join("user1", "repo1", "compare", "master...status1")
req := NewRequestWithValues(t, "POST", url, req := NewRequestWithValues(t, "POST", url,

View File

@ -5,7 +5,7 @@ import {confirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts'; import type {RequestOpts} from '../types.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.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" // fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler // 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) { 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 { try {
hideToastsAll(); hideToastsAll();
const resp = await request(url, opt); const resp = await request(url, opt);
if (resp.status === 200) { respStatus = resp.status;
let {redirect} = await resp.json(); respText = await resp.text();
const respJson = JSON.parse(respText);
if (respStatus === 200) {
let {redirect} = respJson;
redirect = redirect || actionElem.getAttribute('data-redirect'); redirect = redirect || actionElem.getAttribute('data-redirect');
ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading
if (redirect) { if (redirect) {
@ -38,22 +47,19 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R
return; return;
} }
if (resp.status >= 400 && resp.status < 500) { if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) {
const data = await resp.json();
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" // 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. // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
if (data.errorMessage) { showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'});
} else {
showErrorToast(`server error: ${resp.status}`);
}
} else { } else {
showErrorToast(`server error: ${resp.status}`); showErrorForResponse(respStatus, respText);
} }
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') { if (e.name === 'SyntaxError') {
console.error('error when doRequest', e); showErrorForResponse(respStatus, (respText || '').substring(0, 100));
showErrorToast(`${i18n.network_error} ${e}`); } else if (e.name !== 'AbortError') {
console.error('fetchActionDoRequest error', e);
showErrorForResponse(respStatus, `${e}`);
} }
} }
actionElem.classList.remove('is-loading', 'loading-icon-2px'); actionElem.classList.remove('is-loading', 'loading-icon-2px');