Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
639d23d
Fix duplicate script declaration error in pull request merge box
silverwind Feb 25, 2026
c04bf2e
Suppress djlint H025 false positive in pull merge box template
silverwind Feb 25, 2026
f7dcbd8
Read merge form data from element props instead of window.config.page…
silverwind Feb 26, 2026
6399e84
Merge remote-tracking branch 'origin/main' into fixmerge
silverwind Feb 27, 2026
71501fb
Move merge form JSON construction from template to Go
silverwind Feb 27, 2026
3cdf645
Apply suggestion from @silverwind
silverwind Feb 27, 2026
54dc981
Merge branch 'main' into fixmerge
silverwind Feb 27, 2026
83d9f38
Clean up merge form: pass params directly, remove unused code
silverwind Feb 27, 2026
365689a
Simplify merge form: remove params struct, read ctx.Data directly
silverwind Feb 27, 2026
66725dc
Reduce ctx.Data reads in merge form builder
silverwind Feb 27, 2026
db46564
Remove ctx.Data reads: pass values through typed params
silverwind Feb 27, 2026
4811c47
Remove obsolete ctx.Data sets and simplify merge style logic
silverwind Feb 27, 2026
6170a80
Remove ctx.Data round-trips in prepareViewPullInfo
silverwind Feb 27, 2026
1509ca3
DRY up merge form: embed struct, inline closure, deduplicate checks
silverwind Feb 27, 2026
1268eec
Pass pre-computed blocked-by flags to merge form builder
silverwind Feb 27, 2026
1898288
Add e2e tests for pull request merge box
silverwind Feb 27, 2026
ad0c12c
Optimize pull merge box e2e tests
silverwind Feb 27, 2026
b059455
Shorten e2e test names and use lowercase
silverwind Feb 27, 2026
33e154a
Remove redundant recovery check in status checks e2e test
silverwind Feb 27, 2026
96e8b2a
fix lint
silverwind Feb 27, 2026
93fae30
Fix type assertion panic when HeadTarget is template.HTML
silverwind Mar 1, 2026
4e72424
Remove unused apiCreateFile and extract preparePullViewReviewAndMergeAll
silverwind Mar 1, 2026
ff3e554
Remove unused clickDropdownItem utility function
silverwind Mar 1, 2026
885897b
Merge branch 'main' into fixmerge
silverwind Mar 1, 2026
a826a20
Add comments explaining merge permission logic
silverwind Mar 1, 2026
3e02cd4
Restore stillCanManualMerge closure
silverwind Mar 1, 2026
5ff665d
Rename merge form fields for clarity
silverwind Mar 6, 2026
f626aa0
Merge remote-tracking branch 'origin/main' into fixmerge
silverwind Mar 6, 2026
0affa9c
fmt
silverwind Mar 6, 2026
3d66fd3
Merge origin/main into fixmerge
silverwind Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions modules/templates/util_slice.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ func NewSliceUtils() *SliceUtils {
return &SliceUtils{}
}

func (su *SliceUtils) Pack(args ...any) []any {
return args
}

func (su *SliceUtils) Contains(s, v any) bool {
if s == nil {
return false
Expand Down
2 changes: 2 additions & 0 deletions routers/web/repo/issue_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,8 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss
ctx.ServerError("GetScheduledMergeByPullID", err)
return
}

preparePullViewMergeFormData(ctx, issue)
}

