From 6da802744630e98d341a4b958103464de28a7152 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 27 Apr 2026 19:33:10 +0200 Subject: [PATCH 1/8] Fix inconsistent disabled styling on logged-out repo header buttons (#37406) Make the watch, star, and fork buttons in the repo header consistent for logged-out users: - Apply the same look to all three buttons (number labels included), instead of only the action button being grayed. - Clicking any of them while logged out now leads to the login page (with a redirect back) instead of being inert. - Split the per-button markup out of `header.tmpl` into a dedicated `templates/repo/header/` folder (`fork.tmpl`, `star.tmpl`, `watch.tmpl`). --------- Co-authored-by: Claude (Opus 4.7) Co-authored-by: wxiaoguang --- routers/web/repo/star.go | 2 +- routers/web/repo/watch.go | 2 +- templates/repo/header.tmpl | 53 ++----------------- templates/repo/header/fork.tmpl | 47 ++++++++++++++++ .../{star_unstar.tmpl => header/star.tmpl} | 6 +-- .../{watch_unwatch.tmpl => header/watch.tmpl} | 8 +-- 6 files changed, 59 insertions(+), 59 deletions(-) create mode 100644 templates/repo/header/fork.tmpl rename templates/repo/{star_unstar.tmpl => header/star.tmpl} (84%) rename templates/repo/{watch_unwatch.tmpl => header/watch.tmpl} (76%) diff --git a/routers/web/repo/star.go b/routers/web/repo/star.go index 8cfbfefdf1..c93c877d63 100644 --- a/routers/web/repo/star.go +++ b/routers/web/repo/star.go @@ -11,7 +11,7 @@ import ( "code.gitea.io/gitea/services/context" ) -const tplStarUnstar templates.TplName = "repo/star_unstar" +const tplStarUnstar templates.TplName = "repo/header/star" func ActionStar(ctx *context.Context) { err := repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, ctx.PathParam("action") == "star") diff --git a/routers/web/repo/watch.go b/routers/web/repo/watch.go index a7fbfc168b..616e1ee89c 100644 --- a/routers/web/repo/watch.go +++ b/routers/web/repo/watch.go @@ -11,7 +11,7 @@ import ( "code.gitea.io/gitea/services/context" ) -const tplWatchUnwatch templates.TplName = "repo/watch_unwatch" +const tplWatchUnwatch templates.TplName = "repo/header/watch" func ActionWatch(ctx *context.Context) { err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, ctx.PathParam("action") == "watch") diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 9ed74d50bc..9fbe6a9a11 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -57,59 +57,12 @@ {{svg "octicon-rss" 16}} {{end}} - {{template "repo/watch_unwatch" $}} + {{template "repo/header/watch" $}} {{if not $.DisableStars}} - {{template "repo/star_unstar" $}} + {{template "repo/header/star" $}} {{end}} {{if and (not .IsEmpty) ($.Permission.CanRead ctx.Consts.RepoUnitTypeCode)}} - - + {{template "repo/header/fork" $}} {{end}} {{end}} diff --git a/templates/repo/header/fork.tmpl b/templates/repo/header/fork.tmpl new file mode 100644 index 0000000000..e14851f068 --- /dev/null +++ b/templates/repo/header/fork.tmpl @@ -0,0 +1,47 @@ +{{$canNotForkOwn := and $.IsSigned (not $.CanSignedUserFork) (not $.UserAndOrgForks)}} + +{{if $.ShowForkModal}} + +{{end}} diff --git a/templates/repo/star_unstar.tmpl b/templates/repo/header/star.tmpl similarity index 84% rename from templates/repo/star_unstar.tmpl rename to templates/repo/header/star.tmpl index 7e4c61aa28..d1762bc004 100644 --- a/templates/repo/star_unstar.tmpl +++ b/templates/repo/header/star.tmpl @@ -1,18 +1,18 @@
{{$buttonText := ctx.Locale.Tr "repo.star"}} {{if $.IsStaringRepo}}{{$buttonText = ctx.Locale.Tr "repo.unstar"}}{{end}} - + {{CountFmt .Repository.NumStars}} diff --git a/templates/repo/watch_unwatch.tmpl b/templates/repo/header/watch.tmpl similarity index 76% rename from templates/repo/watch_unwatch.tmpl rename to templates/repo/header/watch.tmpl index 1d6daac252..a00fd01c80 100644 --- a/templates/repo/watch_unwatch.tmpl +++ b/templates/repo/header/watch.tmpl @@ -1,19 +1,19 @@
{{$buttonText := ctx.Locale.Tr "repo.watch"}} {{if $.IsWatchingRepo}}{{$buttonText = ctx.Locale.Tr "repo.unwatch"}}{{end}} - - + + {{CountFmt .Repository.NumWatches}}
From 89d358d8a7b386f4b8a4acbdec04523d17d59b5f Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 28 Apr 2026 07:08:50 +0800 Subject: [PATCH 2/8] Fix script error alert (#37458) After using CSP nonce, the "onerror" doesn't work anymore. Change it to use a global variable to detect Also help users like #37379 to catch errors more easily. Co-authored-by: Lunny Xiao --- services/context/context_template.go | 23 ++--------------------- templates/base/footer.tmpl | 3 +++ web_src/js/globals.d.ts | 1 + web_src/js/index.ts | 2 ++ web_src/js/vitest.setup.ts | 1 + 5 files changed, 9 insertions(+), 21 deletions(-) diff --git a/services/context/context_template.go b/services/context/context_template.go index b63aaf4c3c..0f083d097e 100644 --- a/services/context/context_template.go +++ b/services/context/context_template.go @@ -5,13 +5,11 @@ package context import ( "context" - "fmt" "html" "html/template" "net/http" "strconv" "strings" - "sync" "time" "code.gitea.io/gitea/modules/httplib" @@ -91,31 +89,14 @@ func (c TemplateContext) AppFullLink(link ...string) template.URL { return template.URL(s + "/" + strings.TrimPrefix(link[0], "/")) } -var globalVars = sync.OnceValue(func() (ret struct { - scriptImportRemainingPart string -}, -) { - // add onerror handler to alert users when the script fails to load: - // * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config - // * for developers: help them to remember to run "make watch-frontend" to build frontend assets - // the message will be directly put in the onerror JS code's string - onScriptErrorPrompt := `Please make sure the asset files can be accessed.` - if !setting.IsProd { - onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.` - } - onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt) - ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `">` - return ret -}) - func (c TemplateContext) ScriptImport(path string, typ ...string) template.HTML { if len(typ) > 0 { if typ[0] == "module" { - return template.HTML(``) } panic("unsupported script type: " + typ[0]) } - return template.HTML(``) } func (c TemplateContext) CspScriptNonce() (ret string) { diff --git a/templates/base/footer.tmpl b/templates/base/footer.tmpl index 5a218bb62a..b7443345ad 100644 --- a/templates/base/footer.tmpl +++ b/templates/base/footer.tmpl @@ -11,5 +11,8 @@ {{template "base/footer_content" .}} {{ctx.ScriptImport "js/index.js" "module"}} {{template "custom/footer" .}} + diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts index bd9fd410b8..5398d407d1 100644 --- a/web_src/js/globals.d.ts +++ b/web_src/js/globals.d.ts @@ -53,6 +53,7 @@ interface Window { enableTimeTracking: boolean, mermaidMaxSourceCharacters: number, i18n: Record, + frontendInited: boolean, }, $: JQueryStatic, jQuery: JQueryStatic, diff --git a/web_src/js/index.ts b/web_src/js/index.ts index d6457a1326..cb2b56a5bd 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -171,3 +171,5 @@ const initDur = performance.now() - initStartTime; if (initDur > 500) { console.error(`slow init functions took ${initDur.toFixed(3)}ms`); } + +window.config.frontendInited = true; diff --git a/web_src/js/vitest.setup.ts b/web_src/js/vitest.setup.ts index 229c99cceb..190196cabd 100644 --- a/web_src/js/vitest.setup.ts +++ b/web_src/js/vitest.setup.ts @@ -12,6 +12,7 @@ window.config = { enableTimeTracking: true, mermaidMaxSourceCharacters: 5000, i18n: {}, + frontendInited: false, }; window.testModules = {}; From 4952a48b4eb1b1e2394908a8fca811ed3e187455 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 28 Apr 2026 07:30:27 +0800 Subject: [PATCH 3/8] Clean up org pages layout (#37445) 1. Fix overview sidebar regression 2. Remove unnecessary classes and styles 3. Fix "org invite" page --- routers/web/org/teams.go | 2 ++ templates/org/header.tmpl | 36 +++++++++++++++++++------------- templates/org/home.tmpl | 19 ++++++++++------- templates/org/team/invite.tmpl | 20 +++++++----------- templates/org/team/sidebar.tmpl | 20 +++++++++--------- web_src/css/index.css | 1 - web_src/css/org.css | 37 --------------------------------- web_src/css/shared/repoorg.css | 18 ---------------- 8 files changed, 52 insertions(+), 101 deletions(-) delete mode 100644 web_src/css/shared/repoorg.css diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 10803c9fbf..7312478299 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -609,6 +609,8 @@ func DeleteTeam(ctx *context.Context) { // TeamInvite renders the team invite page func TeamInvite(ctx *context.Context) { invite, org, team, inviter, err := getTeamInviteFromContext(ctx) + // TODO: to quickly debug the UI, can uncomment this (don't worry, it won't pass CI lint) + // invite, org, team, inviter, err = &org_model.TeamInvite{}, &org_model.Organization{}, &org_model.Team{}, ctx.Doer, nil if err != nil { if org_model.IsErrTeamInviteNotFound(err) { ctx.NotFound(err) diff --git a/templates/org/header.tmpl b/templates/org/header.tmpl index 83ae95a1ae..4f9e54b610 100644 --- a/templates/org/header.tmpl +++ b/templates/org/header.tmpl @@ -1,13 +1,13 @@ -
- {{ctx.AvatarUtils.Avatar .Org 100 "org-avatar"}} -
-
- {{.Org.DisplayName}} - +
+
{{ctx.AvatarUtils.Avatar .Org 100}}
+
+
+
+ {{.Org.DisplayName}} {{if .Org.Visibility.IsLimited}}{{ctx.Locale.Tr "org.settings.visibility.limited_shortname"}}{{end}} {{if .Org.Visibility.IsPrivate}}{{ctx.Locale.Tr "org.settings.visibility.private_shortname"}}{{end}} - - +
+
- {{if .RenderedDescription}}
{{.RenderedDescription}}
{{end}} -
- {{if .Org.Location}}
{{svg "octicon-location"}} {{.Org.Location}}
{{end}} - {{if .Org.Website}}
{{end}} - {{if .IsSigned}} - {{if .Org.Email}}
{{svg "octicon-mail"}} {{.Org.Email}}
{{end}} + {{if .RenderedDescription}} +
{{.RenderedDescription}}
+ {{end}} +
+ {{if .Org.Location}} +
{{svg "octicon-location"}} {{.Org.Location}}
+ {{end}} + {{if .Org.Website}} +
{{svg "octicon-link"}} {{.Org.Website}}
+ {{end}} + {{if and .IsSigned .Org.Email}} +
{{svg "octicon-mail"}} {{.Org.Email}}
{{end}}
diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index 7132743d0c..12b41c3e94 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -55,11 +55,12 @@ {{end}} {{if .NumMembers}} -

- {{ctx.Locale.Tr "org.members"}} +

+ {{ctx.Locale.Tr "org.members"}} {{.NumMembers}} {{svg "octicon-chevron-right"}}

-
+ {{/* gap 8px below is specifically chosen to make sure a full line of avatars can exactly fit the segment width */}} +
{{range $memberUser := .OrgOverviewMembers}} {{if or $.IsOrganizationMember (call $.IsPublicMember $memberUser.ID)}} {{template "shared/user/avatarlink" dict "user" $memberUser "size" 32 "tooltip" true}} @@ -68,20 +69,22 @@
{{end}} {{if .IsOrganizationMember}} -
- {{ctx.Locale.Tr "org.teams"}} +
+ {{ctx.Locale.Tr "org.teams"}} {{.Org.NumTeams}} {{svg "octicon-chevron-right"}}
-
+
+ {{if .IsOrganizationOwner}}
diff --git a/templates/org/team/invite.tmpl b/templates/org/team/invite.tmpl index 14a97ae659..d591414efc 100644 --- a/templates/org/team/invite.tmpl +++ b/templates/org/team/invite.tmpl @@ -1,18 +1,14 @@ {{template "base/head" .}} -
+
{{template "base/alert" .}} -
-
- {{ctx.AvatarUtils.Avatar .Organization 140}} -
-
-
{{ctx.Locale.Tr "org.teams.invite.title" .Team.Name .Organization.Name}}
-
{{ctx.Locale.Tr "org.teams.invite.by" .Inviter.Name}}
-
{{ctx.Locale.Tr "org.teams.invite.description"}}
-
-
-
+
+
+
{{ctx.AvatarUtils.Avatar .Organization 140}}
+
{{ctx.Locale.Tr "org.teams.invite.title" .Team.Name .Organization.Name}}
+
{{ctx.Locale.Tr "org.teams.invite.by" .Inviter.Name}}
+
{{ctx.Locale.Tr "org.teams.invite.description"}}
+
diff --git a/templates/org/team/sidebar.tmpl b/templates/org/team/sidebar.tmpl index 1487c280de..1036e886da 100644 --- a/templates/org/team/sidebar.tmpl +++ b/templates/org/team/sidebar.tmpl @@ -15,21 +15,21 @@ {{end}}
-
-
+ +
{{if .Team.Description}} {{.Team.Description}} {{else}} {{ctx.Locale.Tr "org.teams.no_desc"}} {{end}}
- {{if eq .Team.LowerName "owners"}} -
+ +
+ {{/* TODO: old indent is kept to make diff changes minimal, can be reformatted in the future */}} + {{if eq .Team.LowerName "owners"}}

{{ctx.Locale.Tr "org.teams.owners_permission_desc"}}

{{ctx.Locale.Tr "org.teams.owners_permission_suggestion"}}

-
- {{else}} -
+ {{else}}

{{ctx.Locale.Tr "org.team_access_desc"}}

    {{if .Team.IncludesAllRepositories}} @@ -75,9 +75,9 @@ {{end}} -
- {{end}} -
+ {{end}} +
+ {{if .IsOrganizationOwner}}
{{svg "octicon-gear"}} {{ctx.Locale.Tr "org.teams.settings"}} diff --git a/web_src/css/index.css b/web_src/css/index.css index c23e3e1c19..d7f57e324b 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -37,7 +37,6 @@ @import "./shared/flex-list.css"; @import "./shared/milestone.css"; -@import "./shared/repoorg.css"; @import "./shared/settings.css"; @import "./features/dropzone.css"; diff --git a/web_src/css/org.css b/web_src/css/org.css index b54a21ac6e..6de9ebd51e 100644 --- a/web_src/css/org.css +++ b/web_src/css/org.css @@ -1,23 +1,3 @@ -.organization .head .ui.header .ui.right { - margin-top: 5px; -} - -.page-content.organization .org-avatar { - margin-right: 15px; -} - -.page-content.organization #org-info .ui.header { - display: flex; - align-items: center; - font-size: 36px; - margin-bottom: 0; -} - -.page-content.organization #org-info .desc { - font-size: 16px; - margin-bottom: 10px; -} - .page-content.organization .team-item-box > .team-item-header { min-height: 50px; /* the header sometimes contains a mini button, sometimes not, so we set a min-height to make sure the layout is consistent */ } @@ -30,20 +10,3 @@ white-space: nowrap; color: var(--color-text-light-3); } - -.organization.invite .ui.avatar { - width: 100%; - height: 100%; -} - -.organization.teams .detail .item { - padding: 10px 15px; -} - -.organization.teams .detail .item:not(:last-child) { - border-bottom: 1px solid var(--color-secondary); -} - -.org-team-navbar .active.item { - background: var(--color-box-body) !important; -} diff --git a/web_src/css/shared/repoorg.css b/web_src/css/shared/repoorg.css deleted file mode 100644 index 5573ae47b8..0000000000 --- a/web_src/css/shared/repoorg.css +++ /dev/null @@ -1,18 +0,0 @@ -.repository .head .ui.header .text, -.organization .head .ui.header .text { - vertical-align: middle; - font-size: 1.6rem; - margin-left: 15px; -} - -.repository .ui.tabs.container, -.organization .ui.tabs.container { - margin-top: 14px; - margin-bottom: 0; -} - -.repository .head .ui.header .org-visibility .label, -.organization .head .ui.header .org-visibility .label { - margin-left: 5px; - margin-top: 5px; -} From c6ffbfe0d2aabadfd27ec1de984a52f571e270c7 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 28 Apr 2026 08:34:17 +0800 Subject: [PATCH 4/8] Rename CurrentRefPath to CurrentRefSubURL (#37453) Fix a TODO Co-authored-by: Nicolas --- models/renderhelper/repo_comment.go | 4 ++-- models/renderhelper/repo_comment_test.go | 4 ++-- models/renderhelper/repo_file.go | 15 +++++++-------- models/renderhelper/repo_file_test.go | 16 ++++++++-------- models/renderhelper/repo_wiki.go | 8 ++++---- routers/common/markup.go | 4 ++-- routers/web/org/home.go | 2 +- routers/web/repo/commit.go | 3 +-- routers/web/repo/render.go | 4 ++-- routers/web/repo/view_file.go | 4 ++-- routers/web/repo/view_readme.go | 4 ++-- routers/web/user/profile.go | 2 +- 12 files changed, 34 insertions(+), 36 deletions(-) diff --git a/models/renderhelper/repo_comment.go b/models/renderhelper/repo_comment.go index ae0fbf0abd..d1c587671b 100644 --- a/models/renderhelper/repo_comment.go +++ b/models/renderhelper/repo_comment.go @@ -34,7 +34,7 @@ func (r *RepoComment) ResolveLink(link, preferLinkType string) string { case markup.LinkTypeRoot: return r.ctx.ResolveLinkRoot(link) default: - return r.ctx.ResolveLinkRelative(r.repoLink, r.opts.CurrentRefPath, link) + return r.ctx.ResolveLinkRelative(r.repoLink, r.opts.CurrentRefSubURL, link) } } @@ -43,7 +43,7 @@ var _ markup.RenderHelper = (*RepoComment)(nil) type RepoCommentOptions struct { DeprecatedRepoName string // it is only a patch for the non-standard "markup" api DeprecatedOwnerName string // it is only a patch for the non-standard "markup" api - CurrentRefPath string // eg: "branch/main" or "commit/11223344" + CurrentRefSubURL string // eg: "branch/main" or "commit/11223344" FootnoteContextID string // the extra context ID for footnotes, used to avoid conflicts with other footnotes in the same page } diff --git a/models/renderhelper/repo_comment_test.go b/models/renderhelper/repo_comment_test.go index 3b13bff73c..1443f8b3c0 100644 --- a/models/renderhelper/repo_comment_test.go +++ b/models/renderhelper/repo_comment_test.go @@ -54,8 +54,8 @@ func TestRepoComment(t *testing.T) { `, rendered) }) - t.Run("WithCurrentRefPath", func(t *testing.T) { - rctx := NewRenderContextRepoComment(t.Context(), repo1, RepoCommentOptions{CurrentRefPath: "/commit/1234"}). + t.Run("WithCurrentRefSubURL", func(t *testing.T) { + rctx := NewRenderContextRepoComment(t.Context(), repo1, RepoCommentOptions{CurrentRefSubURL: "/commit/1234"}). WithMarkupType(markdown.MarkupName) // the ref path is only used to render commit message: a commit message is rendered at the commit page with its commit ID path diff --git a/models/renderhelper/repo_file.go b/models/renderhelper/repo_file.go index 5d0bfd6c80..d9aa71b727 100644 --- a/models/renderhelper/repo_file.go +++ b/models/renderhelper/repo_file.go @@ -35,11 +35,11 @@ func (r *RepoFile) ResolveLink(link, preferLinkType string) (finalLink string) { case markup.LinkTypeRoot: finalLink = r.ctx.ResolveLinkRoot(link) case markup.LinkTypeRaw: - finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefPath), r.opts.CurrentTreePath, link) + finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link) case markup.LinkTypeMedia: - finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefPath), r.opts.CurrentTreePath, link) + finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link) default: - finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefPath), r.opts.CurrentTreePath, link) + finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link) } return finalLink } @@ -50,8 +50,8 @@ type RepoFileOptions struct { DeprecatedRepoName string // it is only a patch for the non-standard "markup" api DeprecatedOwnerName string // it is only a patch for the non-standard "markup" api - CurrentRefPath string // eg: "branch/main", it is a sub URL path escaped by callers, TODO: rename to CurrentRefSubURL - CurrentTreePath string // eg: "path/to/file" in the repo, it is the tree path without URL path escaping + CurrentRefSubURL string // eg: "branch/main" or "branch/my%20branch", it is a sub URL path escaped by callers + CurrentTreePath string // eg: "path/to/file" in the repo, it is the tree path without URL path escaping } func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository, opts ...RepoFileOptions) *markup.RenderContext { @@ -71,9 +71,8 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository, }) } // External render's iframe needs this to generate correct links - // TODO: maybe need to make it access "CurrentRefPath" directly (but impossible at the moment due to cycle-import) - // CurrentRefPath is already path-escaped by callers - rctx.RenderOptions.Metas["RefTypeNameSubURL"] = helper.opts.CurrentRefPath + // TODO: maybe need to make it access "CurrentRefSubURL" directly (but impossible at the moment due to cycle-import) + rctx.RenderOptions.Metas["RefTypeNameSubURL"] = helper.opts.CurrentRefSubURL rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true) return rctx } diff --git a/models/renderhelper/repo_file_test.go b/models/renderhelper/repo_file_test.go index 3b48efba3a..72d98efc66 100644 --- a/models/renderhelper/repo_file_test.go +++ b/models/renderhelper/repo_file_test.go @@ -36,7 +36,7 @@ func TestRepoFile(t *testing.T) { }) t.Run("AbsoluteAndRelative", func(t *testing.T) { - rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefPath: "branch/main"}). + rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "branch/main"}). WithMarkupType(markdown.MarkupName) rendered, err := markup.RenderString(rctx, ` [/test](/test) @@ -53,8 +53,8 @@ func TestRepoFile(t *testing.T) { `, rendered) }) - t.Run("WithCurrentRefPath", func(t *testing.T) { - rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefPath: "/commit/1234"}). + t.Run("WithCurrentRefSubURL", func(t *testing.T) { + rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "/commit/1234"}). WithMarkupType(markdown.MarkupName) rendered, err := markup.RenderString(rctx, ` [/test](/test) @@ -66,10 +66,10 @@ func TestRepoFile(t *testing.T) { `, rendered) }) - t.Run("WithCurrentRefPathByTag", func(t *testing.T) { + t.Run("WithCurrentRefSubURLByTag", func(t *testing.T) { rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{ - CurrentRefPath: "/commit/1234", - CurrentTreePath: "my-dir", + CurrentRefSubURL: "/commit/1234", + CurrentTreePath: "my-dir", }). WithMarkupType(markdown.MarkupName) rendered, err := markup.RenderString(rctx, ` @@ -89,8 +89,8 @@ func TestRepoFileOrgMode(t *testing.T) { t.Run("Links", func(t *testing.T) { rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{ - CurrentRefPath: "/commit/1234", - CurrentTreePath: "my-dir", + CurrentRefSubURL: "/commit/1234", + CurrentTreePath: "my-dir", }).WithRelativePath("my-dir/a.org") rendered, err := markup.RenderString(rctx, ` diff --git a/models/renderhelper/repo_wiki.go b/models/renderhelper/repo_wiki.go index 218b1e4a67..61e2b570e5 100644 --- a/models/renderhelper/repo_wiki.go +++ b/models/renderhelper/repo_wiki.go @@ -36,9 +36,9 @@ func (r *RepoWiki) ResolveLink(link, preferLinkType string) (finalLink string) { case markup.LinkTypeRoot: finalLink = r.ctx.ResolveLinkRoot(link) case markup.LinkTypeMedia, markup.LinkTypeRaw: - finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefPath), r.opts.currentTreePath, link) + finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), r.opts.currentTreePath, link) default: - finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefPath), r.opts.currentTreePath, link) + finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), r.opts.currentTreePath, link) } return finalLink } @@ -50,8 +50,8 @@ type RepoWikiOptions struct { DeprecatedOwnerName string // it is only a patch for the non-standard "markup" api // these options are not used at the moment because Wiki doesn't support sub-path, nor branch - currentRefPath string // eg: "branch/main" - currentTreePath string // eg: "path/to/file" in the repo + currentRefSubURL string // eg: "branch/main" + currentTreePath string // eg: "path/to/file" in the repo } func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, opts ...RepoWikiOptions) *markup.RenderContext { diff --git a/routers/common/markup.go b/routers/common/markup.go index 35b1b21f6a..05f48c7902 100644 --- a/routers/common/markup.go +++ b/routers/common/markup.go @@ -71,7 +71,7 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur case "gfm": // legacy mode rctx = renderhelper.NewRenderContextRepoFile(ctx, repoModel, renderhelper.RepoFileOptions{ DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName, - CurrentRefPath: refPath, CurrentTreePath: treePath, + CurrentRefSubURL: refPath, CurrentTreePath: treePath, }) rctx = rctx.WithMarkupType(markdown.MarkupName) case "comment": @@ -87,7 +87,7 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur case "file": rctx = renderhelper.NewRenderContextRepoFile(ctx, repoModel, renderhelper.RepoFileOptions{ DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName, - CurrentRefPath: refPath, CurrentTreePath: treePath, + CurrentRefSubURL: refPath, CurrentTreePath: treePath, }) rctx = rctx.WithMarkupType("").WithRelativePath(filePath) // render the repo file content by its extension default: diff --git a/routers/web/org/home.go b/routers/web/org/home.go index 262b001e6a..56475c47f0 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -182,7 +182,7 @@ func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.Pr } rctx := renderhelper.NewRenderContextRepoFile(ctx, profileRepo, renderhelper.RepoFileOptions{ - CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileRepo.DefaultBranch)), + CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileRepo.DefaultBranch)), }) ctx.Data["ProfileReadmeContent"], err = markdown.RenderString(rctx, readmeBytes) if err != nil { diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 736a2dff00..c118f41381 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -9,7 +9,6 @@ import ( "fmt" "html/template" "net/http" - "path" "strings" asymkey_model "code.gitea.io/gitea/models/asymkey" @@ -409,7 +408,7 @@ func Diff(ctx *context.Context) { if err == nil { ctx.Data["NoteCommit"] = note.Commit ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit) - rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefPath: path.Join("commit", util.PathEscapeSegments(commitID))}) + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefSubURL: "commit/" + util.PathEscapeSegments(commitID)}) htmlMessage := template.HTML(template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{})))) ctx.Data["NoteRendered"], err = markup.PostProcessCommitMessage(rctx, htmlMessage) if err != nil { diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index ace871a9f1..4a68c96aaa 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -40,8 +40,8 @@ func RenderFile(ctx *context.Context) { defer blobReader.Close() rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Dir(ctx.Repo.TreePath), + CurrentRefSubURL: ctx.Repo.RefTypeNameSubURL(), + CurrentTreePath: path.Dir(ctx.Repo.TreePath), }).WithRelativePath(ctx.Repo.TreePath).WithStandalonePage(markup.StandalonePageOptions{ CurrentWebTheme: ctx.TemplateContext.CurrentWebTheme(), RenderQueryString: ctx.Req.URL.RawQuery, diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 8d7721103a..f6c97e83bb 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -59,8 +59,8 @@ func prepareFileViewLfsAttrs(ctx *context.Context) (*attribute.Attributes, bool) func handleFileViewRenderMarkup(ctx *context.Context, prefetchBuf []byte, utf8Reader io.Reader) bool { rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Dir(ctx.Repo.TreePath), + CurrentRefSubURL: ctx.Repo.RefTypeNameSubURL(), + CurrentTreePath: path.Dir(ctx.Repo.TreePath), }).WithRelativePath(ctx.Repo.TreePath) renderer := rctx.DetectMarkupRenderer(prefetchBuf) diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 25e1f87806..79ca9efc36 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -190,8 +190,8 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Dir(readmeFullPath), + CurrentRefSubURL: ctx.Repo.RefTypeNameSubURL(), + CurrentTreePath: path.Dir(readmeFullPath), }).WithRelativePath(readmeFullPath) renderer := rctx.DetectMarkupRenderer(buf) if renderer != nil { diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index faf2f442a2..b1d00520c2 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -251,7 +251,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R log.Error("failed to GetBlobContent: %v", err) } else { rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{ - CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), + CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), }) if profileContent, err := markdown.RenderString(rctx, bytes); err != nil { log.Error("failed to RenderString: %v", err) From 596a8868d741da01ee0520d1196459b444dc4699 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Tue, 28 Apr 2026 01:04:43 +0000 Subject: [PATCH 5/8] [skip ci] Updated translations via Crowdin --- options/locale/locale_zh-CN.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/options/locale/locale_zh-CN.json b/options/locale/locale_zh-CN.json index 0bab3a0010..5960e554b7 100644 --- a/options/locale/locale_zh-CN.json +++ b/options/locale/locale_zh-CN.json @@ -2474,7 +2474,7 @@ "repo.settings.tags.protection.allowed.noone": "无", "repo.settings.tags.protection.create": "保护 Git 标签", "repo.settings.tags.protection.none": "没有受保护的 Git 标签。", - "repo.settings.tags.protection.pattern.description": "您可以使用单个名称或 glob 表达式匹配或正则表达式来匹配多个 Git 标签。了解详情请访问 保护标签指南。", + "repo.settings.tags.protection.pattern.description": "您可以使用单个名称或 glob 表达式匹配或正则表达式来匹配多个 Git 标签。欲了解详情请访问 保护标签指南。", "repo.settings.bot_token": "Bot 令牌", "repo.settings.chat_id": "聊天 ID", "repo.settings.thread_id": "线程 ID", @@ -3099,11 +3099,11 @@ "admin.packages.size": "大小", "admin.packages.published": "已发布", "admin.defaulthooks": "默认 Web 钩子", - "admin.defaulthooks.desc": "当某些 Gitea 事件触发时,Web 钩子自动向服务器发出 HTTP POST 请求。这里定义的 Web 钩子是默认配置,将被复制到所有新的仓库中。详情请访问 Web 钩子指南。", + "admin.defaulthooks.desc": "当某些 Gitea 事件触发时,Web 钩子自动向服务器发出 HTTP POST 请求。这里定义的 Web 钩子是默认配置,将被复制到所有新的仓库中。欲了解详情请访问 Web 钩子指南。", "admin.defaulthooks.add_webhook": "添加默认 Web 钩子", "admin.defaulthooks.update_webhook": "更新默认 Web 钩子", "admin.systemhooks": "系统 Web 钩子", - "admin.systemhooks.desc": "当某些 Gitea 事件触发时,Web 钩子自动向服务器发出 HTTP POST 请求。这里定义的 Web 钩子将作用于系统上的所有仓库,所以请考虑这可能带来的任何性能影响。了解详情请访问 Web 钩子指南。", + "admin.systemhooks.desc": "当某些 Gitea 事件触发时,Web 钩子自动向服务器发出 HTTP POST 请求。这里定义的 Web 钩子将作用于系统上的所有仓库,所以请考虑这可能带来的任何性能影响。欲了解详情请访问 Web 钩子指南。", "admin.systemhooks.add_webhook": "添加系统 Web 钩子", "admin.systemhooks.update_webhook": "更新系统 Web 钩子", "admin.auths.auth_manage_panel": "认证源管理", From 15b23f037d8e7bf8d945a7fecb0ebb9cc4ab43c8 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 28 Apr 2026 09:29:09 +0800 Subject: [PATCH 6/8] Fix attachment Content-Security-Policy (#37455) See the comments. Others are not changed, only added a new rule for medias: `serveHeaderCspMedia` --------- Co-authored-by: Giteabot --- modules/httplib/serve.go | 51 +++++++++++++++++++++++++---------- modules/httplib/serve_test.go | 27 +++++++++++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 8abf6f1887..6c2fe9b0d6 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -37,6 +37,42 @@ type ServeHeaderOptions struct { LastModified time.Time } +const ( + // Disable JS execution on the same origin, since we serve the file from the same origin as Gitea server. + // This rule can be relaxed in the future as long as it is properly sandboxed. + // "style-src" is for SVG inline styles (from Display SVG files as images instead of text #14101) + serveHeaderCspDefault = "default-src 'none'; style-src 'unsafe-inline'; sandbox" + + // No sandbox attribute for PDF as it breaks rendering in at least Safari. + // This should generally be safe as scripts inside PDF can not escape the PDF document. + // See https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion. + // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context + serveHeaderCspPdf = "default-src 'none'; style-src 'unsafe-inline'" + + // For audios and videos, actually it doesn't really need CSP (just like Gitea <= 1.25) + serveHeaderCspAudioVideo = "" +) + +func serveSetHeaderContentRelated(w http.ResponseWriter, contentType string) { + header := w.Header() + contentType = util.IfZero(contentType, typesniffer.MimeTypeApplicationOctetStream) + header.Set("Content-Type", contentType) + header.Set("X-Content-Type-Options", "nosniff") + + csp := serveHeaderCspDefault + if strings.HasPrefix(contentType, "application/pdf") { + csp = serveHeaderCspPdf + } + if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") { + csp = serveHeaderCspAudioVideo + } + if csp != "" { + header.Set("Content-Security-Policy", csp) + } else { + header.Del("Content-Security-Policy") + } +} + // ServeSetHeaders sets necessary content serve headers func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { header := w.Header() @@ -46,24 +82,11 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { w.Header().Add(gzhttp.HeaderNoCompression, "1") } - contentType := util.IfZero(opts.ContentType, typesniffer.MimeTypeApplicationOctetStream) - header.Set("Content-Type", contentType) - header.Set("X-Content-Type-Options", "nosniff") + serveSetHeaderContentRelated(w, opts.ContentType) if opts.ContentLength != nil { header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10)) } - - // Disable script execution of HTML/SVG files, since we serve the file from the same origin as Gitea server - header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") - if strings.Contains(contentType, "application/pdf") { - // no sandbox attribute for PDF as it breaks rendering in at least safari. this - // should generally be safe as scripts inside PDF can not escape the PDF document - // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion - // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context - header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") - } - if opts.Filename != "" && opts.ContentDisposition != "" { header.Set("Content-Disposition", encodeContentDisposition(opts.ContentDisposition, path.Base(opts.Filename))) header.Set("Access-Control-Expose-Headers", "Content-Disposition") diff --git a/modules/httplib/serve_test.go b/modules/httplib/serve_test.go index 38cf4c197f..2a245300b0 100644 --- a/modules/httplib/serve_test.go +++ b/modules/httplib/serve_test.go @@ -12,6 +12,8 @@ import ( "strings" "testing" + "code.gitea.io/gitea/modules/typesniffer" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -106,3 +108,28 @@ func TestServeUserContentByFile(t *testing.T) { test(t, http.StatusPartialContent, data[1:]) }) } + +func TestServeSetHeaderContentRelated(t *testing.T) { + cases := []struct { + contentType string + csp string + }{ + {"", serveHeaderCspDefault}, + {"any", serveHeaderCspDefault}, + {"application/pdf", serveHeaderCspPdf}, + {"application/pdf; other", serveHeaderCspPdf}, + {"audio/mp4", serveHeaderCspAudioVideo}, + {"video/ogg; other", serveHeaderCspAudioVideo}, + {typesniffer.MimeTypeImageSvg, serveHeaderCspDefault}, + } + for _, c := range cases { + w := httptest.NewRecorder() + serveSetHeaderContentRelated(w, c.contentType) + csp := w.Header().Get("Content-Security-Policy") + assert.Equal(t, c.csp, csp, "content-type: %s", c.contentType) + assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options")) // it should always be there + } + + // make sure sandboxed + require.Contains(t, serveHeaderCspDefault, "; sandbox") +} From c8e67799b2fa6eb3fa0a087608a0241f6265a5e4 Mon Sep 17 00:00:00 2001 From: Rayan Salhab Date: Tue, 28 Apr 2026 04:58:04 +0300 Subject: [PATCH 7/8] Fix scheduled action panic with null event payload (#37459) This fixes the scheduled action panic when an event payload is JSON `null` by initializing the payload map before adding `schedule`. It also adds regression coverage for the null-payload case. Fixes #37447. Testing: - `go test -tags 'sqlite sqlite_unlock_notify' ./services/actions -run '^TestWithScheduleInEventPayload$' -count=1` - Local note: this agent ran the command as root with a temporary `GITEA_TEST_CONF=custom/conf/app-test-root.ini` file that only set `I_AM_BEING_UNSAFE_RUNNING_AS_ROOT = true`. Authorship: cyphercodes; AI assistance disclosed: Hermes Agent (GPT-5.5). --------- Co-authored-by: cyphercodes Co-authored-by: Hermes Agent (GPT-5.5) Co-authored-by: Nicolas Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.7) Co-authored-by: Giteabot --- services/actions/schedule_tasks.go | 18 +++++++++++++----- services/actions/schedule_tasks_test.go | 15 +++++++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index b2dc3f9840..3a0dff490a 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -132,14 +132,22 @@ func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleS } func withScheduleInEventPayload(eventPayload, schedule string) string { - if schedule == "" || eventPayload == "" { + if schedule == "" { return eventPayload } - event := map[string]any{} - if err := json.Unmarshal([]byte(eventPayload), &event); err != nil { - log.Error("withScheduleInEventPayload: unmarshal: %v", err) - return eventPayload + // eventPayload originates from json.Marshal(input.Payload) in handleSchedules, + // so a nil payload is stored as the literal "null" and pre-existing rows may be + // empty. Both cases start from a fresh map so the schedule field can still be set. + var event map[string]any + if eventPayload != "" { + if err := json.Unmarshal([]byte(eventPayload), &event); err != nil { + log.Error("withScheduleInEventPayload: unmarshal: %v", err) + return eventPayload + } + } + if event == nil { + event = map[string]any{} } event["schedule"] = schedule diff --git a/services/actions/schedule_tasks_test.go b/services/actions/schedule_tasks_test.go index 770b842623..f2c7e656e6 100644 --- a/services/actions/schedule_tasks_test.go +++ b/services/actions/schedule_tasks_test.go @@ -22,9 +22,20 @@ func TestWithScheduleInEventPayload(t *testing.T) { assert.Equal(t, "refs/heads/main", event["ref"]) }) - t.Run("keeps empty payload", func(t *testing.T) { + t.Run("adds schedule to null payload", func(t *testing.T) { + updated := withScheduleInEventPayload("null", "37 12 5 1 2") + + event := map[string]any{} + assert.NoError(t, json.Unmarshal([]byte(updated), &event)) + assert.Equal(t, "37 12 5 1 2", event["schedule"]) + }) + + t.Run("adds schedule to empty payload", func(t *testing.T) { updated := withScheduleInEventPayload("", "37 12 5 1 2") - assert.Empty(t, updated) + + event := map[string]any{} + assert.NoError(t, json.Unmarshal([]byte(updated), &event)) + assert.Equal(t, "37 12 5 1 2", event["schedule"]) }) t.Run("keeps payload when schedule empty", func(t *testing.T) { From 8bf51da65fe1167802678c02639ee11c20c2a815 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 28 Apr 2026 12:36:39 +0800 Subject: [PATCH 8/8] Refactor pull request view (4) (#37451) Use JSON attribute instead of inline script --------- Co-authored-by: Nicolas --- routers/web/repo/issue_view.go | 70 +-------- routers/web/repo/pull.go | 13 +- routers/web/repo/pull_merge_form.go | 147 ++++++++++++++++++ .../issue/view_content/pull_merge_box.tmpl | 97 +----------- .../view_content/pull_merge_instruction.tmpl | 34 ++-- .../js/components/PullRequestMergeForm.vue | 6 +- web_src/js/features/repo-issue-pull.ts | 24 +-- 7 files changed, 194 insertions(+), 197 deletions(-) create mode 100644 routers/web/repo/pull_merge_form.go diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index af13a1156e..852b880ab0 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -18,7 +18,6 @@ import ( "code.gitea.io/gitea/models/organization" access_model "code.gitea.io/gitea/models/perm/access" project_model "code.gitea.io/gitea/models/project" - pull_model "code.gitea.io/gitea/models/pull" "code.gitea.io/gitea/models/renderhelper" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" @@ -826,6 +825,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue * panic("impossible, issue must be the same") } + pull := issue.PullRequest data := &pullMergeBoxData{} prInfo.MergeBoxData = data @@ -834,14 +834,12 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue * statusCheckData = &pullCommitStatusCheckData{} // make the following logic easier, no need to keep checking "nil" } - pull := issue.PullRequest canDelete := false allowMerge := false canWriteToHeadRepo := false pull_service.StartPullRequestCheckOnView(ctx, pull) - ctx.Data["GetCommitMessages"] = "" if !prInfo.IsPullRequestBroken { var err error ctx.Data["UpdateAllowed"], ctx.Data["UpdateByRebaseAllowed"], err = pull_service.IsUserAllowedToUpdate(ctx, pull, ctx.Doer) @@ -849,7 +847,6 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue * ctx.ServerError("IsUserAllowedToUpdate", err) return } - ctx.Data["GetCommitMessages"] = pull_service.GetSquashMergeCommitMessages(ctx, pull) } if pull.IsFilesConflicted() { @@ -903,59 +900,11 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue * } } - data.ReloadingInterval = util.Iif(pull != nil && pull.IsChecking(), 2000, 0) - ctx.Data["CanWriteToHeadRepo"] = canWriteToHeadRepo - ctx.Data["ShowMergeInstructions"] = canWriteToHeadRepo + data.ReloadingInterval = util.Iif(pull.IsChecking(), 2000, 0) + data.ShowMergeInstructions = canWriteToHeadRepo + data.ShowPullCommands = pull.HeadRepo != nil && !pull.HasMerged && !issue.IsClosed ctx.Data["AllowMerge"] = allowMerge - prUnit, err := issue.Repo.GetUnit(ctx, unit.TypePullRequests) - if err != nil { - ctx.ServerError("GetUnit", err) - return - } - prConfig := prUnit.PullRequestsConfig() - - ctx.Data["AutodetectManualMerge"] = prConfig.AutodetectManualMerge - - var mergeStyle repo_model.MergeStyle - // Check correct values and select default - if ms, ok := ctx.Data["MergeStyle"].(repo_model.MergeStyle); !ok || - !prConfig.IsMergeStyleAllowed(ms) { - if prConfig.IsMergeStyleAllowed(prConfig.DefaultMergeStyle) && !ok { - mergeStyle = prConfig.DefaultMergeStyle - } else if prConfig.AllowMerge { - mergeStyle = repo_model.MergeStyleMerge - } else if prConfig.AllowRebase { - mergeStyle = repo_model.MergeStyleRebase - } else if prConfig.AllowRebaseMerge { - mergeStyle = repo_model.MergeStyleRebaseMerge - } else if prConfig.AllowSquash { - mergeStyle = repo_model.MergeStyleSquash - } else if prConfig.AllowFastForwardOnly { - mergeStyle = repo_model.MergeStyleFastForwardOnly - } else if prConfig.AllowManualMerge { - mergeStyle = repo_model.MergeStyleManuallyMerged - } - } - - ctx.Data["MergeStyle"] = mergeStyle - - defaultMergeMessage, defaultMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle) - if err != nil { - ctx.ServerError("GetDefaultMergeMessage", err) - return - } - ctx.Data["DefaultMergeMessage"] = defaultMergeMessage - ctx.Data["DefaultMergeBody"] = defaultMergeBody - - defaultSquashMergeMessage, defaultSquashMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, repo_model.MergeStyleSquash) - if err != nil { - ctx.ServerError("GetDefaultSquashMergeMessage", err) - return - } - ctx.Data["DefaultSquashMergeMessage"] = defaultSquashMergeMessage - ctx.Data["DefaultSquashMergeBody"] = defaultSquashMergeBody - pb := prInfo.ProtectedBranchRule if pb != nil { pb.Repo = pull.BaseRepo @@ -995,6 +944,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue * return } + prConfig := issue.Repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig() + data.AutodetectManualMerge = prConfig.AutodetectManualMerge + stillCanManualMerge := func() bool { if pull.HasMerged || issue.IsClosed || !ctx.IsSigned { return false @@ -1007,13 +959,6 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue * ctx.Data["StillCanManualMerge"] = stillCanManualMerge() - // Check if there is a pending pr merge - ctx.Data["HasPendingPullRequestMerge"], ctx.Data["PendingPullRequestMerge"], err = pull_model.GetScheduledMergeByPullID(ctx, pull.ID) - if err != nil { - ctx.ServerError("GetScheduledMergeByPullID", err) - return - } - enableStatusCheck := pb != nil && pb.EnableStatusCheck ctx.Data["EnableStatusCheck"] = enableStatusCheck @@ -1043,6 +988,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue * (!data.requireSigned || data.willSign) // signing requirement is satisfied ctx.Data["PullMergeBoxData"] = prInfo.MergeBoxData + prInfo.prepareMergeBoxFormProps(ctx) } func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) { diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index c532cbba22..7b31a26d6f 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -164,12 +164,13 @@ func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) { func (prInfo *pullRequestViewInfo) setTemplateDataMergeTarget(ctx *context.Context) { pull := prInfo.issue.PullRequest if ctx.Repo.Owner.Name == pull.MustHeadUserName(ctx) { - ctx.Data["HeadTarget"] = pull.HeadBranch + prInfo.headTarget = pull.HeadBranch } else if pull.HeadRepo == nil { - ctx.Data["HeadTarget"] = ctx.Locale.Tr("repo.pull.deleted_branch", pull.HeadBranch) + prInfo.headTarget = ctx.Locale.TrString("repo.pull.deleted_branch", pull.HeadBranch) } else { - ctx.Data["HeadTarget"] = pull.MustHeadUserName(ctx) + "/" + pull.HeadRepo.Name + ":" + pull.HeadBranch + prInfo.headTarget = pull.MustHeadUserName(ctx) + "/" + pull.HeadRepo.Name + ":" + pull.HeadBranch } + ctx.Data["HeadTarget"] = prInfo.headTarget ctx.Data["BaseTarget"] = pull.BaseBranch headBranchLink := "" if pull.Flow == issues_model.PullRequestFlowGithub { @@ -268,6 +269,11 @@ type pullMergeBoxData struct { HasOverridableBlockers bool CanMergeNow bool + MergeFormProps map[string]any + ShowPullCommands bool + ShowMergeInstructions bool + AutodetectManualMerge bool + // don't expose unneeded fields to templates, need more refactoring changes hasStatusCheckBlocker bool isPullBranchDeletable bool @@ -289,6 +295,7 @@ type pullRequestViewInfo struct { IsPullRequestBroken bool HeadBranchCommitID string + headTarget string // for display purpose only CompareInfo git_service.CompareInfo ProtectedBranchRule *git_model.ProtectedBranch diff --git a/routers/web/repo/pull_merge_form.go b/routers/web/repo/pull_merge_form.go new file mode 100644 index 0000000000..b390fd6934 --- /dev/null +++ b/routers/web/repo/pull_merge_form.go @@ -0,0 +1,147 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "html/template" + + 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/templates" + "code.gitea.io/gitea/services/context" + pull_service "code.gitea.io/gitea/services/pull" +) + +func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context) { + pull := prInfo.issue.PullRequest + prConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig() + + // Check correct values and select default + var mergeStyle repo_model.MergeStyle + if prConfig.IsMergeStyleAllowed(prConfig.DefaultMergeStyle) { + mergeStyle = prConfig.DefaultMergeStyle + } else if prConfig.AllowMerge { + mergeStyle = repo_model.MergeStyleMerge + } else if prConfig.AllowRebase { + mergeStyle = repo_model.MergeStyleRebase + } else if prConfig.AllowRebaseMerge { + mergeStyle = repo_model.MergeStyleRebaseMerge + } else if prConfig.AllowSquash { + mergeStyle = repo_model.MergeStyleSquash + } else if prConfig.AllowFastForwardOnly { + mergeStyle = repo_model.MergeStyleFastForwardOnly + } else if prConfig.AllowManualMerge { + mergeStyle = repo_model.MergeStyleManuallyMerged + } + if mergeStyle == "" { + return + } + + // Check if there is a pending pr merge + hasPendingPullRequestMerge, pendingPullRequestMerge, err := pull_model.GetScheduledMergeByPullID(ctx, pull.ID) + if err != nil { + ctx.ServerError("GetScheduledMergeByPullID", err) + return + } + + var hasPendingPullRequestMergeTip template.HTML + if hasPendingPullRequestMerge { + createdPRMergeStr := templates.TimeSince(pendingPullRequestMerge.CreatedUnix) + hasPendingPullRequestMergeTip = ctx.Locale.Tr("repo.pulls.auto_merge_has_pending_schedule", pendingPullRequestMerge.Doer.Name, createdPRMergeStr) + } + + defaultMergeTitle, defaultMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle) + if err != nil { + ctx.ServerError("GetDefaultMergeMessage", err) + return + } + defaultSquashMergeTitle, defaultSquashMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, repo_model.MergeStyleSquash) + if err != nil { + ctx.ServerError("GetDefaultSquashMergeMessage", err) + return + } + + var defaultSquashMergeCommitMessages string + if !prInfo.IsPullRequestBroken { + defaultSquashMergeCommitMessages = pull_service.GetSquashMergeCommitMessages(ctx, pull) + } + + allOverridableChecksOk := !prInfo.MergeBoxData.HasOverridableBlockers + prInfo.MergeBoxData.MergeFormProps = map[string]any{ + "baseLink": prInfo.issue.Link(), + "textCancel": ctx.Locale.Tr("cancel"), + "textDeleteBranch": ctx.Locale.Tr("repo.branch.delete", prInfo.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": prInfo.MergeBoxData.CanMergeNow, + "allOverridableChecksOk": allOverridableChecksOk, + "emptyCommit": pull.IsEmpty(), + "pullHeadCommitID": prInfo.CompareInfo.HeadCommitID, + "isPullBranchDeletable": prInfo.MergeBoxData.isPullBranchDeletable, + "defaultMergeStyle": mergeStyle, + "defaultDeleteBranchAfterMerge": prConfig.DefaultDeleteBranchAfterMerge, + "mergeMessageFieldPlaceHolder": ctx.Locale.Tr("repo.editor.commit_message_desc"), + "defaultMergeMessage": defaultMergeBody, + + "hasPendingPullRequestMerge": hasPendingPullRequestMerge, + "hasPendingPullRequestMergeTip": hasPendingPullRequestMergeTip, + } + + // if this pr can be merged now, then hide the auto merge + generalHideAutoMerge := prInfo.MergeBoxData.CanMergeNow && allOverridableChecksOk + + prInfo.MergeBoxData.MergeFormProps["mergeStyles"] = []any{ + map[string]any{ + "name": "merge", + "allowed": prConfig.AllowMerge, + "textDoMerge": ctx.Locale.Tr("repo.pulls.merge_pull_request"), + "mergeTitleFieldText": defaultMergeTitle, + "mergeMessageFieldText": defaultMergeBody, + "hideAutoMerge": generalHideAutoMerge, + }, + map[string]any{ + "name": "rebase", + "allowed": prConfig.AllowRebase, + "textDoMerge": ctx.Locale.Tr("repo.pulls.rebase_merge_pull_request"), + "hideMergeMessageTexts": true, + "hideAutoMerge": generalHideAutoMerge, + }, + map[string]any{ + "name": "rebase-merge", + "allowed": prConfig.AllowRebaseMerge, + "textDoMerge": ctx.Locale.Tr("repo.pulls.rebase_merge_commit_pull_request"), + "mergeTitleFieldText": defaultMergeTitle, + "mergeMessageFieldText": defaultMergeBody, + "hideAutoMerge": generalHideAutoMerge, + }, + map[string]any{ + "name": "squash", + "allowed": prConfig.AllowSquash, + "textDoMerge": ctx.Locale.Tr("repo.pulls.squash_merge_pull_request"), + "mergeTitleFieldText": defaultSquashMergeTitle, + "mergeMessageFieldText": defaultSquashMergeCommitMessages + defaultSquashMergeBody, + "hideAutoMerge": generalHideAutoMerge, + }, + map[string]any{ + "name": "fast-forward-only", + "allowed": prConfig.AllowFastForwardOnly && pull.CommitsBehind == 0, + "textDoMerge": ctx.Locale.Tr("repo.pulls.fast_forward_only_merge_pull_request"), + "hideMergeMessageTexts": true, + "hideAutoMerge": generalHideAutoMerge, + }, + map[string]any{ + "name": "manually-merged", + "allowed": prConfig.AllowManualMerge, + "textDoMerge": ctx.Locale.Tr("repo.pulls.merge_manually"), + "hideMergeMessageTexts": true, + "hideAutoMerge": true, + }, + } +} diff --git a/templates/repo/issue/view_content/pull_merge_box.tmpl b/templates/repo/issue/view_content/pull_merge_box.tmpl index 84d703debc..768d3b3119 100644 --- a/templates/repo/issue/view_content/pull_merge_box.tmpl +++ b/templates/repo/issue/view_content/pull_merge_box.tmpl @@ -208,100 +208,11 @@ {{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}} + {{if $data.MergeFormProps}}
- - {{$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 */}} -
+
{{else}} {{/* no merge style was set in repo setting: not or ($prUnit.PullRequestsConfig.AllowMerge ...) */}}
@@ -396,8 +307,8 @@ {{end}} - {{if and .Issue.PullRequest.HeadRepo (not .Issue.PullRequest.HasMerged) (not .Issue.IsClosed)}} - {{template "repo/issue/view_content/pull_merge_instruction" dict "PullRequest" .Issue.PullRequest "ShowMergeInstructions" .ShowMergeInstructions}} + {{if $data.ShowPullCommands}} + {{template "repo/issue/view_content/pull_merge_instruction" dict "PullRequest" .Issue.PullRequest "MergeBoxData" $data}} {{end}}
diff --git a/templates/repo/issue/view_content/pull_merge_instruction.tmpl b/templates/repo/issue/view_content/pull_merge_instruction.tmpl index b52333466d..ad85c00450 100644 --- a/templates/repo/issue/view_content/pull_merge_instruction.tmpl +++ b/templates/repo/issue/view_content/pull_merge_instruction.tmpl @@ -1,57 +1,59 @@ +{{$data := $.MergeBoxData}} +{{$pull := $.PullRequest}}
{{ctx.Locale.Tr "repo.pulls.cmd_instruction_hint"}}

{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_title"}}

{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_desc"}}
- {{$localBranch := .PullRequest.HeadBranch}} - {{if ne .PullRequest.HeadRepo.ID .PullRequest.BaseRepo.ID}} - {{$localBranch = print .PullRequest.HeadRepo.OwnerName "-" .PullRequest.HeadBranch}} + {{$localBranch := $pull.HeadBranch}} + {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}} + {{$localBranch = print $pull.HeadRepo.OwnerName "-" $pull.HeadBranch}} {{end}}
{{$gitRemoteName := ctx.RootData.SystemConfig.Repository.GitGuideRemoteName.Value ctx}} - {{if eq .PullRequest.Flow 0}} -
git fetch -u {{if ne .PullRequest.HeadRepo.ID .PullRequest.BaseRepo.ID}}{{ctx.AppFullLink .PullRequest.HeadRepo.Link}}{{else}}{{$gitRemoteName}}{{end}} {{.PullRequest.HeadBranch}}:{{$localBranch}}
+ {{if eq $pull.Flow 0}} +
git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{ctx.AppFullLink $pull.HeadRepo.Link}}{{else}}{{$gitRemoteName}}{{end}} {{$pull.HeadBranch}}:{{$localBranch}}
{{else}} -
git fetch -u {{$gitRemoteName}} {{.PullRequest.GetGitHeadRefName}}:{{$localBranch}}
+
git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$localBranch}}
{{end}}
git checkout {{$localBranch}}
- {{if .ShowMergeInstructions}} + {{if $data.ShowMergeInstructions}}