func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) {
Expand Down
214 changes: 214 additions & 0 deletions routers/web/repo/pull_merge_form.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repo

import (
"fmt"

git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
pull_model "code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
)

type mergeStyleField struct {
Name string `json:"name"`
Allowed bool `json:"allowed"`
TextDoMerge string `json:"textDoMerge"`
MergeTitleFieldText string `json:"mergeTitleFieldText,omitempty"`
MergeMessageFieldText string `json:"mergeMessageFieldText,omitempty"`
HideMergeMessageTexts bool `json:"hideMergeMessageTexts,omitempty"`
HideAutoMerge bool `json:"hideAutoMerge"`
}

type mergeFormField struct {
BaseLink string `json:"baseLink"`
TextCancel string `json:"textCancel"`
TextDeleteBranch string `json:"textDeleteBranch"`
TextAutoMergeButtonWhenSucceed string `json:"textAutoMergeButtonWhenSucceed"`
TextAutoMergeWhenSucceed string `json:"textAutoMergeWhenSucceed"`
TextAutoMergeCancelSchedule string `json:"textAutoMergeCancelSchedule"`
TextClearMergeMessage string `json:"textClearMergeMessage"`
TextClearMergeMessageHint string `json:"textClearMergeMessageHint"`
TextMergeCommitID string `json:"textMergeCommitId"`
CanMergeNow bool `json:"canMergeNow"`
AllOverridableChecksOk bool `json:"allOverridableChecksOk"`
EmptyCommit bool `json:"emptyCommit"`
PullHeadCommitID string `json:"pullHeadCommitID"`
IsPullBranchDeletable bool `json:"isPullBranchDeletable"`
DefaultMergeStyle string `json:"defaultMergeStyle"`
DefaultDeleteBranchAfterMerge bool `json:"defaultDeleteBranchAfterMerge"`
MergeMessageFieldPlaceHolder string `json:"mergeMessageFieldPlaceHolder"`
DefaultMergeMessage string `json:"defaultMergeMessage"`
HasPendingPullRequestMerge bool `json:"hasPendingPullRequestMerge"`
HasPendingPullRequestMergeTip string `json:"hasPendingPullRequestMergeTip"`
MergeStyles []mergeStyleField `json:"mergeStyles"`
}

// preparePullViewMergeFormData builds the JSON data for the merge form Vue component.
// It must be called after preparePullViewReviewAndMerge so all necessary ctx.Data values exist.
func preparePullViewMergeFormData(ctx *context.Context, issue *issues_model.Issue) {
if !issue.IsPull {
return
}
pull := issue.PullRequest

if pull.HasMerged || issue.IsClosed {
return
}

// The merge form is only shown when CanAutoMerge or IsEmpty
if !pull.CanAutoMerge() && !pull.IsEmpty() {
return
}

allowMerge, _ := ctx.Data["AllowMerge"].(bool)
if !allowMerge {
return
}

prUnit, err := issue.Repo.GetUnit(ctx, unit.TypePullRequests)
if err != nil {
return
}
prConfig := prUnit.PullRequestsConfig()

if !(prConfig.AllowMerge || prConfig.AllowRebase || prConfig.AllowRebaseMerge || prConfig.AllowSquash || prConfig.AllowFastForwardOnly) {
return
}

// Compute notAllOverridableChecksOk (same logic as template)
isBlockedByApprovals, _ := ctx.Data["IsBlockedByApprovals"].(bool)
isBlockedByRejection, _ := ctx.Data["IsBlockedByRejection"].(bool)
isBlockedByOfficialReviewRequests, _ := ctx.Data["IsBlockedByOfficialReviewRequests"].(bool)
isBlockedByOutdatedBranch, _ := ctx.Data["IsBlockedByOutdatedBranch"].(bool)
isBlockedByChangedProtectedFiles, _ := ctx.Data["IsBlockedByChangedProtectedFiles"].(bool)
enableStatusCheck, _ := ctx.Data["EnableStatusCheck"].(bool)

requiredStatusCheckSuccess := false
if statusCheckData, ok := ctx.Data["StatusCheckData"].(*pullCommitStatusCheckData); ok && statusCheckData != nil {
requiredStatusCheckSuccess = statusCheckData.RequiredChecksState.IsSuccess()
}

notAllOverridableChecksOk := isBlockedByApprovals || isBlockedByRejection ||
isBlockedByOfficialReviewRequests || isBlockedByOutdatedBranch ||
isBlockedByChangedProtectedFiles || (enableStatusCheck && !requiredStatusCheckSuccess)

// Compute canMergeNow (same logic as template)
isRepoAdmin := ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin)
blockAdminMergeOverride := false
if pb, ok := ctx.Data["ProtectedBranch"].(*git_model.ProtectedBranch); ok && pb != nil {
blockAdminMergeOverride = pb.BlockAdminMergeOverride
}
requireSigned, _ := ctx.Data["RequireSigned"].(bool)
willSign, _ := ctx.Data["WillSign"].(bool)

canMergeNow := ((!blockAdminMergeOverride && isRepoAdmin) || !notAllOverridableChecksOk) &&
(!allowMerge || !requireSigned || willSign)

generalHideAutoMerge := canMergeNow && !notAllOverridableChecksOk

// Build hasPendingPullRequestMergeTip
hasPendingPullRequestMerge, _ := ctx.Data["HasPendingPullRequestMerge"].(bool)
hasPendingPullRequestMergeTip := ""
if hasPendingPullRequestMerge {
if pendingMerge, ok := ctx.Data["PendingPullRequestMerge"].(*pull_model.AutoMerge); ok && pendingMerge != nil {
createdPRMergeStr := templates.TimeSince(pendingMerge.CreatedUnix)
hasPendingPullRequestMergeTip = string(ctx.Locale.Tr("repo.pulls.auto_merge_has_pending_schedule", pendingMerge.Doer.Name, createdPRMergeStr))
}
}

// Get values from ctx.Data
mergeStyle, _ := ctx.Data["MergeStyle"].(repo_model.MergeStyle)
defaultMergeMessage, _ := ctx.Data["DefaultMergeMessage"].(string)
defaultMergeBody, _ := ctx.Data["DefaultMergeBody"].(string)
defaultSquashMergeMessage, _ := ctx.Data["DefaultSquashMergeMessage"].(string)
defaultSquashMergeBody, _ := ctx.Data["DefaultSquashMergeBody"].(string)
pullHeadCommitID, _ := ctx.Data["PullHeadCommitID"].(string)
isPullBranchDeletable, _ := ctx.Data["IsPullBranchDeletable"].(bool)
headTarget, _ := ctx.Data["HeadTarget"].(string)
getCommitMessages, _ := ctx.Data["GetCommitMessages"].(string)

form := &mergeFormField{
BaseLink: issue.Link(),
TextCancel: string(ctx.Locale.Tr("cancel")),
TextDeleteBranch: string(ctx.Locale.Tr("repo.branch.delete", headTarget)),
TextAutoMergeButtonWhenSucceed: string(ctx.Locale.Tr("repo.pulls.auto_merge_button_when_succeed")),
TextAutoMergeWhenSucceed: string(ctx.Locale.Tr("repo.pulls.auto_merge_when_succeed")),
TextAutoMergeCancelSchedule: string(ctx.Locale.Tr("repo.pulls.auto_merge_cancel_schedule")),
TextClearMergeMessage: string(ctx.Locale.Tr("repo.pulls.clear_merge_message")),
TextClearMergeMessageHint: string(ctx.Locale.Tr("repo.pulls.clear_merge_message_hint")),
TextMergeCommitID: string(ctx.Locale.Tr("repo.pulls.merge_commit_id")),
CanMergeNow: canMergeNow,
AllOverridableChecksOk: !notAllOverridableChecksOk,
EmptyCommit: pull.IsEmpty(),
PullHeadCommitID: pullHeadCommitID,
IsPullBranchDeletable: isPullBranchDeletable,
DefaultMergeStyle: string(mergeStyle),
DefaultDeleteBranchAfterMerge: prConfig.DefaultDeleteBranchAfterMerge,
MergeMessageFieldPlaceHolder: string(ctx.Locale.Tr("repo.editor.commit_message_desc")),
DefaultMergeMessage: defaultMergeBody,
HasPendingPullRequestMerge: hasPendingPullRequestMerge,
HasPendingPullRequestMergeTip: hasPendingPullRequestMergeTip,
MergeStyles: []mergeStyleField{
{
Name: "merge",
Allowed: prConfig.AllowMerge,
TextDoMerge: string(ctx.Locale.Tr("repo.pulls.merge_pull_request")),
MergeTitleFieldText: defaultMergeMessage,
MergeMessageFieldText: defaultMergeBody,
HideAutoMerge: generalHideAutoMerge,
},
{
Name: "rebase",
Allowed: prConfig.AllowRebase,
TextDoMerge: string(ctx.Locale.Tr("repo.pulls.rebase_merge_pull_request")),
HideMergeMessageTexts: true,
HideAutoMerge: generalHideAutoMerge,
},
{
Name: "rebase-merge",
Allowed: prConfig.AllowRebaseMerge,
TextDoMerge: string(ctx.Locale.Tr("repo.pulls.rebase_merge_commit_pull_request")),
MergeTitleFieldText: defaultMergeMessage,
MergeMessageFieldText: defaultMergeBody,
HideAutoMerge: generalHideAutoMerge,
},
{
Name: "squash",
Allowed: prConfig.AllowSquash,
TextDoMerge: string(ctx.Locale.Tr("repo.pulls.squash_merge_pull_request")),
MergeTitleFieldText: defaultSquashMergeMessage,
MergeMessageFieldText: fmt.Sprintf("%s%s", getCommitMessages, defaultSquashMergeBody),
HideAutoMerge: generalHideAutoMerge,
},
{
Name: "fast-forward-only",
Allowed: prConfig.AllowFastForwardOnly && pull.CommitsBehind == 0,
TextDoMerge: string(ctx.Locale.Tr("repo.pulls.fast_forward_only_merge_pull_request")),
HideMergeMessageTexts: true,
HideAutoMerge: generalHideAutoMerge,
},
{
Name: "manually-merged",
Allowed: prConfig.AllowManualMerge,
TextDoMerge: string(ctx.Locale.Tr("repo.pulls.merge_manually")),
HideMergeMessageTexts: true,
HideAutoMerge: true,
},
},
}

jsonBytes, err := json.Marshal(form)
if err != nil {
ctx.ServerError("json.Marshal", err)
return
}
ctx.Data["MergeFormJSON"] = string(jsonBytes)
ctx.Data["ShowGeneralMergeForm"] = true
}
125 changes: 18 additions & 107 deletions templates/repo/issue/view_content/pull_merge_box.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
)}}
</div>
{{end}}
{{$showGeneralMergeForm := false}}
<div class="ui attached segment merge-section {{if not $.LatestCommitStatus}}avatar-content-left-arrow{{end}} flex-items-block">
{{if .Issue.PullRequest.HasMerged}}
{{if .IsPullBranchDeletable}}
Expand Down Expand Up @@ -211,111 +210,21 @@
</div>
{{end}}

{{if .AllowMerge}} {{/* user is allowed to merge */}}
{{$prUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypePullRequests}}
{{if or $prUnit.PullRequestsConfig.AllowMerge $prUnit.PullRequestsConfig.AllowRebase $prUnit.PullRequestsConfig.AllowRebaseMerge $prUnit.PullRequestsConfig.AllowSquash $prUnit.PullRequestsConfig.AllowFastForwardOnly}}
{{$hasPendingPullRequestMergeTip := ""}}
{{if .HasPendingPullRequestMerge}}
{{$createdPRMergeStr := DateUtils.TimeSince .PendingPullRequestMerge.CreatedUnix}}
{{$hasPendingPullRequestMergeTip = ctx.Locale.Tr "repo.pulls.auto_merge_has_pending_schedule" .PendingPullRequestMerge.Doer.Name $createdPRMergeStr}}
{{end}}
<div class="divider"></div>
<script type="module">
const defaultMergeTitle = {{.DefaultMergeMessage}};
const defaultSquashMergeTitle = {{.DefaultSquashMergeMessage}};
const defaultMergeMessage = {{.DefaultMergeBody}};
const defaultSquashMergeMessage = {{.DefaultSquashMergeBody}};
const mergeForm = {
'baseLink': {{.Issue.Link}},
'textCancel': {{ctx.Locale.Tr "cancel"}},
'textDeleteBranch': {{ctx.Locale.Tr "repo.branch.delete" .HeadTarget}},
'textAutoMergeButtonWhenSucceed': {{ctx.Locale.Tr "repo.pulls.auto_merge_button_when_succeed"}},
'textAutoMergeWhenSucceed': {{ctx.Locale.Tr "repo.pulls.auto_merge_when_succeed"}},
'textAutoMergeCancelSchedule': {{ctx.Locale.Tr "repo.pulls.auto_merge_cancel_schedule"}},
'textClearMergeMessage': {{ctx.Locale.Tr "repo.pulls.clear_merge_message"}},
'textClearMergeMessageHint': {{ctx.Locale.Tr "repo.pulls.clear_merge_message_hint"}},
'textMergeCommitId': {{ctx.Locale.Tr "repo.pulls.merge_commit_id"}},

'canMergeNow': {{$canMergeNow}},
'allOverridableChecksOk': {{not $notAllOverridableChecksOk}},
'emptyCommit': {{.Issue.PullRequest.IsEmpty}},
'pullHeadCommitID': {{.PullHeadCommitID}},
'isPullBranchDeletable': {{.IsPullBranchDeletable}},
'defaultMergeStyle': {{.MergeStyle}},
'defaultDeleteBranchAfterMerge': {{$prUnit.PullRequestsConfig.DefaultDeleteBranchAfterMerge}},
'mergeMessageFieldPlaceHolder': {{ctx.Locale.Tr "repo.editor.commit_message_desc"}},
'defaultMergeMessage': defaultMergeMessage,

'hasPendingPullRequestMerge': {{.HasPendingPullRequestMerge}},
'hasPendingPullRequestMergeTip': {{$hasPendingPullRequestMergeTip}},
};