{{ctx.Locale.Tr "repo.pulls.cmd_instruction_merge_title"}}

{{ctx.Locale.Tr "repo.pulls.cmd_instruction_merge_desc"}} - {{if not .AutodetectManualMerge}} + {{if not $data.AutodetectManualMerge}}
{{ctx.Locale.Tr "repo.pulls.cmd_instruction_merge_warning"}}
{{end}}
-
git checkout {{.PullRequest.BaseBranch}}
+
git checkout {{$pull.BaseBranch}}
git merge --no-ff {{$localBranch}}
-
git checkout {{.PullRequest.BaseBranch}}
+
git checkout {{$pull.BaseBranch}}
git merge --ff-only {{$localBranch}}
git checkout {{$localBranch}}
-
git rebase {{.PullRequest.BaseBranch}}
-
git checkout {{.PullRequest.BaseBranch}}
+
git rebase {{$pull.BaseBranch}}
+
git checkout {{$pull.BaseBranch}}
git merge --no-ff {{$localBranch}}
-
git checkout {{.PullRequest.BaseBranch}}
+
git checkout {{$pull.BaseBranch}}
git merge --squash {{$localBranch}}
-
git checkout {{.PullRequest.BaseBranch}}
+
git checkout {{$pull.BaseBranch}}
git merge --ff-only {{$localBranch}}
-
git checkout {{.PullRequest.BaseBranch}}
+
git checkout {{$pull.BaseBranch}}
git merge {{$localBranch}}
-
git push {{$gitRemoteName}} {{.PullRequest.BaseBranch}}
+
git push {{$gitRemoteName}} {{$pull.BaseBranch}}
{{end}}
diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue index 7ae8fbfdaf..d2835fe163 100644 --- a/web_src/js/components/PullRequestMergeForm.vue +++ b/web_src/js/components/PullRequestMergeForm.vue @@ -3,9 +3,11 @@ 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<{ + mergeFormProps: any, // TODO: this is a huge object, need to be refactored in the future +}>(); -const mergeForm = pageData.pullRequestMergeForm!; +const mergeForm = props.mergeFormProps; const mergeTitleFieldValue = shallowRef(''); const mergeMessageFieldValue = shallowRef(''); diff --git a/web_src/js/features/repo-issue-pull.ts b/web_src/js/features/repo-issue-pull.ts index 4e148b3ec0..33e072a1a5 100644 --- a/web_src/js/features/repo-issue-pull.ts +++ b/web_src/js/features/repo-issue-pull.ts @@ -63,27 +63,10 @@ async function initRepoPullRequestMergeForm(box: HTMLElement) { const el = box.querySelector('#pull-request-merge-form'); if (!el) return; + const data = JSON.parse(el.getAttribute('data-merge-form-props')!); const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue'); - const view = createApp(PullRequestMergeForm); - view.mount(el); -} - -function executeScripts(elem: Element) { - // find any existing nonce value from the current page and apply it to the new script - const scriptNonce = document.querySelector('script[nonce]')!.getAttribute('nonce')!; - for (const oldScript of elem.querySelectorAll('script')) { - // TODO: that's the only way to load the data for the merge form. In the future - // we need to completely decouple the page data and embedded script - // eslint-disable-next-line github/no-dynamic-script-tag - const newScript = document.createElement('script'); - for (const attr of oldScript.attributes) { - if (attr.name === 'type' && attr.value === 'module') continue; - newScript.setAttribute(attr.name, attr.value); - } - newScript.setAttribute('nonce', scriptNonce); - newScript.text = oldScript.text; - document.body.append(newScript); - } + const view = createApp(PullRequestMergeForm, {mergeFormProps: data}); + view.mount(el); // TODO: can unmount when reloaded? } export function initRepoPullMergeBox(el: HTMLElement) { @@ -124,7 +107,6 @@ export function initRepoPullMergeBox(el: HTMLElement) { } document.removeEventListener('visibilitychange', onVisibilityChange); const newElem = createElementFromHTML(await resp.text()); - executeScripts(newElem); el.replaceWith(newElem); };