const generalHideAutoMerge = mergeForm.canMergeNow && mergeForm.allOverridableChecksOk; // if this pr can be merged now, then hide the auto merge
mergeForm['mergeStyles'] = [
{
'name': 'merge',
'allowed': {{$prUnit.PullRequestsConfig.AllowMerge}},
'textDoMerge': {{ctx.Locale.Tr "repo.pulls.merge_pull_request"}},
'mergeTitleFieldText': defaultMergeTitle,
'mergeMessageFieldText': defaultMergeMessage,
'hideAutoMerge': generalHideAutoMerge,
},
{
'name': 'rebase',
'allowed': {{$prUnit.PullRequestsConfig.AllowRebase}},
'textDoMerge': {{ctx.Locale.Tr "repo.pulls.rebase_merge_pull_request"}},
'hideMergeMessageTexts': true,
'hideAutoMerge': generalHideAutoMerge,
},
{
'name': 'rebase-merge',
'allowed': {{$prUnit.PullRequestsConfig.AllowRebaseMerge}},
'textDoMerge': {{ctx.Locale.Tr "repo.pulls.rebase_merge_commit_pull_request"}},
'mergeTitleFieldText': defaultMergeTitle,
'mergeMessageFieldText': defaultMergeMessage,
'hideAutoMerge': generalHideAutoMerge,
},
{
'name': 'squash',
'allowed': {{$prUnit.PullRequestsConfig.AllowSquash}},
'textDoMerge': {{ctx.Locale.Tr "repo.pulls.squash_merge_pull_request"}},
'mergeTitleFieldText': defaultSquashMergeTitle,
'mergeMessageFieldText': {{.GetCommitMessages}} + defaultSquashMergeMessage,
'hideAutoMerge': generalHideAutoMerge,
},
{
'name': 'fast-forward-only',
'allowed': {{and $prUnit.PullRequestsConfig.AllowFastForwardOnly (eq .Issue.PullRequest.CommitsBehind 0)}},
'textDoMerge': {{ctx.Locale.Tr "repo.pulls.fast_forward_only_merge_pull_request"}},
'hideMergeMessageTexts': true,
'hideAutoMerge': generalHideAutoMerge,
},
{
'name': 'manually-merged',
'allowed': {{$prUnit.PullRequestsConfig.AllowManualMerge}},
'textDoMerge': {{ctx.Locale.Tr "repo.pulls.merge_manually"}},
'hideMergeMessageTexts': true,
'hideAutoMerge': true,
}
];
window.config.pageData.pullRequestMergeForm = mergeForm;
</script>

{{$showGeneralMergeForm = true}}
{{/* The merge form is a Vue component. After mounted, it has a button for choosing merge style, so make it have min-height to avoid layout shifting */}}
<div id="pull-request-merge-form" class="tw-min-h-[40px]"></div>
{{else}}
{{/* no merge style was set in repo setting: not or ($prUnit.PullRequestsConfig.AllowMerge ...) */}}
<div class="divider"></div>
<div class="item tw-text-red">
{{svg "octicon-x"}}
{{ctx.Locale.Tr "repo.pulls.no_merge_desc"}}
</div>
<div class="item">
{{svg "octicon-info"}}
{{ctx.Locale.Tr "repo.pulls.no_merge_helper"}}
</div>
{{end}} {{/* end if the repo was set to use any merge style */}}
{{if .MergeFormJSON}}
<div class="divider"></div>
{{/* The merge form is a Vue component. After mounted, it has a button for choosing merge style, so make it have min-height to avoid layout shifting */}}
<div id="pull-request-merge-form" class="tw-min-h-[40px]" data-merge-form="{{.MergeFormJSON}}"></div>
{{else if .AllowMerge}}
{{/* no merge style was set in repo setting */}}
<div class="divider"></div>
<div class="item tw-text-red">
{{svg "octicon-x"}}
{{ctx.Locale.Tr "repo.pulls.no_merge_desc"}}
</div>
<div class="item">
{{svg "octicon-info"}}
{{ctx.Locale.Tr "repo.pulls.no_merge_helper"}}
</div>
{{else}}
{{/* user is not allowed to merge */}}
<div class="divider"></div>
Expand Down Expand Up @@ -386,7 +295,7 @@
* - Make some conflicts between the base branch and the pull request branch
* Then the Manually Merged form will be shown in the merge form
*/}}
{{if and $.StillCanManualMerge (not $showGeneralMergeForm)}}
{{if and $.StillCanManualMerge (not $.ShowGeneralMergeForm)}}
<div class="divider"></div>
<form class="ui form form-fetch-action" action="{{.Issue.Link}}/merge" method="post">{{/* another similar form is in PullRequestMergeForm.vue*/}}
<div class="field">
Expand All @@ -403,5 +312,7 @@
{{end}}
</div>
</div>
{{/* djlint:off H025 */}}
</div>
{{/* djlint:on */}}
{{end}}
4 changes: 2 additions & 2 deletions web_src/js/components/PullRequestMergeForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import {toggleElem} from '../utils/dom.ts';

const {pageData} = window.config;
const props = defineProps<{elRoot: HTMLElement}>();

const mergeForm = pageData.pullRequestMergeForm;
const mergeForm = JSON.parse(props.elRoot.getAttribute('data-merge-form')!);

const mergeTitleFieldValue = shallowRef('');
const mergeMessageFieldValue = shallowRef('');
Expand Down
Loading
Loading