From 61b495e5ab604a26c867433e5c5ae5b07267e30f Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Tue, 30 Apr 2024 10:36:32 +0800
Subject: [PATCH 01/47] Fix issue label rendering in the issue popup (#30763)

---
 modules/templates/util_render.go         | 39 +++++++++++-------------
 routers/web/repo/issue.go                |  5 ++-
 tests/integration/issue_test.go          | 11 +++++--
 web_src/js/components/ContextPopup.vue   | 27 ++++------------
 web_src/js/features/common-issue-list.js |  2 +-
 5 files changed, 36 insertions(+), 48 deletions(-)

diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index 659422aee7..b15de6521d 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -121,29 +121,25 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string)
 // RenderLabel renders a label
 // locale is needed due to an import cycle with our context providing the `Tr` function
 func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
-	var (
-		archivedCSSClass string
-		textColor        = util.ContrastColor(label.Color)
-		labelScope       = label.ExclusiveScope()
-	)
-
-	description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
+	var extraCSSClasses string
+	textColor := util.ContrastColor(label.Color)
+	labelScope := label.ExclusiveScope()
+	descriptionText := emoji.ReplaceAliases(label.Description)
 
 	if label.IsArchived() {
-		archivedCSSClass = "archived-label"
-		description = fmt.Sprintf("(%s) %s", locale.TrString("archived"), description)
+		extraCSSClasses = "archived-label"
+		descriptionText = fmt.Sprintf("(%s) %s", locale.TrString("archived"), descriptionText)
 	}
 
 	if labelScope == "" {
 		// Regular label
-		s := fmt.Sprintf("<div class='ui label %s' style='color: %s !important; background-color: %s !important;' data-tooltip-content title='%s'>%s</div>",
-			archivedCSSClass, textColor, label.Color, description, RenderEmoji(ctx, label.Name))
-		return template.HTML(s)
+		return HTMLFormat(`<div class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s">%s</div>`,
+			extraCSSClasses, textColor, label.Color, descriptionText, RenderEmoji(ctx, label.Name))
 	}
 
 	// Scoped label
-	scopeText := RenderEmoji(ctx, labelScope)
-	itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:])
+	scopeHTML := RenderEmoji(ctx, labelScope)
+	itemHTML := RenderEmoji(ctx, label.Name[len(labelScope)+1:])
 
 	// Make scope and item background colors slightly darker and lighter respectively.
 	// More contrast needed with higher luminance, empirically tweaked.
@@ -171,14 +167,13 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
 	itemColor := "#" + hex.EncodeToString(itemBytes)
 	scopeColor := "#" + hex.EncodeToString(scopeBytes)
 
-	s := fmt.Sprintf("<span class='ui label %s scope-parent' data-tooltip-content title='%s'>"+
-		"<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+
-		"<div class='ui label scope-right' style='color: %s !important; background-color: %s !important'>%s</div>"+
-		"</span>",
-		archivedCSSClass, description,
-		textColor, scopeColor, scopeText,
-		textColor, itemColor, itemText)
-	return template.HTML(s)
+	return HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
+		`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
+		`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
+		`</span>`,
+		extraCSSClasses, descriptionText,
+		textColor, scopeColor, scopeHTML,
+		textColor, itemColor, itemHTML)
 }
 
 // RenderEmoji renders html text with emoji post processors
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index de6ef9e93b..0c8363a168 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -2177,7 +2177,10 @@ func GetIssueInfo(ctx *context.Context) {
 		}
 	}
 
-	ctx.JSON(http.StatusOK, convert.ToIssue(ctx, ctx.Doer, issue))
+	ctx.JSON(http.StatusOK, map[string]any{
+		"convertedIssue": convert.ToIssue(ctx, ctx.Doer, issue),
+		"renderedLabels": templates.RenderLabels(ctx, ctx.Locale, issue.Labels, ctx.Repo.RepoLink, issue),
+	})
 }
 
 // UpdateIssueTitle change issue's title
diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go
index 44d362d9c7..b7952b0879 100644
--- a/tests/integration/issue_test.go
+++ b/tests/integration/issue_test.go
@@ -6,6 +6,7 @@ package integration
 import (
 	"context"
 	"fmt"
+	"html/template"
 	"net/http"
 	"net/url"
 	"path"
@@ -573,10 +574,14 @@ func TestGetIssueInfo(t *testing.T) {
 	urlStr := fmt.Sprintf("/%s/%s/issues/%d/info", owner.Name, repo.Name, issue.Index)
 	req := NewRequest(t, "GET", urlStr)
 	resp := session.MakeRequest(t, req, http.StatusOK)
-	var apiIssue api.Issue
-	DecodeJSON(t, resp, &apiIssue)
+	var respStruct struct {
+		ConvertedIssue api.Issue
+		RenderedLabels template.HTML
+	}
+	DecodeJSON(t, resp, &respStruct)
 
-	assert.EqualValues(t, issue.ID, apiIssue.ID)
+	assert.EqualValues(t, issue.ID, respStruct.ConvertedIssue.ID)
+	assert.Contains(t, string(respStruct.RenderedLabels), `"labels-list"`)
 }
 
 func TestUpdateIssueDeadline(t *testing.T) {
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
index 65a6089522..e4e8bce184 100644
--- a/web_src/js/components/ContextPopup.vue
+++ b/web_src/js/components/ContextPopup.vue
@@ -1,6 +1,5 @@
 <script>
 import {SvgIcon} from '../svg.js';
-import {contrastColor} from '../utils/color.js';
 import {GET} from '../modules/fetch.js';
 
 const {appSubUrl, i18n} = window.config;
@@ -10,6 +9,7 @@ export default {
   data: () => ({
     loading: false,
     issue: null,
+    renderedLabels: '',
     i18nErrorOccurred: i18n.error_occurred,
     i18nErrorMessage: null,
   }),
@@ -56,14 +56,6 @@ export default {
       }
       return 'red'; // Closed Issue
     },
-
-    labels() {
-      return this.issue.labels.map((label) => ({
-        name: label.name,
-        color: `#${label.color}`,
-        textColor: contrastColor(`#${label.color}`),
-      }));
-    },
   },
   mounted() {
     this.$refs.root.addEventListener('ce-load-context-popup', (e) => {
@@ -79,13 +71,14 @@ export default {
       this.i18nErrorMessage = null;
 
       try {
-        const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`);
+        const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`); // backend: GetIssueInfo
         const respJson = await response.json();
         if (!response.ok) {
           this.i18nErrorMessage = respJson.message ?? i18n.network_error;
           return;
         }
-        this.issue = respJson;
+        this.issue = respJson.convertedIssue;
+        this.renderedLabels = respJson.renderedLabels;
       } catch {
         this.i18nErrorMessage = i18n.network_error;
       } finally {
@@ -102,16 +95,8 @@ export default {
       <p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p>
       <p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p>
       <p>{{ body }}</p>
-      <div class="labels-list">
-        <div
-          v-for="label in labels"
-          :key="label.name"
-          class="ui label"
-          :style="{ color: label.textColor, backgroundColor: label.color }"
-        >
-          {{ label.name }}
-        </div>
-      </div>
+      <!-- eslint-disable-next-line vue/no-v-html -->
+      <div v-html="renderedLabels"/>
     </div>
     <div v-if="!loading && issue === null">
       <p><small>{{ i18nErrorOccurred }}</small></p>
diff --git a/web_src/js/features/common-issue-list.js b/web_src/js/features/common-issue-list.js
index 0c0f6c563d..219a8a9c9a 100644
--- a/web_src/js/features/common-issue-list.js
+++ b/web_src/js/features/common-issue-list.js
@@ -53,7 +53,7 @@ export function initCommonIssueListQuickGoto() {
     // try to check whether the parsed goto link is valid
     let targetUrl = parseIssueListQuickGotoLink(repoLink, searchText);
     if (targetUrl) {
-      const res = await GET(`${targetUrl}/info`);
+      const res = await GET(`${targetUrl}/info`); // backend: GetIssueInfo, it only checks whether the issue exists by status code
       if (res.status !== 200) targetUrl = '';
     }
     // if the input value has changed, then ignore the result

From 7ad50313284db7eec565ad1750108de1444c5a84 Mon Sep 17 00:00:00 2001
From: Zettat123 <zettat123@gmail.com>
Date: Tue, 30 Apr 2024 11:53:16 +0800
Subject: [PATCH 02/47] Fix duplicate status check contexts (#30660)

Caused by #30076.

There may be some duplicate status check contexts when setting status
checks for a branch protection rule. The duplicate contexts should be
removed.

Before:
<img
src="https://github.com/go-gitea/gitea/assets/15528715/97f4de2d-4868-47a3-8a99-5a180f9ac0a3"
width="600px" />

After:
<img
src="https://github.com/go-gitea/gitea/assets/15528715/ff7289c5-9793-4090-ba31-e8cb3c85f8a3"
width="600px" />
---
 models/git/commit_status.go      | 30 +++--------------
 models/git/commit_status_test.go | 56 ++++++++++++++++++++++++++++++++
 2 files changed, 61 insertions(+), 25 deletions(-)

diff --git a/models/git/commit_status.go b/models/git/commit_status.go
index c3cda7b73d..d12afc42c5 100644
--- a/models/git/commit_status.go
+++ b/models/git/commit_status.go
@@ -397,36 +397,16 @@ func GetLatestCommitStatusForRepoCommitIDs(ctx context.Context, repoID int64, co
 
 // FindRepoRecentCommitStatusContexts returns repository's recent commit status contexts
 func FindRepoRecentCommitStatusContexts(ctx context.Context, repoID int64, before time.Duration) ([]string, error) {
-	type result struct {
-		Index int64
-		SHA   string
-	}
-	getBase := func() *xorm.Session {
-		return db.GetEngine(ctx).Table(&CommitStatus{}).Where("repo_id = ?", repoID)
-	}
-
 	start := timeutil.TimeStampNow().AddDuration(-before)
-	results := make([]result, 0, 10)
 
-	sess := getBase().And("updated_unix >= ?", start).
-		Select("max( `index` ) as `index`, sha").
-		GroupBy("context_hash, sha").OrderBy("max( `index` ) desc")
-
-	err := sess.Find(&results)
-	if err != nil {
+	var contexts []string
+	if err := db.GetEngine(ctx).Table("commit_status").
+		Where("repo_id = ?", repoID).And("updated_unix >= ?", start).
+		Cols("context").Distinct().Find(&contexts); err != nil {
 		return nil, err
 	}
 
-	contexts := make([]string, 0, len(results))
-	if len(results) == 0 {
-		return contexts, nil
-	}
-
-	conds := make([]builder.Cond, 0, len(results))
-	for _, result := range results {
-		conds = append(conds, builder.Eq{"`index`": result.Index, "sha": result.SHA})
-	}
-	return contexts, getBase().And(builder.Or(conds...)).Select("context").Find(&contexts)
+	return contexts, nil
 }
 
 // NewCommitStatusOptions holds options for creating a CommitStatus
diff --git a/models/git/commit_status_test.go b/models/git/commit_status_test.go
index 74ba4a1006..08eba6e293 100644
--- a/models/git/commit_status_test.go
+++ b/models/git/commit_status_test.go
@@ -5,11 +5,15 @@ package git_test
 
 import (
 	"testing"
+	"time"
 
 	"code.gitea.io/gitea/models/db"
 	git_model "code.gitea.io/gitea/models/git"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/structs"
 
 	"github.com/stretchr/testify/assert"
@@ -175,3 +179,55 @@ func Test_CalcCommitStatus(t *testing.T) {
 		assert.Equal(t, kase.expected, git_model.CalcCommitStatus(kase.statuses))
 	}
 }
+
+func TestFindRepoRecentCommitStatusContexts(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo2)
+	assert.NoError(t, err)
+	defer gitRepo.Close()
+
+	commit, err := gitRepo.GetBranchCommit(repo2.DefaultBranch)
+	assert.NoError(t, err)
+
+	defer func() {
+		_, err := db.DeleteByBean(db.DefaultContext, &git_model.CommitStatus{
+			RepoID:    repo2.ID,
+			CreatorID: user2.ID,
+			SHA:       commit.ID.String(),
+		})
+		assert.NoError(t, err)
+	}()
+
+	err = git_model.NewCommitStatus(db.DefaultContext, git_model.NewCommitStatusOptions{
+		Repo:    repo2,
+		Creator: user2,
+		SHA:     commit.ID,
+		CommitStatus: &git_model.CommitStatus{
+			State:     structs.CommitStatusFailure,
+			TargetURL: "https://example.com/tests/",
+			Context:   "compliance/lint-backend",
+		},
+	})
+	assert.NoError(t, err)
+
+	err = git_model.NewCommitStatus(db.DefaultContext, git_model.NewCommitStatusOptions{
+		Repo:    repo2,
+		Creator: user2,
+		SHA:     commit.ID,
+		CommitStatus: &git_model.CommitStatus{
+			State:     structs.CommitStatusSuccess,
+			TargetURL: "https://example.com/tests/",
+			Context:   "compliance/lint-backend",
+		},
+	})
+	assert.NoError(t, err)
+
+	contexts, err := git_model.FindRepoRecentCommitStatusContexts(db.DefaultContext, repo2.ID, time.Hour)
+	assert.NoError(t, err)
+	if assert.Len(t, contexts, 1) {
+		assert.Equal(t, "compliance/lint-backend", contexts[0])
+	}
+}

From 059b2718a5615c01b897283f6ae53c9702f11239 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Tue, 30 Apr 2024 12:26:13 +0800
Subject: [PATCH 03/47] Right align the "Settings" menu item in overflow-menu
 (#30764)

I guess there could be enough people liking to make the Settings menu
item right aligned. As a site admin, I found it's easier to find the
right-aligned Settings menu item.

Tested with various sizes:

![image](https://github.com/go-gitea/gitea/assets/2114189/92836527-2cb2-4531-9296-233c5bd698f4)

![image](https://github.com/go-gitea/gitea/assets/2114189/3a0729fc-5e33-44b5-9fb4-3a4e787405b5)

![image](https://github.com/go-gitea/gitea/assets/2114189/9845ab6b-88e3-4e5a-8d6d-2b8af259d593)
---
 templates/org/menu.tmpl                   |  3 ++-
 templates/repo/header.tmpl                |  1 +
 web_src/css/base.css                      | 17 ++++++++++++
 web_src/css/modules/container.css         | 32 -----------------------
 web_src/js/webcomponents/overflow-menu.js | 24 +++++++++++++----
 5 files changed, 39 insertions(+), 38 deletions(-)

diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl
index c519606d1f..698a9559c5 100644
--- a/templates/org/menu.tmpl
+++ b/templates/org/menu.tmpl
@@ -40,8 +40,9 @@
 			</a>
 			{{end}}
 			{{if .IsOrganizationOwner}}
+			<span class="item-flex-space"></span>
 			<a class="{{if .PageIsOrgSettings}}active {{end}}item" href="{{.OrgLink}}/settings">
-			{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
+				{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
 			</a>
 			{{end}}
 		</div>
diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl
index 775aa30063..c0d833a187 100644
--- a/templates/repo/header.tmpl
+++ b/templates/repo/header.tmpl
@@ -216,6 +216,7 @@
 				{{template "custom/extra_tabs" .}}
 
 				{{if .Permission.IsAdmin}}
+					<span class="item-flex-space"></span>
 					<a class="{{if .PageIsRepoSettings}}active {{end}} item" href="{{.RepoLink}}/settings">
 						{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
 					</a>
diff --git a/web_src/css/base.css b/web_src/css/base.css
index df9028b50a..1d65bb37e7 100644
--- a/web_src/css/base.css
+++ b/web_src/css/base.css
@@ -938,6 +938,23 @@ overflow-menu .overflow-menu-items .item {
   margin-bottom: 0 !important; /* reset fomantic's margin, because the active menu has special bottom border */
 }
 
+overflow-menu .overflow-menu-items .item-flex-space {
+  flex: 1;
+}
+
+overflow-menu .overflow-menu-button {
+  background: transparent;
+  border: none;
+  color: inherit;
+  text-align: center;
+  width: 32px;
+  padding: 0;
+}
+
+overflow-menu .overflow-menu-button:hover {
+  color: var(--color-text-dark);
+}
+
 overflow-menu .ui.label {
   margin-left: 7px !important; /* save some space */
 }
diff --git a/web_src/css/modules/container.css b/web_src/css/modules/container.css
index f394d6c06d..9f67ceb8d5 100644
--- a/web_src/css/modules/container.css
+++ b/web_src/css/modules/container.css
@@ -6,38 +6,6 @@
   max-width: 100%;
 }
 
-@media (max-width: 767.98px) {
-  .ui.ui.ui.container:not(.fluid) {
-    width: auto;
-    margin-left: 1em;
-    margin-right: 1em;
-  }
-}
-
-@media (min-width: 768px) and (max-width: 991.98px) {
-  .ui.ui.ui.container:not(.fluid) {
-    width: 723px;
-    margin-left: auto;
-    margin-right: auto;
-  }
-}
-
-@media (min-width: 992px) and (max-width: 1199.98px) {
-  .ui.ui.ui.container:not(.fluid) {
-    width: 933px;
-    margin-left: auto;
-    margin-right: auto;
-  }
-}
-
-@media (min-width: 1200px) {
-  .ui.ui.ui.container:not(.fluid) {
-    width: 1127px;
-    margin-left: auto;
-    margin-right: auto;
-  }
-}
-
 .ui.fluid.container {
   width: 100%;
 }
diff --git a/web_src/js/webcomponents/overflow-menu.js b/web_src/js/webcomponents/overflow-menu.js
index 604fce7d4b..0778c5990f 100644
--- a/web_src/js/webcomponents/overflow-menu.js
+++ b/web_src/js/webcomponents/overflow-menu.js
@@ -8,7 +8,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
     if (!this.tippyContent) {
       const div = document.createElement('div');
       div.classList.add('tippy-target');
-      div.tabIndex = '-1'; // for initial focus, programmatic focus only
+      div.tabIndex = -1; // for initial focus, programmatic focus only
       div.addEventListener('keydown', (e) => {
         if (e.key === 'Tab') {
           const items = this.tippyContent.querySelectorAll('[role="menuitem"]');
@@ -60,21 +60,35 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
       this.tippyContent = div;
     }
 
+    const itemFlexSpace = this.menuItemsEl.querySelector('.item-flex-space');
+
     // move items in tippy back into the menu items for subsequent measurement
     for (const item of this.tippyItems || []) {
-      this.menuItemsEl.append(item);
+      if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
+        this.menuItemsEl.append(item);
+      } else {
+        itemFlexSpace.insertAdjacentElement('beforebegin', item);
+      }
     }
 
     // measure which items are partially outside the element and move them into the button menu
+    itemFlexSpace?.style.setProperty('display', 'none', 'important');
     this.tippyItems = [];
     const menuRight = this.offsetLeft + this.offsetWidth;
-    const menuItems = this.menuItemsEl.querySelectorAll('.item');
+    const menuItems = this.menuItemsEl.querySelectorAll('.item, .item-flex-space');
+    let afterFlexSpace = false;
     for (const item of menuItems) {
+      if (item.classList.contains('item-flex-space')) {
+        afterFlexSpace = true;
+        continue;
+      }
+      if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true');
       const itemRight = item.offsetLeft + item.offsetWidth;
-      if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button
+      if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space
         this.tippyItems.push(item);
       }
     }
+    itemFlexSpace?.style.removeProperty('display');
 
     // if there are no overflown items, remove any previously created button
     if (!this.tippyItems?.length) {
@@ -105,7 +119,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
 
     // create button initially
     const btn = document.createElement('button');
-    btn.classList.add('overflow-menu-button', 'btn', 'tw-px-2', 'hover:tw-text-text-dark');
+    btn.classList.add('overflow-menu-button');
     btn.setAttribute('aria-label', window.config.i18n.more_items);
     btn.innerHTML = octiconKebabHorizontal;
     this.append(btn);

From f2d8ccc5bb2df25557cc0d4d23f2cdd029358274 Mon Sep 17 00:00:00 2001
From: 6543 <6543@obermui.de>
Date: Tue, 30 Apr 2024 10:43:08 +0200
Subject: [PATCH 04/47] Get repo assignees and reviewers should ignore
 deactivated users (#30770)

If an user is deactivated, it should not be in the list of users who are
suggested to be assigned or review-requested.

old assignees or reviewers are not affected.

---
*Sponsored by Kithara Software GmbH*
---
 models/repo/user_repo.go           |  8 ++++++--
 models/repo/user_repo_test.go      | 22 +++++++++++++++++-----
 tests/integration/api_repo_test.go |  4 +++-
 3 files changed, 26 insertions(+), 8 deletions(-)

diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go
index 1c5412fe7d..c305603e02 100644
--- a/models/repo/user_repo.go
+++ b/models/repo/user_repo.go
@@ -130,7 +130,10 @@ func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.Us
 	// and just waste 1 unit is cheaper than re-allocate memory once.
 	users := make([]*user_model.User, 0, len(uniqueUserIDs)+1)
 	if len(userIDs) > 0 {
-		if err = e.In("id", uniqueUserIDs.Values()).OrderBy(user_model.GetOrderByName()).Find(&users); err != nil {
+		if err = e.In("id", uniqueUserIDs.Values()).
+			Where(builder.Eq{"`user`.is_active": true}).
+			OrderBy(user_model.GetOrderByName()).
+			Find(&users); err != nil {
 			return nil, err
 		}
 	}
@@ -152,7 +155,8 @@ func GetReviewers(ctx context.Context, repo *Repository, doerID, posterID int64)
 		return nil, err
 	}
 
-	cond := builder.And(builder.Neq{"`user`.id": posterID})
+	cond := builder.And(builder.Neq{"`user`.id": posterID}).
+		And(builder.Eq{"`user`.is_active": true})
 
 	if repo.IsPrivate || repo.Owner.Visibility == api.VisibleTypePrivate {
 		// This a private repository:
diff --git a/models/repo/user_repo_test.go b/models/repo/user_repo_test.go
index 591dcea5b5..d2bf6dc912 100644
--- a/models/repo/user_repo_test.go
+++ b/models/repo/user_repo_test.go
@@ -9,6 +9,7 @@ import (
 	"code.gitea.io/gitea/models/db"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
 
 	"github.com/stretchr/testify/assert"
 )
@@ -25,8 +26,17 @@ func TestRepoAssignees(t *testing.T) {
 	repo21 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 21})
 	users, err = repo_model.GetRepoAssignees(db.DefaultContext, repo21)
 	assert.NoError(t, err)
-	assert.Len(t, users, 4)
-	assert.ElementsMatch(t, []int64{10, 15, 16, 18}, []int64{users[0].ID, users[1].ID, users[2].ID, users[3].ID})
+	if assert.Len(t, users, 4) {
+		assert.ElementsMatch(t, []int64{10, 15, 16, 18}, []int64{users[0].ID, users[1].ID, users[2].ID, users[3].ID})
+	}
+
+	// do not return deactivated users
+	assert.NoError(t, user_model.UpdateUserCols(db.DefaultContext, &user_model.User{ID: 15, IsActive: false}, "is_active"))
+	users, err = repo_model.GetRepoAssignees(db.DefaultContext, repo21)
+	assert.NoError(t, err)
+	if assert.Len(t, users, 3) {
+		assert.NotContains(t, []int64{users[0].ID, users[1].ID, users[2].ID}, 15)
+	}
 }
 
 func TestRepoGetReviewers(t *testing.T) {
@@ -38,17 +48,19 @@ func TestRepoGetReviewers(t *testing.T) {
 	ctx := db.DefaultContext
 	reviewers, err := repo_model.GetReviewers(ctx, repo1, 2, 2)
 	assert.NoError(t, err)
-	assert.Len(t, reviewers, 4)
+	if assert.Len(t, reviewers, 3) {
+		assert.ElementsMatch(t, []int64{1, 4, 11}, []int64{reviewers[0].ID, reviewers[1].ID, reviewers[2].ID})
+	}
 
 	// should include doer if doer is not PR poster.
 	reviewers, err = repo_model.GetReviewers(ctx, repo1, 11, 2)
 	assert.NoError(t, err)
-	assert.Len(t, reviewers, 4)
+	assert.Len(t, reviewers, 3)
 
 	// should not include PR poster, if PR poster would be otherwise eligible
 	reviewers, err = repo_model.GetReviewers(ctx, repo1, 11, 4)
 	assert.NoError(t, err)
-	assert.Len(t, reviewers, 3)
+	assert.Len(t, reviewers, 2)
 
 	// test private user repo
 	repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
diff --git a/tests/integration/api_repo_test.go b/tests/integration/api_repo_test.go
index bc2720d51e..f33827e58b 100644
--- a/tests/integration/api_repo_test.go
+++ b/tests/integration/api_repo_test.go
@@ -684,7 +684,9 @@ func TestAPIRepoGetReviewers(t *testing.T) {
 	resp := MakeRequest(t, req, http.StatusOK)
 	var reviewers []*api.User
 	DecodeJSON(t, resp, &reviewers)
-	assert.Len(t, reviewers, 4)
+	if assert.Len(t, reviewers, 3) {
+		assert.ElementsMatch(t, []int64{1, 4, 11}, []int64{reviewers[0].ID, reviewers[1].ID, reviewers[2].ID})
+	}
 }
 
 func TestAPIRepoGetAssignees(t *testing.T) {

From 610802df85933e7a190a705bc3f7800da87ce868 Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Tue, 30 Apr 2024 14:34:40 +0200
Subject: [PATCH 05/47] Fix tautological conditions (#30735)

As discovered by https://github.com/go-gitea/gitea/pull/30729.

---------

Co-authored-by: Giteabot <teabot@gitea.io>
---
 modules/indexer/code/indexer.go          |  6 ------
 routers/private/hook_post_receive.go     | 18 ++++++++----------
 services/auth/source/oauth2/providers.go |  2 +-
 services/convert/issue.go                | 14 ++++++++------
 tests/integration/git_test.go            |  9 +++------
 5 files changed, 20 insertions(+), 29 deletions(-)

diff --git a/modules/indexer/code/indexer.go b/modules/indexer/code/indexer.go
index ebebf6ba8a..c1ab26569c 100644
--- a/modules/indexer/code/indexer.go
+++ b/modules/indexer/code/indexer.go
@@ -178,12 +178,6 @@ func Init() {
 			}()
 
 			rIndexer = elasticsearch.NewIndexer(setting.Indexer.RepoConnStr, setting.Indexer.RepoIndexerName)
-			if err != nil {
-				cancel()
-				(*globalIndexer.Load()).Close()
-				close(waitChannel)
-				log.Fatal("PID: %d Unable to create the elasticsearch Repository Indexer connstr: %s Error: %v", os.Getpid(), setting.Indexer.RepoConnStr, err)
-			}
 			existed, err = rIndexer.Init(ctx)
 			if err != nil {
 				cancel()
diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go
index 769a68970d..adc435b42c 100644
--- a/routers/private/hook_post_receive.go
+++ b/routers/private/hook_post_receive.go
@@ -117,16 +117,14 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
 			}
 		}
 		if len(branchesToSync) > 0 {
-			if gitRepo == nil {
-				var err error
-				gitRepo, err = gitrepo.OpenRepository(ctx, repo)
-				if err != nil {
-					log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err)
-					ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
-						Err: fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err),
-					})
-					return
-				}
+			var err error
+			gitRepo, err = gitrepo.OpenRepository(ctx, repo)
+			if err != nil {
+				log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err)
+				ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
+					Err: fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err),
+				})
+				return
 			}
 
 			var (
diff --git a/services/auth/source/oauth2/providers.go b/services/auth/source/oauth2/providers.go
index 6ed6c184eb..f2c1bb4894 100644
--- a/services/auth/source/oauth2/providers.go
+++ b/services/auth/source/oauth2/providers.go
@@ -182,7 +182,7 @@ func createProvider(providerName string, source *Source) (goth.Provider, error)
 	}
 
 	// always set the name if provider is created so we can support multiple setups of 1 provider
-	if err == nil && provider != nil {
+	if provider != nil {
 		provider.SetName(providerName)
 	}
 
diff --git a/services/convert/issue.go b/services/convert/issue.go
index 54b00cd88e..668affe09a 100644
--- a/services/convert/issue.go
+++ b/services/convert/issue.go
@@ -211,13 +211,11 @@ func ToLabel(label *issues_model.Label, repo *repo_model.Repository, org *user_m
 		IsArchived:  label.IsArchived(),
 	}
 
+	labelBelongsToRepo := label.BelongsToRepo()
+
 	// calculate URL
-	if label.BelongsToRepo() && repo != nil {
-		if repo != nil {
-			result.URL = fmt.Sprintf("%s/labels/%d", repo.APIURL(), label.ID)
-		} else {
-			log.Error("ToLabel did not get repo to calculate url for label with id '%d'", label.ID)
-		}
+	if labelBelongsToRepo && repo != nil {
+		result.URL = fmt.Sprintf("%s/labels/%d", repo.APIURL(), label.ID)
 	} else { // BelongsToOrg
 		if org != nil {
 			result.URL = fmt.Sprintf("%sapi/v1/orgs/%s/labels/%d", setting.AppURL, url.PathEscape(org.Name), label.ID)
@@ -226,6 +224,10 @@ func ToLabel(label *issues_model.Label, repo *repo_model.Repository, org *user_m
 		}
 	}
 
+	if labelBelongsToRepo && repo == nil {
+		log.Error("ToLabel did not get repo to calculate url for label with id '%d'", label.ID)
+	}
+
 	return result
 }
 
diff --git a/tests/integration/git_test.go b/tests/integration/git_test.go
index 74c511fd7e..8a091ecab7 100644
--- a/tests/integration/git_test.go
+++ b/tests/integration/git_test.go
@@ -81,7 +81,7 @@ func testGit(t *testing.T, u *url.URL) {
 		rawTest(t, &httpContext, little, big, littleLFS, bigLFS)
 		mediaTest(t, &httpContext, little, big, littleLFS, bigLFS)
 
-		t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &httpContext, "master", "test/head"))
+		t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &httpContext, "test/head"))
 		t.Run("BranchProtectMerge", doBranchProtectPRMerge(&httpContext, dstPath))
 		t.Run("AutoMerge", doAutoPRMerge(&httpContext, dstPath))
 		t.Run("CreatePRAndSetManuallyMerged", doCreatePRAndSetManuallyMerged(httpContext, httpContext, dstPath, "master", "test-manually-merge"))
@@ -122,7 +122,7 @@ func testGit(t *testing.T, u *url.URL) {
 			rawTest(t, &sshContext, little, big, littleLFS, bigLFS)
 			mediaTest(t, &sshContext, little, big, littleLFS, bigLFS)
 
-			t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &sshContext, "master", "test/head2"))
+			t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &sshContext, "test/head2"))
 			t.Run("BranchProtectMerge", doBranchProtectPRMerge(&sshContext, dstPath))
 			t.Run("MergeFork", func(t *testing.T) {
 				defer tests.PrintCurrentTest(t)()
@@ -329,9 +329,6 @@ func generateCommitWithNewData(size int, repoPath, email, fullName, prefix strin
 		}
 		written += n
 	}
-	if err != nil {
-		return "", err
-	}
 
 	// Commit
 	// Now here we should explicitly allow lfs filters to run
@@ -693,7 +690,7 @@ func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
 	}
 }
 
-func doCreateAgitFlowPull(dstPath string, ctx *APITestContext, baseBranch, headBranch string) func(t *testing.T) {
+func doCreateAgitFlowPull(dstPath string, ctx *APITestContext, headBranch string) func(t *testing.T) {
 	return func(t *testing.T) {
 		defer tests.PrintCurrentTest(t)()
 

From 5f05e7b41a57972cc418a125d9263173b7b9838f Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Tue, 30 Apr 2024 20:39:36 +0800
Subject: [PATCH 06/47] Fix dashboard commit status null access (#30771)

Fix #30768
---
 web_src/js/components/DashboardRepoList.vue | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue
index 2d980a1b18..8bce40ee79 100644
--- a/web_src/js/components/DashboardRepoList.vue
+++ b/web_src/js/components/DashboardRepoList.vue
@@ -251,9 +251,9 @@ const sfc = {
         this.repos = json.data.map((webSearchRepo) => {
           return {
             ...webSearchRepo.repository,
-            latest_commit_status_state: webSearchRepo.latest_commit_status.State,
+            latest_commit_status_state: webSearchRepo.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status
+            latest_commit_status_state_link: webSearchRepo.latest_commit_status?.TargetURL,
             locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status,
-            latest_commit_status_state_link: webSearchRepo.latest_commit_status.TargetURL,
           };
         });
         const count = response.headers.get('X-Total-Count');

From 564102ce89f53d6bd2fdbaa33416e4287d6fe9a8 Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Tue, 30 Apr 2024 16:52:46 +0200
Subject: [PATCH 07/47] Rework and fix stopwatch (#30732)

Fixes https://github.com/go-gitea/gitea/issues/30721 and overhauls the
stopwatch. Time is now shown inside the "dot" icon and on both mobile
and desktop. All rendering is now done by `<relative-time>`, the
`pretty-ms` dependency is dropped.

Desktop:
<img width="557" alt="Screenshot 2024-04-29 at 22 33 27"
src="https://github.com/go-gitea/gitea/assets/115237/3a46cdbf-6af2-4bf9-b07f-021348badaac">

Mobile:
<img width="640" alt="Screenshot 2024-04-29 at 22 34 19"
src="https://github.com/go-gitea/gitea/assets/115237/8a2beea7-bd5d-473f-8fff-66f63fd50877">

Note for tippy:
Previously, tippy instances defaulted to "menu" theme, but that theme is
really only meant for `.ui.menu`, so it was not optimal for the
stopwatch popover.

This introduces a unopinionated `default` theme that has no padding and
should be suitable for all content. I reviewed all existing uses and
explicitely set the desired `theme` on all of them.
---
 package-lock.json                         | 26 -------
 package.json                              |  1 -
 templates/base/head_navbar.tmpl           | 69 ++++++++++---------
 web_src/css/modules/navbar.css            | 16 ++---
 web_src/css/modules/tippy.css             |  7 +-
 web_src/js/features/contextpopup.js       |  2 +
 web_src/js/features/repo-code.js          |  1 +
 web_src/js/features/repo-issue.js         |  1 +
 web_src/js/features/stopwatch.js          | 82 +++++++++++------------
 web_src/js/modules/tippy.js               |  6 +-
 web_src/js/webcomponents/overflow-menu.js |  1 +
 11 files changed, 99 insertions(+), 113 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 8e4eeb7fb8..917ff1029b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -42,7 +42,6 @@
         "postcss": "8.4.38",
         "postcss-loader": "8.1.1",
         "postcss-nesting": "12.1.2",
-        "pretty-ms": "9.0.0",
         "sortablejs": "1.15.2",
         "swagger-ui-dist": "5.17.2",
         "tailwindcss": "3.4.3",
@@ -9170,17 +9169,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/parse-ms": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
-      "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -9772,20 +9760,6 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
-    "node_modules/pretty-ms": {
-      "version": "9.0.0",
-      "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.0.0.tgz",
-      "integrity": "sha512-E9e9HJ9R9NasGOgPaPE8VMeiPKAyWR5jcFpNnwIejslIhWqdqOrb2wShBsncMPUb+BcCd2OPYfh7p2W6oemTng==",
-      "dependencies": {
-        "parse-ms": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/printable-characters": {
       "version": "1.0.42",
       "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",
diff --git a/package.json b/package.json
index 142b9bb3ee..5f9b810320 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,6 @@
     "postcss": "8.4.38",
     "postcss-loader": "8.1.1",
     "postcss-nesting": "12.1.2",
-    "pretty-ms": "9.0.0",
     "sortablejs": "1.15.2",
     "swagger-ui-dist": "5.17.2",
     "tailwindcss": "3.4.3",
diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl
index addff22c49..7a3e663c49 100644
--- a/templates/base/head_navbar.tmpl
+++ b/templates/base/head_navbar.tmpl
@@ -12,6 +12,14 @@
 
 		<!-- mobile right menu, it must be here because in mobile view, each item is a flex column, the first item is a full row column -->
 		<div class="ui secondary menu item navbar-mobile-right only-mobile">
+			{{if and .IsSigned EnableTimetracking .ActiveStopwatch}}
+			<a id="mobile-stopwatch-icon" class="active-stopwatch item tw-mx-0" href="{{.ActiveStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}" data-seconds="{{.ActiveStopwatch.Seconds}}">
+				<div class="tw-relative">
+					{{svg "octicon-stopwatch"}}
+					<span class="header-stopwatch-dot"></span>
+				</div>
+			</a>
+			{{end}}
 			{{if .IsSigned}}
 			<a id="mobile-notifications-icon" class="item tw-w-auto tw-p-2" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}" aria-label="{{ctx.Locale.Tr "notifications"}}">
 				<div class="tw-relative">
@@ -74,41 +82,13 @@
 				</div><!-- end content avatar menu -->
 			</div><!-- end dropdown avatar menu -->
 		{{else if .IsSigned}}
-			{{if EnableTimetracking}}
-			<a class="active-stopwatch-trigger item tw-mx-0{{if not .ActiveStopwatch}} tw-hidden{{end}}" href="{{.ActiveStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}">
+			{{if and EnableTimetracking .ActiveStopwatch}}
+			<a class="item not-mobile active-stopwatch tw-mx-0" href="{{.ActiveStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}" data-seconds="{{.ActiveStopwatch.Seconds}}">
 				<div class="tw-relative">
 					{{svg "octicon-stopwatch"}}
 					<span class="header-stopwatch-dot"></span>
 				</div>
-				<span class="only-mobile tw-ml-2">{{ctx.Locale.Tr "active_stopwatch"}}</span>
 			</a>
-			<div class="active-stopwatch-popup item tippy-target tw-p-2">
-				<div class="tw-flex tw-items-center">
-					<a class="stopwatch-link tw-flex tw-items-center" href="{{.ActiveStopwatch.IssueLink}}">
-						{{svg "octicon-issue-opened" 16 "tw-mr-2"}}
-						<span class="stopwatch-issue">{{.ActiveStopwatch.RepoSlug}}#{{.ActiveStopwatch.IssueIndex}}</span>
-						<span class="ui primary label stopwatch-time tw-my-0 tw-mx-4" data-seconds="{{.ActiveStopwatch.Seconds}}">
-							{{if .ActiveStopwatch}}{{Sec2Time .ActiveStopwatch.Seconds}}{{end}}
-						</span>
-					</a>
-					<form class="stopwatch-commit" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/toggle">
-						{{.CsrfTokenHtml}}
-						<button
-							type="submit"
-							class="ui button mini compact basic icon"
-							data-tooltip-content="{{ctx.Locale.Tr "repo.issues.stop_tracking"}}"
-						>{{svg "octicon-square-fill"}}</button>
-					</form>
-					<form class="stopwatch-cancel" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/cancel">
-						{{.CsrfTokenHtml}}
-						<button
-							type="submit"
-							class="ui button mini compact basic icon"
-							data-tooltip-content="{{ctx.Locale.Tr "repo.issues.cancel_tracking"}}"
-						>{{svg "octicon-trash"}}</button>
-					</form>
-				</div>
-			</div>
 			{{end}}
 
 			<a class="item not-mobile tw-mx-0" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}" aria-label="{{ctx.Locale.Tr "notifications"}}">
@@ -202,4 +182,33 @@
 			</a>
 		{{end}}
 	</div><!-- end full right menu -->
+
+	{{if and .IsSigned EnableTimetracking .ActiveStopwatch}}
+		<div class="active-stopwatch-popup tippy-target">
+			<div class="tw-flex tw-items-center tw-gap-2 tw-p-3">
+				<a class="stopwatch-link tw-flex tw-items-center tw-gap-2 muted" href="{{.ActiveStopwatch.IssueLink}}">
+					{{svg "octicon-issue-opened" 16}}
+					<span class="stopwatch-issue">{{.ActiveStopwatch.RepoSlug}}#{{.ActiveStopwatch.IssueIndex}}</span>
+				</a>
+				<div class="tw-flex tw-gap-1">
+					<form class="stopwatch-commit" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/toggle">
+						{{.CsrfTokenHtml}}
+						<button
+							type="submit"
+							class="ui button mini compact basic icon tw-mr-0"
+							data-tooltip-content="{{ctx.Locale.Tr "repo.issues.stop_tracking"}}"
+						>{{svg "octicon-square-fill"}}</button>
+					</form>
+					<form class="stopwatch-cancel" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/cancel">
+						{{.CsrfTokenHtml}}
+						<button
+							type="submit"
+							class="ui button mini compact basic icon tw-mr-0"
+							data-tooltip-content="{{ctx.Locale.Tr "repo.issues.cancel_tracking"}}"
+						>{{svg "octicon-trash"}}</button>
+					</form>
+				</div>
+			</div>
+		</div>
+	{{end}}
 </nav>
diff --git a/web_src/css/modules/navbar.css b/web_src/css/modules/navbar.css
index d7aa197e02..848f9331d0 100644
--- a/web_src/css/modules/navbar.css
+++ b/web_src/css/modules/navbar.css
@@ -103,19 +103,12 @@
     width: 50%;
     min-height: 48px;
   }
+  #navbar #mobile-stopwatch-icon,
   #navbar #mobile-notifications-icon {
     margin-right: 6px !important;
   }
 }
 
-#navbar a.item .notification_count {
-  color: var(--color-nav-bg);
-  padding: 0 3.75px;
-  font-size: 12px;
-  line-height: 12px;
-  font-weight: var(--font-weight-bold);
-}
-
 #navbar a.item:hover .notification_count,
 #navbar a.item:hover .header-stopwatch-dot {
   border-color: var(--color-nav-hover-bg);
@@ -123,6 +116,11 @@
 
 #navbar a.item .notification_count,
 #navbar a.item .header-stopwatch-dot {
+  color: var(--color-nav-bg);
+  padding: 0 3.75px;
+  font-size: 12px;
+  line-height: 12px;
+  font-weight: var(--font-weight-bold);
   background: var(--color-primary);
   border: 2px solid var(--color-nav-bg);
   position: absolute;
@@ -135,6 +133,8 @@
   align-items: center;
   justify-content: center;
   z-index: 1; /* prevent menu button background from overlaying icon */
+  user-select: none;
+  white-space: nowrap;
 }
 
 .secondary-nav {
diff --git a/web_src/css/modules/tippy.css b/web_src/css/modules/tippy.css
index 6ac7c37d93..53c3d5aaea 100644
--- a/web_src/css/modules/tippy.css
+++ b/web_src/css/modules/tippy.css
@@ -16,8 +16,8 @@
 
 .tippy-box {
   position: relative;
-  background-color: var(--color-body);
-  color: var(--color-secondary-dark-6);
+  background-color: var(--color-menu);
+  color: var(--color-text);
   border: 1px solid var(--color-secondary);
   border-radius: var(--border-radius);
   font-size: 1rem;
@@ -25,7 +25,6 @@
 
 .tippy-content {
   position: relative;
-  padding: 1rem; /* if you need different padding, use different data-theme */
   z-index: 1;
 }
 
@@ -166,5 +165,5 @@
 }
 
 .tippy-svg-arrow-inner {
-  fill: var(--color-body);
+  fill: var(--color-menu);
 }
diff --git a/web_src/js/features/contextpopup.js b/web_src/js/features/contextpopup.js
index ce90f3e505..6a9325ed1c 100644
--- a/web_src/js/features/contextpopup.js
+++ b/web_src/js/features/contextpopup.js
@@ -18,6 +18,7 @@ export function attachRefIssueContextPopup(refIssues) {
     if (!owner) return;
 
     const el = document.createElement('div');
+    el.classList.add('tw-p-3');
     refIssue.parentNode.insertBefore(el, refIssue.nextSibling);
 
     const view = createApp(ContextPopup);
@@ -30,6 +31,7 @@ export function attachRefIssueContextPopup(refIssues) {
     }
 
     createTippy(refIssue, {
+      theme: 'default',
       content: el,
       placement: 'top-start',
       interactive: true,
diff --git a/web_src/js/features/repo-code.js b/web_src/js/features/repo-code.js
index 63da5f2039..7c74c253a2 100644
--- a/web_src/js/features/repo-code.js
+++ b/web_src/js/features/repo-code.js
@@ -113,6 +113,7 @@ function showLineButton() {
   btn.closest('.code-view').append(menu.cloneNode(true));
 
   createTippy(btn, {
+    theme: 'menu',
     trigger: 'click',
     hideOnClick: true,
     content: menu,
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index 2b2eed58bb..c4e14c62c4 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -502,6 +502,7 @@ export function initRepoPullRequestReview() {
   if ($reviewBtn.length && $panel.length) {
     const tippy = createTippy($reviewBtn[0], {
       content: $panel[0],
+      theme: 'default',
       placement: 'bottom',
       trigger: 'click',
       maxWidth: 'none',
diff --git a/web_src/js/features/stopwatch.js b/web_src/js/features/stopwatch.js
index 2ec74344fc..bcea26bd6e 100644
--- a/web_src/js/features/stopwatch.js
+++ b/web_src/js/features/stopwatch.js
@@ -1,4 +1,3 @@
-import prettyMilliseconds from 'pretty-ms';
 import {createTippy} from '../modules/tippy.js';
 import {GET} from '../modules/fetch.js';
 import {hideElem, showElem} from '../utils/dom.js';
@@ -10,28 +9,31 @@ export function initStopwatch() {
     return;
   }
 
-  const stopwatchEl = document.querySelector('.active-stopwatch-trigger');
+  const stopwatchEls = document.querySelectorAll('.active-stopwatch');
   const stopwatchPopup = document.querySelector('.active-stopwatch-popup');
 
-  if (!stopwatchEl || !stopwatchPopup) {
+  if (!stopwatchEls.length || !stopwatchPopup) {
     return;
   }
 
-  stopwatchEl.removeAttribute('href'); // intended for noscript mode only
-
-  createTippy(stopwatchEl, {
-    content: stopwatchPopup,
-    placement: 'bottom-end',
-    trigger: 'click',
-    maxWidth: 'none',
-    interactive: true,
-    hideOnClick: true,
-  });
-
   // global stop watch (in the head_navbar), it should always work in any case either the EventSource or the PeriodicPoller is used.
-  const currSeconds = document.querySelector('.stopwatch-time')?.getAttribute('data-seconds');
-  if (currSeconds) {
-    updateStopwatchTime(currSeconds);
+  const seconds = stopwatchEls[0]?.getAttribute('data-seconds');
+  if (seconds) {
+    updateStopwatchTime(parseInt(seconds));
+  }
+
+  for (const stopwatchEl of stopwatchEls) {
+    stopwatchEl.removeAttribute('href'); // intended for noscript mode only
+
+    createTippy(stopwatchEl, {
+      content: stopwatchPopup.cloneNode(true),
+      placement: 'bottom-end',
+      trigger: 'click',
+      maxWidth: 'none',
+      interactive: true,
+      hideOnClick: true,
+      theme: 'default',
+    });
   }
 
   let usingPeriodicPoller = false;
@@ -124,10 +126,9 @@ async function updateStopwatch() {
 
 function updateStopwatchData(data) {
   const watch = data[0];
-  const btnEl = document.querySelector('.active-stopwatch-trigger');
+  const btnEls = document.querySelectorAll('.active-stopwatch');
   if (!watch) {
-    clearStopwatchTimer();
-    hideElem(btnEl);
+    hideElem(btnEls);
   } else {
     const {repo_owner_name, repo_name, issue_index, seconds} = watch;
     const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
@@ -137,31 +138,28 @@ function updateStopwatchData(data) {
     const stopwatchIssue = document.querySelector('.stopwatch-issue');
     if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`;
     updateStopwatchTime(seconds);
-    showElem(btnEl);
+    showElem(btnEls);
   }
   return Boolean(data.length);
 }
 
-let updateTimeIntervalId = null; // holds setInterval id when active
-function clearStopwatchTimer() {
-  if (updateTimeIntervalId !== null) {
-    clearInterval(updateTimeIntervalId);
-    updateTimeIntervalId = null;
+// TODO: This flickers on page load, we could avoid this by making a custom
+// element to render time periods. Feeding a datetime in backend does not work
+// when time zone between server and client differs.
+function updateStopwatchTime(seconds) {
+  if (!Number.isFinite(seconds)) return;
+  const datetime = (new Date(Date.now() - seconds * 1000)).toISOString();
+  for (const parent of document.querySelectorAll('.header-stopwatch-dot')) {
+    const existing = parent.querySelector(':scope > relative-time');
+    if (existing) {
+      existing.setAttribute('datetime', datetime);
+    } else {
+      const el = document.createElement('relative-time');
+      el.setAttribute('format', 'micro');
+      el.setAttribute('datetime', datetime);
+      el.setAttribute('lang', 'en-US');
+      el.setAttribute('title', ''); // make <relative-time> show no title and therefor no tooltip
+      parent.append(el);
+    }
   }
 }
-function updateStopwatchTime(seconds) {
-  const secs = parseInt(seconds);
-  if (!Number.isFinite(secs)) return;
-
-  clearStopwatchTimer();
-  const stopwatch = document.querySelector('.stopwatch-time');
-  // TODO: replace with <relative-time> similar to how system status up time is shown
-  const start = Date.now();
-  const updateUi = () => {
-    const delta = Date.now() - start;
-    const dur = prettyMilliseconds(secs * 1000 + delta, {compact: true});
-    if (stopwatch) stopwatch.textContent = dur;
-  };
-  updateUi();
-  updateTimeIntervalId = setInterval(updateUi, 1000);
-}
diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js
index 83b28e5745..a18c94cafb 100644
--- a/web_src/js/modules/tippy.js
+++ b/web_src/js/modules/tippy.js
@@ -37,8 +37,10 @@ export function createTippy(target, opts = {}) {
       return onShow?.(instance);
     },
     arrow: arrow || (theme === 'bare' ? false : arrowSvg),
-    role: role || 'menu', // HTML role attribute
-    theme: theme || role || 'menu', // CSS theme, either "tooltip", "menu", "box-with-header" or "bare"
+    // HTML role attribute, ideally the default role would be "popover" but it does not exist
+    role: role || 'menu',
+    // CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare"
+    theme: theme || role || 'default',
     plugins: [followCursor],
     ...other,
   });
diff --git a/web_src/js/webcomponents/overflow-menu.js b/web_src/js/webcomponents/overflow-menu.js
index 0778c5990f..80dd1a545b 100644
--- a/web_src/js/webcomponents/overflow-menu.js
+++ b/web_src/js/webcomponents/overflow-menu.js
@@ -131,6 +131,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
       interactive: true,
       placement: 'bottom-end',
       role: 'menu',
+      theme: 'menu',
       content: this.tippyContent,
       onShow: () => { // FIXME: onShown doesn't work (never be called)
         setTimeout(() => {

From a988237eb43fe0b68465c4c965f869b51d0c60ea Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Tue, 30 Apr 2024 23:35:42 +0800
Subject: [PATCH 08/47] Improve logout from worker (#30775)

A quick fix for #30756
---
 web_src/js/features/notification.js | 3 ++-
 web_src/js/features/stopwatch.js    | 3 ++-
 web_src/js/modules/worker.js        | 9 +++++++++
 3 files changed, 13 insertions(+), 2 deletions(-)
 create mode 100644 web_src/js/modules/worker.js

diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.js
index 2de640e674..8e5a1f83db 100644
--- a/web_src/js/features/notification.js
+++ b/web_src/js/features/notification.js
@@ -1,6 +1,7 @@
 import $ from 'jquery';
 import {GET} from '../modules/fetch.js';
 import {toggleElem} from '../utils/dom.js';
+import {logoutFromWorker} from '../modules/worker.js';
 
 const {appSubUrl, notificationSettings, assetVersionEncoded} = window.config;
 let notificationSequenceNumber = 0;
@@ -95,7 +96,7 @@ export function initNotificationCount() {
           type: 'close',
         });
         worker.port.close();
-        window.location.href = `${appSubUrl}/`;
+        logoutFromWorker();
       } else if (event.data.type === 'close') {
         worker.port.postMessage({
           type: 'close',
diff --git a/web_src/js/features/stopwatch.js b/web_src/js/features/stopwatch.js
index bcea26bd6e..79d9892b74 100644
--- a/web_src/js/features/stopwatch.js
+++ b/web_src/js/features/stopwatch.js
@@ -1,6 +1,7 @@
 import {createTippy} from '../modules/tippy.js';
 import {GET} from '../modules/fetch.js';
 import {hideElem, showElem} from '../utils/dom.js';
+import {logoutFromWorker} from '../modules/worker.js';
 
 const {appSubUrl, notificationSettings, enableTimeTracking, assetVersionEncoded} = window.config;
 
@@ -77,7 +78,7 @@ export function initStopwatch() {
           type: 'close',
         });
         worker.port.close();
-        window.location.href = `${appSubUrl}/`;
+        logoutFromWorker();
       } else if (event.data.type === 'close') {
         worker.port.postMessage({
           type: 'close',
diff --git a/web_src/js/modules/worker.js b/web_src/js/modules/worker.js
new file mode 100644
index 0000000000..ef3f1dea48
--- /dev/null
+++ b/web_src/js/modules/worker.js
@@ -0,0 +1,9 @@
+import {sleep} from '../utils.js';
+
+const {appSubUrl} = window.config;
+
+export async function logoutFromWorker() {
+  // wait for a while because other requests (eg: logout) may be in the flight
+  await sleep(5000);
+  window.location.href = `${appSubUrl}/`;
+}

From d8d46d1c483390da746d7a60edd2ace18a66c933 Mon Sep 17 00:00:00 2001
From: GiteaBot <teabot@gitea.io>
Date: Wed, 1 May 2024 00:26:38 +0000
Subject: [PATCH 09/47] [skip ci] Updated translations via Crowdin

---
 options/locale/locale_pt-PT.ini | 1 +
 1 file changed, 1 insertion(+)

diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini
index c711c72045..7d799a20ba 100644
--- a/options/locale/locale_pt-PT.ini
+++ b/options/locale/locale_pt-PT.ini
@@ -3495,6 +3495,7 @@ npm.install=Para instalar o pacote usando o npm, execute o seguinte comando:
 npm.install2=ou adicione-o ao ficheiro <code>package.json</code>:
 npm.dependencies=Dependências
 npm.dependencies.development=Dependências de desenvolvimento
+npm.dependencies.bundle=Dependências agregadas
 npm.dependencies.peer=Dependências de pares
 npm.dependencies.optional=Dependências opcionais
 npm.details.tag=Etiqueta

From 6709e28da78a0ea7e63f9fe4e32f620abdc88d14 Mon Sep 17 00:00:00 2001
From: Chester <chesterip0510@gmail.com>
Date: Wed, 1 May 2024 09:40:23 +0800
Subject: [PATCH 10/47] Add API endpoints for getting action jobs status
 (#26673)

Sample of response, it is similar to Github actions

ref
https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-repository

``` json
{
    "workflow_runs": [
        {
            "id": 3,
            "name": "Explore-Gitea-Actions",
            "head_branch": "main",
            "head_sha": "6d8d29a9f7a01ded8f8aeb64341cb31ee1ab5f19",
            "run_number": 3,
            "event": "push",
            "display_title": "More job",
            "status": "success",
            "workflow_id": "demo2.yaml",
            "url": "/chester/test/actions/runs/3",
            "created_at": "2023-08-22T13:41:33-04:00",
            "updated_at": "2023-08-22T13:41:37-04:00",
            "run_started_at": "2023-08-22T13:41:33-04:00"
        },
        {
            "id": 2,
            "name": "Explore-Gitea-Actions",
            "head_branch": "main",
            "head_sha": "6d8d29a9f7a01ded8f8aeb64341cb31ee1ab5f19",
            "run_number": 2,
            "event": "push",
            "display_title": "More job",
            "status": "success",
            "workflow_id": "demo.yaml",
            "url": "/chester/test/actions/runs/2",
            "created_at": "2023-08-22T13:41:30-04:00",
            "updated_at": "2023-08-22T13:41:33-04:00",
            "run_started_at": "2023-08-22T13:41:30-04:00"
        },
        {
            "id": 1,
            "name": "Explore-Gitea-Actions",
            "head_branch": "main",
            "head_sha": "e5369ab054cae79899ba36e45ee82811a6e0acd5",
            "run_number": 1,
            "event": "push",
            "display_title": "Add job",
            "status": "failure",
            "workflow_id": "demo.yaml",
            "url": "/chester/test/actions/runs/1",
            "created_at": "2023-08-22T13:15:21-04:00",
            "updated_at": "2023-08-22T13:18:10-04:00",
            "run_started_at": "2023-08-22T13:15:21-04:00"
        }
    ],
    "total_count": 3
}
```

---------

Co-authored-by: yp05327 <576951401@qq.com>
Co-authored-by: puni9869 <80308335+puni9869@users.noreply.github.com>
---
 modules/structs/repo_actions.go |  34 ++++++++
 routers/api/v1/api.go           |   3 +
 routers/api/v1/repo/actions.go  |  80 +++++++++++++++++
 routers/api/v1/swagger/repo.go  |   7 ++
 services/convert/convert.go     |  27 ++++++
 templates/swagger/v1_json.tmpl  | 149 ++++++++++++++++++++++++++++++++
 6 files changed, 300 insertions(+)
 create mode 100644 modules/structs/repo_actions.go
 create mode 100644 routers/api/v1/repo/actions.go

diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
new file mode 100644
index 0000000000..b13f344738
--- /dev/null
+++ b/modules/structs/repo_actions.go
@@ -0,0 +1,34 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import (
+	"time"
+)
+
+// ActionTask represents a ActionTask
+type ActionTask struct {
+	ID           int64  `json:"id"`
+	Name         string `json:"name"`
+	HeadBranch   string `json:"head_branch"`
+	HeadSHA      string `json:"head_sha"`
+	RunNumber    int64  `json:"run_number"`
+	Event        string `json:"event"`
+	DisplayTitle string `json:"display_title"`
+	Status       string `json:"status"`
+	WorkflowID   string `json:"workflow_id"`
+	URL          string `json:"url"`
+	// swagger:strfmt date-time
+	CreatedAt time.Time `json:"created_at"`
+	// swagger:strfmt date-time
+	UpdatedAt time.Time `json:"updated_at"`
+	// swagger:strfmt date-time
+	RunStartedAt time.Time `json:"run_started_at"`
+}
+
+// ActionTaskResponse returns a ActionTask
+type ActionTaskResponse struct {
+	Entries    []*ActionTask `json:"workflow_runs"`
+	TotalCount int64         `json:"total_count"`
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 73071aa8df..74062c44ac 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -1168,6 +1168,9 @@ func Routes() *web.Route {
 					m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateTagOption{}), repo.CreateTag)
 					m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteTag)
 				}, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true))
+				m.Group("/actions", func() {
+					m.Get("/tasks", repo.ListActionTasks)
+				}, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true))
 				m.Group("/keys", func() {
 					m.Combo("").Get(repo.ListDeployKeys).
 						Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey)
diff --git a/routers/api/v1/repo/actions.go b/routers/api/v1/repo/actions.go
new file mode 100644
index 0000000000..635cb4e138
--- /dev/null
+++ b/routers/api/v1/repo/actions.go
@@ -0,0 +1,80 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"net/http"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	"code.gitea.io/gitea/models/db"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/routers/api/v1/utils"
+	"code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/convert"
+)
+
+// ListActionTasks list all the actions of a repository
+func ListActionTasks(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/actions/tasks repository ListActionTasks
+	// ---
+	// summary: List a repository's action tasks
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results, default maximum page size is 50
+	//   type: integer
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/TasksList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "409":
+	//     "$ref": "#/responses/conflict"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	tasks, total, err := db.FindAndCount[actions_model.ActionTask](ctx, &actions_model.FindTaskOptions{
+		ListOptions: utils.GetListOptions(ctx),
+		RepoID:      ctx.Repo.Repository.ID,
+	})
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "ListActionTasks", err)
+		return
+	}
+
+	res := new(api.ActionTaskResponse)
+	res.TotalCount = total
+
+	res.Entries = make([]*api.ActionTask, len(tasks))
+	for i := range tasks {
+		convertedTask, err := convert.ToActionTask(ctx, tasks[i])
+		if err != nil {
+			ctx.Error(http.StatusInternalServerError, "ToActionTask", err)
+			return
+		}
+		res.Entries[i] = convertedTask
+	}
+
+	ctx.JSON(http.StatusOK, &res)
+}
diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go
index c3219f28d6..fcd34a63a9 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -415,6 +415,13 @@ type swaggerRepoNewIssuePinsAllowed struct {
 	Body api.NewIssuePinsAllowed `json:"body"`
 }
 
+// TasksList
+// swagger:response TasksList
+type swaggerRepoTasksList struct {
+	// in:body
+	Body api.ActionTaskResponse `json:"body"`
+}
+
 // swagger:response Compare
 type swaggerCompare struct {
 	// in:body
diff --git a/services/convert/convert.go b/services/convert/convert.go
index 3b6139d2fe..c44179632e 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -11,6 +11,7 @@ import (
 	"strings"
 	"time"
 
+	actions_model "code.gitea.io/gitea/models/actions"
 	asymkey_model "code.gitea.io/gitea/models/asymkey"
 	"code.gitea.io/gitea/models/auth"
 	git_model "code.gitea.io/gitea/models/git"
@@ -24,6 +25,7 @@ import (
 	"code.gitea.io/gitea/modules/container"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/services/gitdiff"
@@ -193,6 +195,31 @@ func ToTag(repo *repo_model.Repository, t *git.Tag) *api.Tag {
 	}
 }
 
+// ToActionTask convert a actions_model.ActionTask to an api.ActionTask
+func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.ActionTask, error) {
+	if err := t.LoadAttributes(ctx); err != nil {
+		return nil, err
+	}
+
+	url := strings.TrimSuffix(setting.AppURL, "/") + t.GetRunLink()
+
+	return &api.ActionTask{
+		ID:           t.ID,
+		Name:         t.Job.Name,
+		HeadBranch:   t.Job.Run.PrettyRef(),
+		HeadSHA:      t.Job.CommitSHA,
+		RunNumber:    t.Job.Run.Index,
+		Event:        t.Job.Run.TriggerEvent,
+		DisplayTitle: t.Job.Run.Title,
+		Status:       t.Status.String(),
+		WorkflowID:   t.Job.Run.WorkflowID,
+		URL:          url,
+		CreatedAt:    t.Created.AsLocalTime(),
+		UpdatedAt:    t.Updated.AsLocalTime(),
+		RunStartedAt: t.Started.AsLocalTime(),
+	}, nil
+}
+
 // ToVerification convert a git.Commit.Signature to an api.PayloadCommitVerification
 func ToVerification(ctx context.Context, c *git.Commit) *api.PayloadCommitVerification {
 	verif := asymkey_model.ParseCommitWithSignature(ctx, c)
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 362a847332..0c5e5c974d 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -3997,6 +3997,66 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/actions/tasks": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "List a repository's action tasks",
+        "operationId": "ListActionTasks",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results, default maximum page size is 50",
+            "name": "limit",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/TasksList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "409": {
+            "$ref": "#/responses/conflict"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/actions/variables": {
       "get": {
         "produces": [
@@ -17953,6 +18013,89 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "ActionTask": {
+      "description": "ActionTask represents a ActionTask",
+      "type": "object",
+      "properties": {
+        "created_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "CreatedAt"
+        },
+        "display_title": {
+          "type": "string",
+          "x-go-name": "DisplayTitle"
+        },
+        "event": {
+          "type": "string",
+          "x-go-name": "Event"
+        },
+        "head_branch": {
+          "type": "string",
+          "x-go-name": "HeadBranch"
+        },
+        "head_sha": {
+          "type": "string",
+          "x-go-name": "HeadSHA"
+        },
+        "id": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "ID"
+        },
+        "name": {
+          "type": "string",
+          "x-go-name": "Name"
+        },
+        "run_number": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "RunNumber"
+        },
+        "run_started_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "RunStartedAt"
+        },
+        "status": {
+          "type": "string",
+          "x-go-name": "Status"
+        },
+        "updated_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "UpdatedAt"
+        },
+        "url": {
+          "type": "string",
+          "x-go-name": "URL"
+        },
+        "workflow_id": {
+          "type": "string",
+          "x-go-name": "WorkflowID"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
+    "ActionTaskResponse": {
+      "description": "ActionTaskResponse returns a ActionTask",
+      "type": "object",
+      "properties": {
+        "total_count": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "TotalCount"
+        },
+        "workflow_runs": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/ActionTask"
+          },
+          "x-go-name": "Entries"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "ActionVariable": {
       "description": "ActionVariable return value of the query API",
       "type": "object",
@@ -25409,6 +25552,12 @@
         }
       }
     },
+    "TasksList": {
+      "description": "TasksList",
+      "schema": {
+        "$ref": "#/definitions/ActionTaskResponse"
+      }
+    },
     "Team": {
       "description": "Team",
       "schema": {

From f135cb7c9457f7b9bdc43601f44757834573950f Mon Sep 17 00:00:00 2001
From: Kemal Zebari <60799661+kemzeb@users.noreply.github.com>
Date: Tue, 30 Apr 2024 22:33:40 -0700
Subject: [PATCH 11/47] Don't have `redis-cluster` as possible cache/session
 adapter in docs (#30794)

This is because it doesn't exist as an adapter. The `redis` adapter
already handles Redis cluster configurations.

Fixes #30534.
---
 custom/conf/app.example.ini                          | 12 +++++-------
 .../administration/config-cheat-sheet.en-us.md       | 10 +++++-----
 2 files changed, 10 insertions(+), 12 deletions(-)

diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 62db26fb02..577479e39f 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -1456,7 +1456,7 @@ LEVEL = Info
 ;; Batch size to send for batched queues
 ;BATCH_LENGTH = 20
 ;;
-;; Connection string for redis queues this will store the redis or redis-cluster connection string.
+;; Connection string for redis queues this will store the redis (or Redis cluster) connection string.
 ;; When `TYPE` is `persistable-channel`, this provides a directory for the underlying leveldb
 ;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`.
 ;CONN_STR = "redis://127.0.0.1:6379/0"
@@ -1740,9 +1740,8 @@ LEVEL = Info
 ;; For "memory" only, GC interval in seconds, default is 60
 ;INTERVAL = 60
 ;;
-;; For "redis", "redis-cluster" and "memcache", connection host address
-;; redis: `redis://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s`
-;; redis-cluster: `redis+cluster://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s`
+;; For "redis" and "memcache", connection host address
+;; redis: `redis://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` (or `redis+cluster://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` for a Redis cluster)
 ;; memcache: `127.0.0.1:11211`
 ;; twoqueue: `{"size":50000,"recent_ratio":0.25,"ghost_ratio":0.5}` or `50000`
 ;HOST =
@@ -1772,15 +1771,14 @@ LEVEL = Info
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;
-;; Either "memory", "file", "redis", "redis-cluster", "db", "mysql", "couchbase", "memcache" or "postgres"
+;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres"
 ;; Default is "memory". "db" will reuse the configuration in [database]
 ;PROVIDER = memory
 ;;
 ;; Provider config options
 ;; memory: doesn't have any config yet
 ;; file: session file path, e.g. `data/sessions`
-;; redis: `redis://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s`
-;; redis-cluster: `redis+cluster://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s`
+;; redis: `redis://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` (or `redis+cluster://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` for a Redis cluster)
 ;; mysql: go-sql-driver/mysql dsn config string, e.g. `root:password@/session_table`
 ;PROVIDER_CONFIG = data/sessions ; Relative paths will be made absolute against _`AppWorkPath`_.
 ;;
diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md
index 5066e0f879..07712c1110 100644
--- a/docs/content/administration/config-cheat-sheet.en-us.md
+++ b/docs/content/administration/config-cheat-sheet.en-us.md
@@ -492,7 +492,7 @@ Configuration at `[queue]` will set defaults for queues with overrides for indiv
 - `DATADIR`: **queues/common**: Base DataDir for storing level queues. `DATADIR` for individual queues can be set in `queue.name` sections. Relative paths will be made absolute against `%(APP_DATA_PATH)s`.
 - `LENGTH`: **100000**: Maximal queue size before channel queues block
 - `BATCH_LENGTH`: **20**: Batch data before passing to the handler
-- `CONN_STR`: **redis://127.0.0.1:6379/0**: Connection string for the redis queue type. For `redis-cluster` use `redis+cluster://127.0.0.1:6379/0`. Options can be set using query params. Similarly, LevelDB options can also be set using: **leveldb://relative/path?option=value** or **leveldb:///absolute/path?option=value**, and will override `DATADIR`
+- `CONN_STR`: **redis://127.0.0.1:6379/0**: Connection string for the redis queue type. If you're running a Redis cluster, use `redis+cluster://127.0.0.1:6379/0`. Options can be set using query params. Similarly, LevelDB options can also be set using: **leveldb://relative/path?option=value** or **leveldb:///absolute/path?option=value**, and will override `DATADIR`
 - `QUEUE_NAME`: **_queue**: The suffix for default redis and disk queue name. Individual queues will default to **`name`**`QUEUE_NAME` but can be overridden in the specific `queue.name` section.
 - `SET_NAME`: **_unique**: The suffix that will be added to the default redis and disk queue `set` name for unique queues. Individual queues will default to **`name`**`QUEUE_NAME`_`SET_NAME`_ but can be overridden in the specific `queue.name` section.
 - `MAX_WORKERS`: **(dynamic)**: Maximum number of worker go-routines for the queue. Default value is "CpuNum/2" clipped to between 1 and 10.
@@ -777,11 +777,11 @@ and
 
 ## Cache (`cache`)
 
-- `ADAPTER`: **memory**: Cache engine adapter, either `memory`, `redis`, `redis-cluster`, `twoqueue` or `memcache`. (`twoqueue` represents a size limited LRU cache.)
+- `ADAPTER`: **memory**: Cache engine adapter, either `memory`, `redis`, `twoqueue` or `memcache`. (`twoqueue` represents a size limited LRU cache.)
 - `INTERVAL`: **60**: Garbage Collection interval (sec), for memory and twoqueue cache only.
-- `HOST`: **_empty_**: Connection string for `redis`, `redis-cluster` and `memcache`. For `twoqueue` sets configuration for the queue.
+- `HOST`: **_empty_**: Connection string for `redis` and `memcache`. For `twoqueue` sets configuration for the queue.
   - Redis: `redis://:macaron@127.0.0.1:6379/0?pool_size=100&idle_timeout=180s`
-  - Redis-cluster `redis+cluster://:macaron@127.0.0.1:6379/0?pool_size=100&idle_timeout=180s`
+    - For a Redis cluster: `redis+cluster://:macaron@127.0.0.1:6379/0?pool_size=100&idle_timeout=180s`
   - Memcache: `127.0.0.1:9090;127.0.0.1:9091`
   - TwoQueue LRU cache: `{"size":50000,"recent_ratio":0.25,"ghost_ratio":0.5}` or `50000` representing the maximum number of objects stored in the cache.
 - `ITEM_TTL`: **16h**: Time to keep items in cache if not used, Setting it to -1 disables caching.
@@ -793,7 +793,7 @@ and
 
 ## Session (`session`)
 
-- `PROVIDER`: **memory**: Session engine provider \[memory, file, redis, redis-cluster, db, mysql, couchbase, memcache, postgres\]. Setting `db` will reuse the configuration in `[database]`
+- `PROVIDER`: **memory**: Session engine provider \[memory, file, redis, db, mysql, couchbase, memcache, postgres\]. Setting `db` will reuse the configuration in `[database]`
 - `PROVIDER_CONFIG`: **data/sessions**: For file, the root path; for db, empty (database config will be used); for others, the connection string. Relative paths will be made absolute against _`AppWorkPath`_.
 - `COOKIE_SECURE`:**_empty_**: `true` or `false`. Enable this to force using HTTPS for all session access. If not set, it defaults to `true` if the ROOT_URL is an HTTPS URL.
 - `COOKIE_NAME`: **i\_like\_gitea**: The name of the cookie used for the session ID.

From 6f7cd94a02aaf14bf2e2a6219bbc4379c4995b5d Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Wed, 1 May 2024 20:32:52 +0800
Subject: [PATCH 12/47] Fix bleve fuzziness (#30799)

Fix #30797
Fix #30317
---
 modules/indexer/code/bleve/bleve.go    |  4 +---
 modules/indexer/internal/bleve/util.go | 12 ++++++++++++
 modules/indexer/issues/bleve/bleve.go  |  8 ++------
 routers/web/repo/search.go             |  2 +-
 4 files changed, 16 insertions(+), 10 deletions(-)

diff --git a/modules/indexer/code/bleve/bleve.go b/modules/indexer/code/bleve/bleve.go
index bd844205a6..8056b58ec2 100644
--- a/modules/indexer/code/bleve/bleve.go
+++ b/modules/indexer/code/bleve/bleve.go
@@ -39,8 +39,6 @@ import (
 const (
 	unicodeNormalizeName = "unicodeNormalize"
 	maxBatchSize         = 16
-	// fuzzyDenominator determines the levenshtein distance per each character of a keyword
-	fuzzyDenominator = 4
 )
 
 func addUnicodeNormalizeTokenFilter(m *mapping.IndexMappingImpl) error {
@@ -245,7 +243,7 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
 	phraseQuery.Analyzer = repoIndexerAnalyzer
 	keywordQuery = phraseQuery
 	if opts.IsKeywordFuzzy {
-		phraseQuery.Fuzziness = len(opts.Keyword) / fuzzyDenominator
+		phraseQuery.Fuzziness = inner_bleve.GuessFuzzinessByKeyword(opts.Keyword)
 	}
 
 	if len(opts.RepoIDs) > 0 {
diff --git a/modules/indexer/internal/bleve/util.go b/modules/indexer/internal/bleve/util.go
index 43a7c3c5ec..a2265f86e6 100644
--- a/modules/indexer/internal/bleve/util.go
+++ b/modules/indexer/internal/bleve/util.go
@@ -47,3 +47,15 @@ func openIndexer(path string, latestVersion int) (bleve.Index, int, error) {
 
 	return index, 0, nil
 }
+
+func GuessFuzzinessByKeyword(s string) int {
+	// according to https://github.com/blevesearch/bleve/issues/1563, the supported max fuzziness is 2
+	// magic number 4 was chosen to determine the levenshtein distance per each character of a keyword
+	// BUT, when using CJK (eg: `갃갃갃` `啊啊啊`), it mismatches a lot.
+	for _, r := range s {
+		if r >= 128 {
+			return 0
+		}
+	}
+	return min(2, len(s)/4)
+}
diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go
index 1f54be721b..d7957b266a 100644
--- a/modules/indexer/issues/bleve/bleve.go
+++ b/modules/indexer/issues/bleve/bleve.go
@@ -35,11 +35,7 @@ func addUnicodeNormalizeTokenFilter(m *mapping.IndexMappingImpl) error {
 	})
 }
 
-const (
-	maxBatchSize = 16
-	// fuzzyDenominator determines the levenshtein distance per each character of a keyword
-	fuzzyDenominator = 4
-)
+const maxBatchSize = 16
 
 // IndexerData an update to the issue indexer
 type IndexerData internal.IndexerData
@@ -162,7 +158,7 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
 	if options.Keyword != "" {
 		fuzziness := 0
 		if options.IsFuzzyKeyword {
-			fuzziness = len(options.Keyword) / fuzzyDenominator
+			fuzziness = inner_bleve.GuessFuzzinessByKeyword(options.Keyword)
 		}
 
 		queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go
index 23cf898630..d7854b2499 100644
--- a/routers/web/repo/search.go
+++ b/routers/web/repo/search.go
@@ -28,6 +28,7 @@ func Search(ctx *context.Context) {
 	ctx.Data["Language"] = language
 	ctx.Data["IsFuzzy"] = isFuzzy
 	ctx.Data["PageIsViewCode"] = true
+	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
 
 	if keyword == "" {
 		ctx.HTML(http.StatusOK, tplSearch)
@@ -86,7 +87,6 @@ func Search(ctx *context.Context) {
 		}
 	}
 
-	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
 	ctx.Data["Repo"] = ctx.Repo.Repository
 	ctx.Data["SearchResults"] = searchResults
 	ctx.Data["SearchResultLanguages"] = searchResultLanguages

From ce08a9fe2f7c9ae6390f1ad3d619524010a4e787 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Thu, 2 May 2024 09:00:46 +0800
Subject: [PATCH 13/47] Fix markdown rendering when mentioning users (#30795)

---
 modules/markup/html.go                | 15 +++++++--------
 modules/references/references.go      |  2 +-
 modules/references/references_test.go |  2 +-
 modules/templates/util_render_test.go |  5 +++++
 4 files changed, 14 insertions(+), 10 deletions(-)

diff --git a/modules/markup/html.go b/modules/markup/html.go
index cef643bf18..5ae0cc8755 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -591,17 +591,16 @@ func replaceContentList(node *html.Node, i, j int, newNodes []*html.Node) {
 
 func mentionProcessor(ctx *RenderContext, node *html.Node) {
 	start := 0
-	next := node.NextSibling
-	for node != nil && node != next && start < len(node.Data) {
-		// We replace only the first mention; other mentions will be addressed later
-		found, loc := references.FindFirstMentionBytes([]byte(node.Data[start:]))
+	for node != nil {
+		found, loc := references.FindFirstMentionBytes(util.UnsafeStringToBytes(node.Data[start:]))
 		if !found {
-			return
+			node = node.NextSibling
+			start = 0
+			continue
 		}
 		loc.Start += start
 		loc.End += start
 		mention := node.Data[loc.Start:loc.End]
-		var teams string
 		teams, ok := ctx.Metas["teams"]
 		// FIXME: util.URLJoin may not be necessary here:
 		// - setting.AppURL is defined to have a terminal '/' so unless mention[1:]
@@ -623,10 +622,10 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) {
 		if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) {
 			replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "mention"))
 			node = node.NextSibling.NextSibling
+			start = 0
 		} else {
-			node = node.NextSibling
+			start = loc.End
 		}
-		start = 0
 	}
 }
 
diff --git a/modules/references/references.go b/modules/references/references.go
index 761d6ee3d1..1b656ed4cb 100644
--- a/modules/references/references.go
+++ b/modules/references/references.go
@@ -29,7 +29,7 @@ var (
 	// TODO: fix invalid linking issue
 
 	// mentionPattern matches all mentions in the form of "@user" or "@org/team"
-	mentionPattern = regexp.MustCompile(`(?:\s|^|\(|\[)(@[0-9a-zA-Z-_]+|@[0-9a-zA-Z-_]+\/?[0-9a-zA-Z-_]+|@[0-9a-zA-Z-_][0-9a-zA-Z-_.]+\/?[0-9a-zA-Z-_.]+[0-9a-zA-Z-_])(?:\s|[:,;.?!]\s|[:,;.?!]?$|\)|\])`)
+	mentionPattern = regexp.MustCompile(`(?:\s|^|\(|\[)(@[-\w][-.\w]*?|@[-\w][-.\w]*?/[-\w][-.\w]*?)(?:\s|$|[:,;.?!](\s|$)|'|\)|\])`)
 	// issueNumericPattern matches string that references to a numeric issue, e.g. #1287
 	issueNumericPattern = regexp.MustCompile(`(?:\s|^|\(|\[|\'|\")([#!][0-9]+)(?:\s|$|\)|\]|\'|\"|[:;,.?!]\s|[:;,.?!]$)`)
 	// issueAlphanumericPattern matches string that references to an alphanumeric issue, e.g. ABC-1234
diff --git a/modules/references/references_test.go b/modules/references/references_test.go
index 0c32933619..e5a0d60fe3 100644
--- a/modules/references/references_test.go
+++ b/modules/references/references_test.go
@@ -392,6 +392,7 @@ func TestRegExp_mentionPattern(t *testing.T) {
 		{"@gitea,", "@gitea"},
 		{"@gitea;", "@gitea"},
 		{"@gitea/team1;", "@gitea/team1"},
+		{"@user's idea", "@user"},
 	}
 	falseTestCases := []string{
 		"@ 0",
@@ -412,7 +413,6 @@ func TestRegExp_mentionPattern(t *testing.T) {
 
 	for _, testCase := range trueTestCases {
 		found := mentionPattern.FindStringSubmatch(testCase.pat)
-		assert.Len(t, found, 2)
 		assert.Equal(t, testCase.exp, found[1])
 	}
 	for _, testCase := range falseTestCases {
diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go
index 47c5da6485..f493b899e3 100644
--- a/modules/templates/util_render_test.go
+++ b/modules/templates/util_render_test.go
@@ -207,3 +207,8 @@ func TestRenderLabels(t *testing.T) {
 	expected = `/owner/repo/pulls?labels=123`
 	assert.Contains(t, RenderLabels(ctx, locale, []*issues.Label{label}, "/owner/repo", issue), expected)
 }
+
+func TestUserMention(t *testing.T) {
+	rendered := RenderMarkdownToHtml(context.Background(), "@no-such-user @mention-user @mention-user")
+	assert.EqualValues(t, `<p>@no-such-user <a href="/mention-user" rel="nofollow">@mention-user</a> <a href="/mention-user" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered)))
+}

From be112c1fc30f87a248b30f48e891d1c8c18e8280 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Thu, 2 May 2024 10:27:25 +0800
Subject: [PATCH 14/47] Skip gzip for some well-known compressed file types
 (#30796)

Co-authored-by: silverwind <me@silverwind.io>
---
 modules/httplib/serve.go               |  8 +++++++
 routers/web/web.go                     |  2 +-
 tests/integration/repo_archive_test.go | 33 ++++++++++++++++++++++++++
 3 files changed, 42 insertions(+), 1 deletion(-)
 create mode 100644 tests/integration/repo_archive_test.go

diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go
index a193ed901c..6e147d76f5 100644
--- a/modules/httplib/serve.go
+++ b/modules/httplib/serve.go
@@ -17,11 +17,14 @@ import (
 	"time"
 
 	charsetModule "code.gitea.io/gitea/modules/charset"
+	"code.gitea.io/gitea/modules/container"
 	"code.gitea.io/gitea/modules/httpcache"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/typesniffer"
 	"code.gitea.io/gitea/modules/util"
+
+	"github.com/klauspost/compress/gzhttp"
 )
 
 type ServeHeaderOptions struct {
@@ -38,6 +41,11 @@ type ServeHeaderOptions struct {
 func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
 	header := w.Header()
 
+	skipCompressionExts := container.SetOf(".gz", ".bz2", ".zip", ".xz", ".zst", ".deb", ".apk", ".jar", ".png", ".jpg", ".webp")
+	if skipCompressionExts.Contains(strings.ToLower(path.Ext(opts.Filename))) {
+		w.Header().Add(gzhttp.HeaderNoCompression, "1")
+	}
+
 	contentType := typesniffer.ApplicationOctetStream
 	if opts.ContentType != "" {
 		if opts.ContentTypeCharset != "" {
diff --git a/routers/web/web.go b/routers/web/web.go
index 9a6687059b..91ab378d97 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -54,7 +54,7 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 )
 
-const GzipMinSize = 1400 // min size to compress for the body size of response
+var GzipMinSize = 1400 // min size to compress for the body size of response
 
 // optionsCorsHandler return a http handler which sets CORS options if enabled by config, it blocks non-CORS OPTIONS requests.
 func optionsCorsHandler() func(next http.Handler) http.Handler {
diff --git a/tests/integration/repo_archive_test.go b/tests/integration/repo_archive_test.go
new file mode 100644
index 0000000000..664b04baf7
--- /dev/null
+++ b/tests/integration/repo_archive_test.go
@@ -0,0 +1,33 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"io"
+	"net/http"
+	"testing"
+
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/test"
+	"code.gitea.io/gitea/routers"
+	"code.gitea.io/gitea/routers/web"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRepoDownloadArchive(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	defer test.MockVariableValue(&setting.EnableGzip, true)()
+	defer test.MockVariableValue(&web.GzipMinSize, 10)()
+	defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+	req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip")
+	req.Header.Set("Accept-Encoding", "gzip")
+	resp := MakeRequest(t, req, http.StatusOK)
+	bs, err := io.ReadAll(resp.Body)
+	assert.NoError(t, err)
+	assert.Empty(t, resp.Header().Get("Content-Encoding"))
+	assert.Equal(t, 320, len(bs))
+}

From 82eca44581100d96e11097db743804bc398d1742 Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Thu, 2 May 2024 11:25:55 +0200
Subject: [PATCH 15/47] Fix rounded border for segment followed by pagination
 (#30809)

Fixes https://github.com/go-gitea/gitea/issues/30673, specifically
https://github.com/go-gitea/gitea/issues/30673#issuecomment-2085329812.
---
 web_src/css/modules/segment.css | 1 +
 1 file changed, 1 insertion(+)

diff --git a/web_src/css/modules/segment.css b/web_src/css/modules/segment.css
index cb307dc1a3..48dc5c4488 100644
--- a/web_src/css/modules/segment.css
+++ b/web_src/css/modules/segment.css
@@ -152,6 +152,7 @@
 }
 
 .ui.attached.segment:has(+ .ui[class*="top attached"].header),
+.ui.attached.segment:has(+ .page.buttons),
 .ui.attached.segment:last-child,
 .ui.segment:has(+ .ui.segment:not(.attached)),
 .ui.attached.segment:has(+ .ui.modal) {

From ebe6f4cad775a82d11c916c9af716beec394768b Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Thu, 2 May 2024 18:45:23 +0800
Subject: [PATCH 16/47] Fix branch selector UI (#30803)

Fix  #30802
---
 templates/repo/branch_dropdown.tmpl           |  8 +-
 .../repo/issue/branch_selector_field.tmpl     | 44 +++++-----
 .../repo/issue/labels/labels_sidebar.tmpl     |  2 +-
 web_src/css/base.css                          |  1 +
 web_src/css/repo.css                          | 85 ++++++++++++-------
 .../js/components/RepoBranchTagSelector.vue   | 48 +----------
 web_src/js/features/repo-legacy.js            | 25 +++---
 7 files changed, 96 insertions(+), 117 deletions(-)

diff --git a/templates/repo/branch_dropdown.tmpl b/templates/repo/branch_dropdown.tmpl
index 7b39830df8..8f58826c6a 100644
--- a/templates/repo/branch_dropdown.tmpl
+++ b/templates/repo/branch_dropdown.tmpl
@@ -69,9 +69,9 @@
 
 <div class="js-branch-tag-selector {{if .ContainerClasses}}{{.ContainerClasses}}{{end}}">
 	{{/* show dummy elements before Vue componment is mounted, this code must match the code in BranchTagSelector.vue */}}
-	<div class="ui dropdown custom">
-		<button class="branch-dropdown-button gt-ellipsis ui basic small compact button tw-flex tw-m-0">
-			<span class="text tw-flex tw-items-center tw-mr-1 gt-ellipsis">
+	<div class="ui dropdown custom branch-selector-dropdown">
+		<div class="ui button branch-dropdown-button">
+			<span class="flex-text-block gt-ellipsis">
 				{{if .release}}
 					{{ctx.Locale.Tr "repo.release.compare"}}
 				{{else}}
@@ -84,6 +84,6 @@
 				{{end}}
 			</span>
 			{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-		</button>
+		</div>
 	</div>
 </div>
diff --git a/templates/repo/issue/branch_selector_field.tmpl b/templates/repo/issue/branch_selector_field.tmpl
index ed0d58cf27..e9e5574cd7 100644
--- a/templates/repo/issue/branch_selector_field.tmpl
+++ b/templates/repo/issue/branch_selector_field.tmpl
@@ -4,10 +4,12 @@
 <form method="post" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/ref" id="update_issueref_form">
 	{{$.CsrfTokenHtml}}
 </form>
-{{/* TODO: share this branch selector dropdown with the same in repo page */}}
-<div class="ui {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}} floating filter select-branch dropdown tw-max-w-full" data-no-results="{{ctx.Locale.Tr "no_results_found"}}">
-	<div class="ui basic small button">
-		<span class="text branch-name gt-ellipsis">{{if .Reference}}{{$.RefEndName}}{{else}}{{ctx.Locale.Tr "repo.issues.no_ref"}}{{end}}</span>
+<div class="ui dropdown select-branch branch-selector-dropdown {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}}"
+	data-no-results="{{ctx.Locale.Tr "no_results_found"}}"
+	{{if not .Issue}}data-for-new-issue="true"{{end}}
+>
+	<div class="ui button branch-dropdown-button">
+		<span class="text-branch-name gt-ellipsis">{{if .Reference}}{{$.RefEndName}}{{else}}{{ctx.Locale.Tr "repo.issues.no_ref"}}{{end}}</span>
 		{{if .HasIssuesOrPullsWritePermission}}{{svg "octicon-triangle-down" 14 "dropdown icon"}}{{end}}
 	</div>
 	<div class="menu">
@@ -15,26 +17,18 @@
 			<i class="icon">{{svg "octicon-filter" 16}}</i>
 			<input name="search" placeholder="{{ctx.Locale.Tr "repo.filter_branch_and_tag"}}...">
 		</div>
-		<div class="header">
-			<div class="ui grid">
-				<div class="two column row">
-					<a class="reference column muted" href="#" data-target="#branch-list">
-						<span class="text black">
-							{{svg "octicon-git-branch" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.branches"}}
-						</span>
-					</a>
-					<a class="reference column muted" href="#" data-target="#tag-list">
-						<span class="text">
-							{{svg "octicon-tag" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.tags"}}
-						</span>
-					</a>
-				</div>
-			</div>
+		<div class="branch-tag-tab">
+			<a class="branch-tag-item reference column muted active" href="#" data-target="#branch-list">
+				{{svg "octicon-git-branch" 16 "tw-mr-1"}} {{ctx.Locale.Tr "repo.branches"}}
+			</a>
+			<a class="branch-tag-item reference column muted" href="#" data-target="#tag-list">
+				{{svg "octicon-tag" 16 "tw-mr-1"}} {{ctx.Locale.Tr "repo.tags"}}
+			</a>
 		</div>
 		<div class="branch-tag-divider"></div>
-		<div id="branch-list" class="scrolling menu reference-list-menu {{if not .Issue}}new-issue{{end}}">
-			{{if .Reference}}
-				<div class="item text small" data-id="" data-id-selector="#ref_selector"><strong><a href="#">{{ctx.Locale.Tr "repo.clear_ref"}}</a></strong></div>
+		<div id="branch-list" class="scrolling menu reference-list-menu">
+			{{if or .Reference (not .Issue)}}
+				<div class="item text small" data-id="" data-name="{{ctx.Locale.Tr "repo.issues.no_ref"}}" data-id-selector="#ref_selector"><strong><a href="#">{{ctx.Locale.Tr "repo.clear_ref"}}</a></strong></div>
 			{{end}}
 			{{range .Branches}}
 				<div class="item" data-id="refs/heads/{{.}}" data-name="{{.}}" data-id-selector="#ref_selector" title="{{.}}">{{.}}</div>
@@ -42,9 +36,9 @@
 				<div class="item">{{ctx.Locale.Tr "no_results_found"}}</div>
 			{{end}}
 		</div>
-		<div id="tag-list" class="scrolling menu reference-list-menu {{if not .Issue}}new-issue{{end}} tw-hidden">
-			{{if .Reference}}
-				<div class="item text small" data-id="" data-id-selector="#ref_selector"><strong><a href="#">{{ctx.Locale.Tr "repo.clear_ref"}}</a></strong></div>
+		<div id="tag-list" class="scrolling menu reference-list-menu tw-hidden">
+			{{if or .Reference (not .Issue)}}
+				<div class="item text small" data-id="" data-name="{{ctx.Locale.Tr "repo.issues.no_ref"}}" data-id-selector="#ref_selector"><strong><a href="#">{{ctx.Locale.Tr "repo.clear_ref"}}</a></strong></div>
 			{{end}}
 			{{range .Tags}}
 				<div class="item" data-id="refs/tags/{{.}}" data-name="tags/{{.}}" data-id-selector="#ref_selector">{{.}}</div>
diff --git a/templates/repo/issue/labels/labels_sidebar.tmpl b/templates/repo/issue/labels/labels_sidebar.tmpl
index be30baba92..0b7b9b8969 100644
--- a/templates/repo/issue/labels/labels_sidebar.tmpl
+++ b/templates/repo/issue/labels/labels_sidebar.tmpl
@@ -1,6 +1,6 @@
 <div class="ui labels list">
-	<span class="no-select item {{if .root.HasSelectedLabel}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_label"}}</span>
 	<span class="labels-list">
+		<span class="no-select {{if .root.HasSelectedLabel}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_label"}}</span>
 		{{range .root.Labels}}
 			{{template "repo/issue/labels/label" dict "root" $.root "label" .}}
 		{{end}}
diff --git a/web_src/css/base.css b/web_src/css/base.css
index 1d65bb37e7..c0ced2955c 100644
--- a/web_src/css/base.css
+++ b/web_src/css/base.css
@@ -871,6 +871,7 @@ input:-webkit-autofill:active,
 
 .ui.dropdown .scrolling.menu {
   border-color: var(--color-secondary);
+  border-radius: 0 0 var(--border-radius) var(--border-radius) !important;
 }
 
 .color-preview {
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 0b46f6b69f..cc09ec94e2 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -2748,23 +2748,6 @@ tbody.commit-list {
   }
 }
 
-.branch-dropdown-button {
-  max-width: 340px;
-  vertical-align: bottom !important;
-}
-
-@media (min-width: 768px) and (max-width: 991.98px) {
-  .branch-dropdown-button {
-    max-width: 185px;
-  }
-}
-
-@media (max-width: 767.98px) {
-  .branch-dropdown-button {
-    max-width: 165px;
-  }
-}
-
 .commit-status-header {
   /* reset the default ".ui.attached.header" styles, to use the outer border */
   border: none !important;
@@ -2841,32 +2824,70 @@ tbody.commit-list {
   max-height: 200px;
 }
 
-/* Branch tag selector - TODO: Merge this into the same selector on repo page */
-.repository .issue-content .issue-content-right  .ui.grid .column.row {
-  padding: 10px;
-  padding-bottom: 0;
+.branch-selector-dropdown {
+  max-width: 100%;
 }
-.repository .issue-content .issue-content-right  .ui.grid .column.muted {
-  padding: 0;
+
+.ui.dropdown.branch-selector-dropdown > .menu {
+  margin-top: 4px;
 }
-.repository .issue-content .issue-content-right  .ui.grid .column.muted .text {
+
+.branch-selector-dropdown .branch-dropdown-button {
+  margin: 0;
+  max-width: 340px;
+  line-height: var(--line-height-default);
+}
+
+/* FIXME: These media selectors are not ideal (just keep them from old code).
+    There are many different pages, some need the max-width while some others don't,
+    they should be tested and improved in the future. */
+@media (min-width: 768px) and (max-width: 991.98px) {
+  .branch-selector-dropdown .branch-dropdown-button {
+    max-width: 185px;
+  }
+}
+
+@media (max-width: 767.98px) {
+  .branch-selector-dropdown .branch-dropdown-button {
+    max-width: 165px;
+  }
+}
+
+.branch-selector-dropdown .branch-tag-tab {
+  padding: 0 10px;
+}
+
+.branch-selector-dropdown .branch-tag-item {
   display: inline-block;
   padding: 10px;
-  width: 100%;
-  text-align: center;
   border: 1px solid transparent;
   border-bottom: none;
 }
-.repository .issue-content .issue-content-right .ui.grid .column.muted .text.black {
+
+.branch-selector-dropdown .branch-tag-item.active {
   border-color: var(--color-secondary);
   background: var(--color-menu);
   border-top-left-radius: var(--border-radius);
   border-top-right-radius: var(--border-radius);
 }
-.repository .issue-content .issue-content-right .ui.dropdown  .scrolling.menu {
-  border-top: none;
-}
-.repository .issue-content .issue-content-right .branch-tag-divider {
-  margin-top: -1px;
+
+.branch-selector-dropdown .branch-tag-divider {
+  margin-top: -1px !important;
   border-top: 1px solid var(--color-secondary);
 }
+
+.branch-selector-dropdown .scrolling.menu {
+  border-top: none !important;
+}
+
+.branch-selector-dropdown .menu .item .rss-icon {
+  visibility: hidden; /* only show RSS icon on hover */
+}
+
+.branch-selector-dropdown .menu .item:hover .rss-icon {
+  visibility: visible;
+}
+
+.branch-selector-dropdown .scrolling.menu .loading-indicator {
+  height: 4em;
+}
diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue
index c13af14dea..8a741b68da 100644
--- a/web_src/js/components/RepoBranchTagSelector.vue
+++ b/web_src/js/components/RepoBranchTagSelector.vue
@@ -246,9 +246,9 @@ export function initRepoBranchTagSelector(selector) {
 export default sfc; // activate IDE's Vue plugin
 </script>
 <template>
-  <div class="ui dropdown custom">
-    <button class="branch-dropdown-button gt-ellipsis ui basic small compact button tw-flex tw-m-0" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
-      <span class="text tw-flex tw-items-center tw-mr-1 gt-ellipsis">
+  <div class="ui dropdown custom branch-selector-dropdown">
+    <div class="ui button branch-dropdown-button" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
+      <span class="flex-text-block gt-ellipsis">
         <template v-if="release">{{ textReleaseCompare }}</template>
         <template v-else>
           <svg-icon v-if="isViewTag" name="octicon-tag"/>
@@ -257,7 +257,7 @@ export default sfc; // activate IDE's Vue plugin
         </template>
       </span>
       <svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/>
-    </button>
+    </div>
     <div class="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak>
       <div class="ui icon search input">
         <i class="icon"><svg-icon name="octicon-filter" :size="16"/></i>
@@ -317,43 +317,3 @@ export default sfc; // activate IDE's Vue plugin
     </div>
   </div>
 </template>
-<style scoped>
-.branch-tag-tab {
-  padding: 0 10px;
-}
-
-.branch-tag-item {
-  display: inline-block;
-  padding: 10px;
-  border: 1px solid transparent;
-  border-bottom: none;
-}
-
-.branch-tag-item.active {
-  border-color: var(--color-secondary);
-  background: var(--color-menu);
-  border-top-left-radius: var(--border-radius);
-  border-top-right-radius: var(--border-radius);
-}
-
-.branch-tag-divider {
-  margin-top: -1px !important;
-  border-top: 1px solid var(--color-secondary);
-}
-
-.scrolling.menu {
-  border-top: none !important;
-}
-
-.menu .item .rss-icon {
-  display: none; /* only show RSS icon on hover */
-}
-
-.menu .item:hover .rss-icon {
-  display: inline-block;
-}
-
-.scrolling.menu .loading-indicator {
-  height: 4em;
-}
-</style>
diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js
index 18d98c891d..670e60def0 100644
--- a/web_src/js/features/repo-legacy.js
+++ b/web_src/js/features/repo-legacy.js
@@ -19,7 +19,7 @@ import {initCompReactionSelector} from './comp/ReactionSelector.js';
 import {initRepoSettingBranches} from './repo-settings.js';
 import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.js';
 import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.js';
-import {hideElem, showElem} from '../utils/dom.js';
+import {hideElem, queryElemChildren, showElem} from '../utils/dom.js';
 import {POST} from '../modules/fetch.js';
 import {initRepoIssueCommentEdit} from './repo-issue-edit.js';
 
@@ -56,16 +56,19 @@ export function initRepoCommentForm() {
   }
 
   function initBranchSelector() {
-    const $selectBranch = $('.ui.select-branch');
+    const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
+    const isForNewIssue = elSelectBranch.getAttribute('data-for-new-issue') === 'true';
+
+    const $selectBranch = $(elSelectBranch);
     const $branchMenu = $selectBranch.find('.reference-list-menu');
-    const $isNewIssue = $branchMenu.hasClass('new-issue');
-    $branchMenu.find('.item:not(.no-select)').on('click', async function () {
-      const selectedValue = $(this).data('id');
+    $branchMenu.find('.item:not(.no-select)').on('click', async function (e) {
+      e.preventDefault();
+      const selectedValue = $(this).data('id'); // eg: "refs/heads/my-branch"
       const editMode = $('#editing_mode').val();
       $($(this).data('id-selector')).val(selectedValue);
-      if ($isNewIssue) {
-        $selectBranch.find('.ui .branch-name').text($(this).data('name'));
-        return;
+      if (isForNewIssue) {
+        elSelectBranch.querySelector('.text-branch-name').textContent = this.getAttribute('data-name');
+        return; // only update UI&form, do not send request/reload
       }
 
       if (editMode === 'true') {
@@ -84,9 +87,9 @@ export function initRepoCommentForm() {
     });
     $selectBranch.find('.reference.column').on('click', function () {
       hideElem($selectBranch.find('.scrolling.reference-list-menu'));
-      $selectBranch.find('.reference .text').removeClass('black');
-      showElem($($(this).data('target')));
-      $(this).find('.text').addClass('black');
+      showElem(this.getAttribute('data-target'));
+      queryElemChildren(this.parentNode, '.branch-tag-item', (el) => el.classList.remove('active'));
+      this.classList.add('active');
       return false;
     });
   }

From 6ff2acc52c976e9d7bb6a5693f8a2365d12400f5 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Thu, 2 May 2024 19:19:44 +0800
Subject: [PATCH 17/47] Fix issue card layout (#30800)

Fix #30788
---
 templates/repo/issue/card.tmpl  |  6 +++---
 web_src/css/repo.css            |  8 +-------
 web_src/css/repo/issue-card.css | 15 +++++++++++++++
 3 files changed, 19 insertions(+), 10 deletions(-)

diff --git a/templates/repo/issue/card.tmpl b/templates/repo/issue/card.tmpl
index 4a0ac050aa..526f6dd5db 100644
--- a/templates/repo/issue/card.tmpl
+++ b/templates/repo/issue/card.tmpl
@@ -62,13 +62,13 @@
 	</div>
 
 	{{if or .Labels .Assignees}}
-	<div class="tw-flex tw-justify-between">
-		<div class="labels-list tw-flex-1">
+	<div class="issue-card-bottom">
+		<div class="labels-list">
 			{{range .Labels}}
 				<a target="_blank" href="{{$.Issue.Repo.Link}}/issues?labels={{.ID}}">{{RenderLabel ctx ctx.Locale .}}</a>
 			{{end}}
 		</div>
-		<div class="tw-flex tw-flex-wrap tw-content-start tw-gap-1">
+		<div class="issue-card-assignees">
 			{{range .Assignees}}
 				<a target="_blank" href="{{.HomeLink}}" data-tooltip-content="{{ctx.Locale.Tr "repo.projects.column.assigned_to"}} {{.Name}}">{{ctx.AvatarUtils.Avatar . 28}}</a>
 			{{end}}
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index cc09ec94e2..a930e130f8 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -2195,18 +2195,12 @@ td .commit-summary {
   display: inline-flex;
   flex-wrap: wrap;
   gap: 2.5px;
-}
-
-.labels-list a {
-  display: flex;
-  text-decoration: none;
+  align-items: center;
 }
 
 .labels-list .label {
   padding: 0 6px;
-  margin: 0 !important;
   min-height: 20px;
-  display: inline-flex !important;
   line-height: 1.3; /* there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly */
 }
 
diff --git a/web_src/css/repo/issue-card.css b/web_src/css/repo/issue-card.css
index 609b1b3dbd..390bfb6a01 100644
--- a/web_src/css/repo/issue-card.css
+++ b/web_src/css/repo/issue-card.css
@@ -23,3 +23,18 @@
 .issue-card.sortable-chosen .issue-card-title {
   cursor: inherit;
 }
+
+.issue-card-bottom {
+  display: flex;
+  width: 100%;
+  justify-content: space-between;
+  gap: 0.25em;
+}
+
+.issue-card-assignees {
+  display: flex;
+  align-items: center;
+  gap: 0.25em;
+  justify-content: end;
+  flex-wrap: wrap;
+}

From eb8bb82e584f0d0cb91ebc0e37e40c53da729ce8 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Thu, 2 May 2024 21:22:55 +0800
Subject: [PATCH 18/47] Fix activity heat map padding & locale (#30823)

Fix #30808

---------

Co-authored-by: silverwind <me@silverwind.io>
---
 package-lock.json                         | 26 +++++++++++------------
 package.json                              |  2 +-
 web_src/js/components/ActivityHeatmap.vue | 12 ++++++-----
 web_src/js/features/heatmap.js            | 17 +++++++++------
 4 files changed, 31 insertions(+), 26 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 917ff1029b..bba4ca5a9d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
         "@github/text-expander-element": "2.6.1",
         "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
         "@primer/octicons": "19.9.0",
+        "@silverwind/vue3-calendar-heatmap": "2.0.6",
         "add-asset-webpack-plugin": "2.0.1",
         "ansi_up": "6.0.2",
         "asciinema-player": "3.7.1",
@@ -57,7 +58,6 @@
         "vue-bar-graph": "2.0.0",
         "vue-chartjs": "5.3.1",
         "vue-loader": "17.4.2",
-        "vue3-calendar-heatmap": "2.0.5",
         "webpack": "5.91.0",
         "webpack-cli": "5.1.4",
         "wrap-ansi": "9.0.0"
@@ -1626,6 +1626,18 @@
         "win32"
       ]
     },
+    "node_modules/@silverwind/vue3-calendar-heatmap": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@silverwind/vue3-calendar-heatmap/-/vue3-calendar-heatmap-2.0.6.tgz",
+      "integrity": "sha512-efX+nf2GR7EfA7iNgZDeM9Jue5ksglSXvN0C/ja0M1bTmkCpAxKlGJ3vki7wfTPQgX1O0nCfAM62IKqUUEM0cQ==",
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "tippy.js": "^6.3.7",
+        "vue": "^3.2.29"
+      }
+    },
     "node_modules/@sinclair/typebox": {
       "version": "0.27.8",
       "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -12200,18 +12212,6 @@
         }
       }
     },
-    "node_modules/vue3-calendar-heatmap": {
-      "version": "2.0.5",
-      "resolved": "https://registry.npmjs.org/vue3-calendar-heatmap/-/vue3-calendar-heatmap-2.0.5.tgz",
-      "integrity": "sha512-qvveNQlTS5Aw7AvRLs0zOyu3uP5iGJlXJAnkrkG2ElDdyQ8H1TJhQ8rL702CROjAg16ezIveUY10nCO7lqZ25w==",
-      "engines": {
-        "node": ">=16"
-      },
-      "peerDependencies": {
-        "tippy.js": "^6.3.7",
-        "vue": "^3.2.29"
-      }
-    },
     "node_modules/watchpack": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz",
diff --git a/package.json b/package.json
index 5f9b810320..107f0c96cf 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
     "@github/text-expander-element": "2.6.1",
     "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
     "@primer/octicons": "19.9.0",
+    "@silverwind/vue3-calendar-heatmap": "2.0.6",
     "add-asset-webpack-plugin": "2.0.1",
     "ansi_up": "6.0.2",
     "asciinema-player": "3.7.1",
@@ -56,7 +57,6 @@
     "vue-bar-graph": "2.0.0",
     "vue-chartjs": "5.3.1",
     "vue-loader": "17.4.2",
-    "vue3-calendar-heatmap": "2.0.5",
     "webpack": "5.91.0",
     "webpack-cli": "5.1.4",
     "wrap-ansi": "9.0.0"
diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue
index 9592a0df3c..71f41dda1a 100644
--- a/web_src/js/components/ActivityHeatmap.vue
+++ b/web_src/js/components/ActivityHeatmap.vue
@@ -1,5 +1,6 @@
 <script>
-import {CalendarHeatmap} from 'vue3-calendar-heatmap';
+// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
+import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
 
 export default {
   components: {CalendarHeatmap},
@@ -55,15 +56,16 @@ export default {
 </script>
 <template>
   <div class="total-contributions">
-    {{ locale.contributions_in_the_last_12_months }}
+    {{ locale.textTotalContributions }}
   </div>
   <calendar-heatmap
-    :locale="locale"
-    :no-data-text="locale.no_contributions"
-    :tooltip-unit="locale.contributions"
+    :locale="locale.heatMapLocale"
+    :no-data-text="locale.noDataText"
+    :tooltip-unit="locale.tooltipUnit"
     :end-date="endDate"
     :values="values"
     :range-color="colorRange"
     @day-click="handleDayClick($event)"
+    :tippy-props="{theme: 'tooltip'}"
   />
 </template>
diff --git a/web_src/js/features/heatmap.js b/web_src/js/features/heatmap.js
index b6f06d0cfb..719eeb75fb 100644
--- a/web_src/js/features/heatmap.js
+++ b/web_src/js/features/heatmap.js
@@ -20,13 +20,16 @@ export function initHeatmap() {
 
     // last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/a83761cbbae3c2e3b4bced71e680f44432073ac8
     const locale = {
-      months: new Array(12).fill().map((_, idx) => translateMonth(idx)),
-      days: new Array(7).fill().map((_, idx) => translateDay(idx)),
-      contributions: 'contributions',
-      contributions_in_the_last_12_months: el.getAttribute('data-locale-total-contributions'),
-      no_contributions: el.getAttribute('data-locale-no-contributions'),
-      more: el.getAttribute('data-locale-more'),
-      less: el.getAttribute('data-locale-less'),
+      heatMapLocale: {
+        months: new Array(12).fill().map((_, idx) => translateMonth(idx)),
+        days: new Array(7).fill().map((_, idx) => translateDay(idx)),
+        on: ' - ', // no correct locale support for it, because in many languages the sentence is not "something on someday"
+        more: el.getAttribute('data-locale-more'),
+        less: el.getAttribute('data-locale-less'),
+      },
+      tooltipUnit: 'contributions',
+      textTotalContributions: el.getAttribute('data-locale-total-contributions'),
+      noDataText: el.getAttribute('data-locale-no-contributions'),
     };
 
     const View = createApp(ActivityHeatmap, {values, locale});

From b1bb3642e52ae1401bb06de130b17db48cff379e Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Thu, 2 May 2024 15:42:33 +0200
Subject: [PATCH 19/47] Improve context popup rendering (#30824)

Before, lot of empty space when no labels or body:

<img width="281" alt="Screenshot 2024-05-02 at 13 51 29"
src="https://github.com/go-gitea/gitea/assets/115237/8a980ccd-d53c-43a3-a059-dc8c614621e1">

After, empty space collapsed:

<img width="306" alt="Screenshot 2024-05-02 at 13 51 16"
src="https://github.com/go-gitea/gitea/assets/115237/8d9c154d-5de1-43d0-8536-afd9194d99b3">

All `<p>` (unsuitable) and `<small>` (discouraged in favor of css) tags
are removed.
---
 web_src/js/components/.eslintrc.yaml   |  1 +
 web_src/js/components/ContextPopup.vue | 22 ++++++++++++++--------
 2 files changed, 15 insertions(+), 8 deletions(-)

diff --git a/web_src/js/components/.eslintrc.yaml b/web_src/js/components/.eslintrc.yaml
index 0d233442bc..a79e96f330 100644
--- a/web_src/js/components/.eslintrc.yaml
+++ b/web_src/js/components/.eslintrc.yaml
@@ -18,4 +18,5 @@ rules:
   vue/attributes-order: [0]
   vue/html-closing-bracket-spacing: [2, {startTag: never, endTag: never, selfClosingTag: never}]
   vue/max-attributes-per-line: [0]
+  vue/singleline-html-element-content-newline: [0]
   vue-scoped-css/enforce-style-type: [0]
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
index e4e8bce184..8f389ea003 100644
--- a/web_src/js/components/ContextPopup.vue
+++ b/web_src/js/components/ContextPopup.vue
@@ -91,16 +91,22 @@ export default {
 <template>
   <div ref="root">
     <div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
-    <div v-if="!loading && issue !== null">
-      <p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p>
-      <p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p>
-      <p>{{ body }}</p>
+    <div v-if="!loading && issue !== null" class="tw-flex tw-flex-col tw-gap-2">
+      <div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div>
+      <div class="flex-text-block">
+        <svg-icon :name="icon" :class="['text', color]"/>
+        <span class="issue-title tw-font-semibold tw-break-anywhere">
+          {{ issue.title }}
+          <span class="index">#{{ issue.number }}</span>
+        </span>
+      </div>
+      <div v-if="body">{{ body }}</div>
       <!-- eslint-disable-next-line vue/no-v-html -->
-      <div v-html="renderedLabels"/>
+      <div v-if="issue.labels.length" v-html="renderedLabels"/>
     </div>
-    <div v-if="!loading && issue === null">
-      <p><small>{{ i18nErrorOccurred }}</small></p>
-      <p>{{ i18nErrorMessage }}</p>
+    <div class="tw-flex tw-flex-col tw-gap-2" v-if="!loading && issue === null">
+      <div class="tw-text-12">{{ i18nErrorOccurred }}</div>
+      <div>{{ i18nErrorMessage }}</div>
     </div>
   </div>
 </template>

From cb9e1a3ff66f24f89d99f839376e304161c12962 Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Thu, 2 May 2024 22:09:38 +0800
Subject: [PATCH 20/47] Upgrade chi-binding (#30826)

Front port #30742
---
 go.mod | 2 +-
 go.sum | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/go.mod b/go.mod
index 2c1fc5d6f2..bab5a64069 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
 	code.gitea.io/sdk/gitea v0.17.1
 	codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570
 	connectrpc.com/connect v1.15.0
-	gitea.com/go-chi/binding v0.0.0-20240316035258-17450c5f3028
+	gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed
 	gitea.com/go-chi/cache v0.2.0
 	gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
 	gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96
diff --git a/go.sum b/go.sum
index 8c26b4a7a6..3bb4cbaa42 100644
--- a/go.sum
+++ b/go.sum
@@ -20,8 +20,8 @@ git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4H
 git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs=
 gitea.com/gitea/act v0.259.1 h1:8GG1o/xtUHl3qjn5f0h/2FXrT5ubBn05TJOM5ry+FBw=
 gitea.com/gitea/act v0.259.1/go.mod h1:UxZWRYqQG2Yj4+4OqfGWW5a3HELwejyWFQyU7F1jUD8=
-gitea.com/go-chi/binding v0.0.0-20240316035258-17450c5f3028 h1:6/QAx4+s0dyRwdaTFPTnhGppuiuu0OqxIH9szyTpvKw=
-gitea.com/go-chi/binding v0.0.0-20240316035258-17450c5f3028/go.mod h1:E3i3cgB04dDx0v3CytCgRTTn9Z/9x891aet3r456RVw=
+gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed h1:EZZBtilMLSZNWtHHcgq2mt6NSGhJSZBuduAlinMEmso=
+gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed/go.mod h1:E3i3cgB04dDx0v3CytCgRTTn9Z/9x891aet3r456RVw=
 gitea.com/go-chi/cache v0.2.0 h1:E0npuTfDW6CT1yD8NMDVc1SK6IeRjfmRL2zlEsCEd7w=
 gitea.com/go-chi/cache v0.2.0/go.mod h1:iQlVK2aKTZ/rE9UcHyz9pQWGvdP9i1eI2spOpzgCrtE=
 gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo=

From 9235442ba58524c8d12ae54865d583acfa1f439d Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Thu, 2 May 2024 16:43:23 +0200
Subject: [PATCH 21/47] Remove external API calls in `TestPassword` (#30716)

The test had a dependency on `https://api.pwnedpasswords.com` which
caused many failures on CI recently:

```
--- FAIL: TestPassword (2.37s)
    pwn_test.go:41: Get "https://api.pwnedpasswords.com/range/e6b6a": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
FAIL
coverage: 82.9% of statements
```
---
 go.mod                                |   2 +
 go.sum                                |   6 ++
 modules/auth/password/pwn/pwn_test.go | 101 ++++++--------------------
 3 files changed, 32 insertions(+), 77 deletions(-)

diff --git a/go.mod b/go.mod
index bab5a64069..8afefc6367 100644
--- a/go.mod
+++ b/go.mod
@@ -59,6 +59,7 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/feeds v1.1.2
 	github.com/gorilla/sessions v1.2.2
+	github.com/h2non/gock v1.2.0
 	github.com/hashicorp/go-version v1.6.0
 	github.com/hashicorp/golang-lru/v2 v2.0.7
 	github.com/huandu/xstrings v1.4.0
@@ -209,6 +210,7 @@ require (
 	github.com/gorilla/handlers v1.5.2 // indirect
 	github.com/gorilla/mux v1.8.1 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect
+	github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index 3bb4cbaa42..1d493f4ca4 100644
--- a/go.sum
+++ b/go.sum
@@ -430,6 +430,10 @@ github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pw
 github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
 github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
 github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
+github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
+github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
+github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
+github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
 github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
@@ -591,6 +595,8 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
 github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
 github.com/msteinert/pam v1.2.0 h1:mYfjlvN2KYs2Pb9G6nb/1f/nPfAttT/Jee5Sq9r3bGE=
 github.com/msteinert/pam v1.2.0/go.mod h1:d2n0DCUK8rGecChV3JzvmsDjOY4R7AYbsNxAT+ftQl0=
+github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
+github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
 github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek=
 github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o=
 github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
diff --git a/modules/auth/password/pwn/pwn_test.go b/modules/auth/password/pwn/pwn_test.go
index a2a6b3a174..b3e7734c3f 100644
--- a/modules/auth/password/pwn/pwn_test.go
+++ b/modules/auth/password/pwn/pwn_test.go
@@ -4,12 +4,11 @@
 package pwn
 
 import (
-	"math/rand/v2"
 	"net/http"
-	"strings"
 	"testing"
 	"time"
 
+	"github.com/h2non/gock"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -18,86 +17,34 @@ var client = New(WithHTTP(&http.Client{
 }))
 
 func TestPassword(t *testing.T) {
-	// Check input error
-	_, err := client.CheckPassword("", false)
+	defer gock.Off()
+
+	count, err := client.CheckPassword("", false)
 	assert.ErrorIs(t, err, ErrEmptyPassword, "blank input should return ErrEmptyPassword")
+	assert.Equal(t, -1, count)
 
-	// Should fail
-	fail := "password1234"
-	count, err := client.CheckPassword(fail, false)
-	assert.NotEmpty(t, count, "%s should fail as a password", fail)
+	gock.New("https://api.pwnedpasswords.com").Get("/range/5c1d8").Times(1).Reply(200).BodyString("EAF2F254732680E8AC339B84F3266ECCBB5:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2")
+	count, err = client.CheckPassword("pwned", false)
 	assert.NoError(t, err)
+	assert.Equal(t, 1, count)
 
-	// Should fail (with padding)
-	failPad := "administrator"
-	count, err = client.CheckPassword(failPad, true)
-	assert.NotEmpty(t, count, "%s should fail as a password", failPad)
+	gock.New("https://api.pwnedpasswords.com").Get("/range/ba189").Times(1).Reply(200).BodyString("FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4")
+	count, err = client.CheckPassword("notpwned", false)
 	assert.NoError(t, err)
+	assert.Equal(t, 0, count)
 
-	// Checking for a "good" password isn't going to be perfect, but we can give it a good try
-	// with hopefully minimal error. Try five times?
-	assert.Condition(t, func() bool {
-		for i := 0; i <= 5; i++ {
-			count, err = client.CheckPassword(testPassword(), false)
-			assert.NoError(t, err)
-			if count == 0 {
-				return true
-			}
-		}
-		return false
-	}, "no generated passwords passed. there is a chance this is a fluke")
+	gock.New("https://api.pwnedpasswords.com").Get("/range/a1733").Times(1).Reply(200).BodyString("C4CE0F1F0062B27B9E2F41AF0C08218017C:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2\r\nFE81480327C992FE62065A827429DD1318B:0")
+	count, err = client.CheckPassword("paddedpwned", true)
+	assert.NoError(t, err)
+	assert.Equal(t, 1, count)
 
-	// Again, but with padded responses
-	assert.Condition(t, func() bool {
-		for i := 0; i <= 5; i++ {
-			count, err = client.CheckPassword(testPassword(), true)
-			assert.NoError(t, err)
-			if count == 0 {
-				return true
-			}
-		}
-		return false
-	}, "no generated passwords passed. there is a chance this is a fluke")
-}
-
-// Credit to https://golangbyexample.com/generate-random-password-golang/
-// DO NOT USE THIS FOR AN ACTUAL PASSWORD GENERATOR
-var (
-	lowerCharSet   = "abcdedfghijklmnopqrst"
-	upperCharSet   = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
-	specialCharSet = "!@#$%&*"
-	numberSet      = "0123456789"
-	allCharSet     = lowerCharSet + upperCharSet + specialCharSet + numberSet
-)
-
-func testPassword() string {
-	var password strings.Builder
-
-	// Set special character
-	for i := 0; i < 5; i++ {
-		random := rand.IntN(len(specialCharSet))
-		password.WriteString(string(specialCharSet[random]))
-	}
-
-	// Set numeric
-	for i := 0; i < 5; i++ {
-		random := rand.IntN(len(numberSet))
-		password.WriteString(string(numberSet[random]))
-	}
-
-	// Set uppercase
-	for i := 0; i < 5; i++ {
-		random := rand.IntN(len(upperCharSet))
-		password.WriteString(string(upperCharSet[random]))
-	}
-
-	for i := 0; i < 5; i++ {
-		random := rand.IntN(len(allCharSet))
-		password.WriteString(string(allCharSet[random]))
-	}
-	inRune := []rune(password.String())
-	rand.Shuffle(len(inRune), func(i, j int) {
-		inRune[i], inRune[j] = inRune[j], inRune[i]
-	})
-	return string(inRune)
+	gock.New("https://api.pwnedpasswords.com").Get("/range/5617b").Times(1).Reply(200).BodyString("FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0")
+	count, err = client.CheckPassword("paddednotpwned", true)
+	assert.NoError(t, err)
+	assert.Equal(t, 0, count)
+
+	gock.New("https://api.pwnedpasswords.com").Get("/range/79082").Times(1).Reply(200).BodyString("FDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0\r\nAFEF386F56EB0B4BE314E07696E5E6E6536:0")
+	count, err = client.CheckPassword("paddednotpwnedzero", true)
+	assert.NoError(t, err)
+	assert.Equal(t, 0, count)
 }

From 6f89d5e3a0886d02ead732005f593ae003f78f78 Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Thu, 2 May 2024 16:56:17 +0200
Subject: [PATCH 22/47] Add hover outline to heatmap squares (#30828)

Makes it easier to use because you see which square is currently
hovered:

<img width="314" alt="Screenshot 2024-05-02 at 15 38 20"
src="https://github.com/go-gitea/gitea/assets/115237/3a15dad1-2259-4f28-9fae-5cf6ad3d8798">

I did try a `scoped` style for this, but that did not work for some
reason.
---
 web_src/css/features/heatmap.css | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/web_src/css/features/heatmap.css b/web_src/css/features/heatmap.css
index 364754751a..c064590c46 100644
--- a/web_src/css/features/heatmap.css
+++ b/web_src/css/features/heatmap.css
@@ -31,6 +31,10 @@
   padding: 0 5px;
 }
 
+#user-heatmap .vch__day__square:hover {
+  outline: 1.5px solid var(--color-text);
+}
+
 /* move the "? contributions in the last ? months" text from top to bottom */
 #user-heatmap .total-contributions {
   font-size: 11px;

From 677032d36af9a4052b838e011142d9e0bc706ef5 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Thu, 2 May 2024 23:24:21 +0800
Subject: [PATCH 23/47] Fix incorrect message id for releaes email (#30825)

Make generateMessageIDForRelease outputs the same format as
generateMessageIDForIssue (old `createReference`)
---
 services/mailer/mail.go         | 10 +++++++---
 services/mailer/mail_release.go |  4 ++--
 services/mailer/mail_test.go    | 14 +++++++++++---
 3 files changed, 20 insertions(+), 8 deletions(-)

diff --git a/services/mailer/mail.go b/services/mailer/mail.go
index a63ba7a52a..04194dcf26 100644
--- a/services/mailer/mail.go
+++ b/services/mailer/mail.go
@@ -289,8 +289,8 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
 	}
 
 	// Make sure to compose independent messages to avoid leaking user emails
-	msgID := createReference(ctx.Issue, ctx.Comment, ctx.ActionType)
-	reference := createReference(ctx.Issue, nil, activities_model.ActionType(0))
+	msgID := generateMessageIDForIssue(ctx.Issue, ctx.Comment, ctx.ActionType)
+	reference := generateMessageIDForIssue(ctx.Issue, nil, activities_model.ActionType(0))
 
 	var replyPayload []byte
 	if ctx.Comment != nil {
@@ -362,7 +362,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
 	return msgs, nil
 }
 
-func createReference(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
+func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
 	var path string
 	if issue.IsPull {
 		path = "pulls"
@@ -389,6 +389,10 @@ func createReference(issue *issues_model.Issue, comment *issues_model.Comment, a
 	return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
 }
 
+func generateMessageIDForRelease(release *repo_model.Release) string {
+	return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain)
+}
+
 func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string {
 	repo := ctx.Issue.Repo
 
diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go
index 6682774a04..2aac21e552 100644
--- a/services/mailer/mail_release.go
+++ b/services/mailer/mail_release.go
@@ -86,11 +86,11 @@ func mailNewRelease(ctx context.Context, lang string, tos []string, rel *repo_mo
 
 	msgs := make([]*Message, 0, len(tos))
 	publisherName := rel.Publisher.DisplayName()
-	relURL := "<" + rel.HTMLURL() + ">"
+	msgID := generateMessageIDForRelease(rel)
 	for _, to := range tos {
 		msg := NewMessageFrom(to, publisherName, setting.MailService.FromEmail, subject, mailBody.String())
 		msg.Info = subject
-		msg.SetHeader("Message-ID", relURL)
+		msg.SetHeader("Message-ID", msgID)
 		msgs = append(msgs, msg)
 	}
 
diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go
index d87c57ffe7..0739f4233f 100644
--- a/services/mailer/mail_test.go
+++ b/services/mailer/mail_test.go
@@ -288,7 +288,7 @@ func TestGenerateAdditionalHeaders(t *testing.T) {
 	}
 }
 
-func Test_createReference(t *testing.T) {
+func TestGenerateMessageIDForIssue(t *testing.T) {
 	_, _, issue, comment := prepareMailerTest(t)
 	_, _, pullIssue, _ := prepareMailerTest(t)
 	pullIssue.IsPull = true
@@ -388,10 +388,18 @@ func Test_createReference(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got := createReference(tt.args.issue, tt.args.comment, tt.args.actionType)
+			got := generateMessageIDForIssue(tt.args.issue, tt.args.comment, tt.args.actionType)
 			if !strings.HasPrefix(got, tt.prefix) {
-				t.Errorf("createReference() = %v, want %v", got, tt.prefix)
+				t.Errorf("generateMessageIDForIssue() = %v, want %v", got, tt.prefix)
 			}
 		})
 	}
 }
+
+func TestGenerateMessageIDForRelease(t *testing.T) {
+	msgID := generateMessageIDForRelease(&repo_model.Release{
+		ID:   1,
+		Repo: &repo_model.Repository{OwnerName: "owner", Name: "repo"},
+	})
+	assert.Equal(t, "<owner/repo/releases/1@localhost>", msgID)
+}

From 872caa17c0a30d95f85ab75c068d606e07bd10b3 Mon Sep 17 00:00:00 2001
From: Kemal Zebari <60799661+kemzeb@users.noreply.github.com>
Date: Thu, 2 May 2024 09:33:31 -0700
Subject: [PATCH 24/47] Catch and handle unallowed file type errors in issue
 attachment API (#30791)

Before, we would just throw 500 if a user passes an attachment that is
not an allowed type. This commit catches this error and throws a 422
instead since this should be considered a validation error.
---
 routers/api/v1/repo/issue_attachment.go       |  9 +++++-
 .../api/v1/repo/issue_comment_attachment.go   | 10 ++++++-
 templates/swagger/v1_json.tmpl                |  6 ++++
 .../api_comment_attachment_test.go            | 28 +++++++++++++++++++
 .../integration/api_issue_attachment_test.go  | 27 ++++++++++++++++++
 5 files changed, 78 insertions(+), 2 deletions(-)

diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go
index 7a5c6d554d..f5a28e6fa6 100644
--- a/routers/api/v1/repo/issue_attachment.go
+++ b/routers/api/v1/repo/issue_attachment.go
@@ -14,6 +14,7 @@ import (
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/services/attachment"
 	"code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/context/upload"
 	"code.gitea.io/gitea/services/convert"
 	issue_service "code.gitea.io/gitea/services/issue"
 )
@@ -153,6 +154,8 @@ func CreateIssueAttachment(ctx *context.APIContext) {
 	//     "$ref": "#/responses/error"
 	//   "404":
 	//     "$ref": "#/responses/error"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
 	//   "423":
 	//     "$ref": "#/responses/repoArchivedError"
 
@@ -185,7 +188,11 @@ func CreateIssueAttachment(ctx *context.APIContext) {
 		IssueID:    issue.ID,
 	})
 	if err != nil {
-		ctx.Error(http.StatusInternalServerError, "UploadAttachment", err)
+		if upload.IsErrFileTypeForbidden(err) {
+			ctx.Error(http.StatusUnprocessableEntity, "", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "UploadAttachment", err)
+		}
 		return
 	}
 
diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go
index 4096cbf07b..77aa7f0400 100644
--- a/routers/api/v1/repo/issue_comment_attachment.go
+++ b/routers/api/v1/repo/issue_comment_attachment.go
@@ -16,6 +16,7 @@ import (
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/services/attachment"
 	"code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/context/upload"
 	"code.gitea.io/gitea/services/convert"
 	issue_service "code.gitea.io/gitea/services/issue"
 )
@@ -160,6 +161,8 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) {
 	//     "$ref": "#/responses/forbidden"
 	//   "404":
 	//     "$ref": "#/responses/error"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
 	//   "423":
 	//     "$ref": "#/responses/repoArchivedError"
 
@@ -194,9 +197,14 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) {
 		CommentID:  comment.ID,
 	})
 	if err != nil {
-		ctx.Error(http.StatusInternalServerError, "UploadAttachment", err)
+		if upload.IsErrFileTypeForbidden(err) {
+			ctx.Error(http.StatusUnprocessableEntity, "", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "UploadAttachment", err)
+		}
 		return
 	}
+
 	if err := comment.LoadAttachments(ctx); err != nil {
 		ctx.Error(http.StatusInternalServerError, "LoadAttachments", err)
 		return
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 0c5e5c974d..5ca499e708 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -7478,6 +7478,9 @@
           "404": {
             "$ref": "#/responses/error"
           },
+          "422": {
+            "$ref": "#/responses/validationError"
+          },
           "423": {
             "$ref": "#/responses/repoArchivedError"
           }
@@ -8097,6 +8100,9 @@
           "404": {
             "$ref": "#/responses/error"
           },
+          "422": {
+            "$ref": "#/responses/validationError"
+          },
           "423": {
             "$ref": "#/responses/repoArchivedError"
           }
diff --git a/tests/integration/api_comment_attachment_test.go b/tests/integration/api_comment_attachment_test.go
index 2d7587bbde..0ec950d4c2 100644
--- a/tests/integration/api_comment_attachment_test.go
+++ b/tests/integration/api_comment_attachment_test.go
@@ -120,6 +120,34 @@ func TestAPICreateCommentAttachment(t *testing.T) {
 	unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID})
 }
 
+func TestAPICreateCommentAttachmentWithUnallowedFile(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	session := loginUser(t, repoOwner.Name)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+	filename := "file.bad"
+	body := &bytes.Buffer{}
+
+	// Setup multi-part.
+	writer := multipart.NewWriter(body)
+	_, err := writer.CreateFormFile("attachment", filename)
+	assert.NoError(t, err)
+	err = writer.Close()
+	assert.NoError(t, err)
+
+	req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets", repoOwner.Name, repo.Name, comment.ID), body).
+		AddTokenAuth(token).
+		SetHeader("Content-Type", writer.FormDataContentType())
+
+	session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+}
+
 func TestAPIEditCommentAttachment(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 
diff --git a/tests/integration/api_issue_attachment_test.go b/tests/integration/api_issue_attachment_test.go
index 497dd0155e..b4196ec6db 100644
--- a/tests/integration/api_issue_attachment_test.go
+++ b/tests/integration/api_issue_attachment_test.go
@@ -96,6 +96,33 @@ func TestAPICreateIssueAttachment(t *testing.T) {
 	unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
 }
 
+func TestAPICreateIssueAttachmentWithUnallowedFile(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	session := loginUser(t, repoOwner.Name)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+	filename := "file.bad"
+	body := &bytes.Buffer{}
+
+	// Setup multi-part.
+	writer := multipart.NewWriter(body)
+	_, err := writer.CreateFormFile("attachment", filename)
+	assert.NoError(t, err)
+	err = writer.Close()
+	assert.NoError(t, err)
+
+	req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", repoOwner.Name, repo.Name, issue.Index), body).
+		AddTokenAuth(token)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+
+	session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+}
+
 func TestAPIEditIssueAttachment(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 

From 5c542ca94caa3587329167cfe9e949357ca15cf1 Mon Sep 17 00:00:00 2001
From: Archer <archer@beezig.eu>
Date: Thu, 2 May 2024 19:05:59 +0200
Subject: [PATCH 25/47] Prevent automatic OAuth grants for public clients
 (#30790)

This commit forces the resource owner (user) to always approve OAuth 2.0
authorization requests if the client is public (e.g. native
applications).

As detailed in [RFC 6749 Section 10.2](https://www.rfc-editor.org/rfc/rfc6749.html#section-10.2),

> The authorization server SHOULD NOT process repeated authorization
requests automatically (without active resource owner interaction)
without authenticating the client or relying on other measures to ensure
that the repeated request comes from the original client and not an
impersonator.

With the implementation prior to this patch, attackers with access to
the redirect URI (e.g., the loopback interface for
`git-credential-oauth`) can get access to the user account without any
user interaction if they can redirect the user to the
`/login/oauth/authorize` endpoint somehow (e.g., with `xdg-open` on
Linux).

Fixes #25061.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 routers/web/auth/oauth.go | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go
index c9cb7859cd..354e70bcbf 100644
--- a/routers/web/auth/oauth.go
+++ b/routers/web/auth/oauth.go
@@ -470,8 +470,9 @@ func AuthorizeOAuth(ctx *context.Context) {
 		return
 	}
 
-	// Redirect if user already granted access
-	if grant != nil {
+	// Redirect if user already granted access and the application is confidential.
+	// I.e. always require authorization for public clients as recommended by RFC 6749 Section 10.2
+	if app.ConfidentialClient && grant != nil {
 		code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
 		if err != nil {
 			handleServerError(ctx, form.State, form.RedirectURI)

From e67fbe4f15cdc544f6bec975de6560556724f098 Mon Sep 17 00:00:00 2001
From: Bo-Yi Wu <appleboy.tw@gmail.com>
Date: Fri, 3 May 2024 01:43:29 +0800
Subject: [PATCH 26/47] refactor: merge ListActionTasks func to action.go file
 (#30811)

Just merge actions.go file to action.go

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
---
 routers/api/v1/repo/action.go  | 66 ++++++++++++++++++++++++++++
 routers/api/v1/repo/actions.go | 80 ----------------------------------
 2 files changed, 66 insertions(+), 80 deletions(-)
 delete mode 100644 routers/api/v1/repo/actions.go

diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index 311cfca6e9..f6656d89c6 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -17,6 +17,7 @@ import (
 	"code.gitea.io/gitea/routers/api/v1/utils"
 	actions_service "code.gitea.io/gitea/services/actions"
 	"code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/convert"
 	secret_service "code.gitea.io/gitea/services/secrets"
 )
 
@@ -517,3 +518,68 @@ type Action struct{}
 func NewAction() actions_service.API {
 	return Action{}
 }
+
+// ListActionTasks list all the actions of a repository
+func ListActionTasks(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/actions/tasks repository ListActionTasks
+	// ---
+	// summary: List a repository's action tasks
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results, default maximum page size is 50
+	//   type: integer
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/TasksList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "409":
+	//     "$ref": "#/responses/conflict"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	tasks, total, err := db.FindAndCount[actions_model.ActionTask](ctx, &actions_model.FindTaskOptions{
+		ListOptions: utils.GetListOptions(ctx),
+		RepoID:      ctx.Repo.Repository.ID,
+	})
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "ListActionTasks", err)
+		return
+	}
+
+	res := new(api.ActionTaskResponse)
+	res.TotalCount = total
+
+	res.Entries = make([]*api.ActionTask, len(tasks))
+	for i := range tasks {
+		convertedTask, err := convert.ToActionTask(ctx, tasks[i])
+		if err != nil {
+			ctx.Error(http.StatusInternalServerError, "ToActionTask", err)
+			return
+		}
+		res.Entries[i] = convertedTask
+	}
+
+	ctx.JSON(http.StatusOK, &res)
+}
diff --git a/routers/api/v1/repo/actions.go b/routers/api/v1/repo/actions.go
deleted file mode 100644
index 635cb4e138..0000000000
--- a/routers/api/v1/repo/actions.go
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright 2023 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package repo
-
-import (
-	"net/http"
-
-	actions_model "code.gitea.io/gitea/models/actions"
-	"code.gitea.io/gitea/models/db"
-	api "code.gitea.io/gitea/modules/structs"
-	"code.gitea.io/gitea/routers/api/v1/utils"
-	"code.gitea.io/gitea/services/context"
-	"code.gitea.io/gitea/services/convert"
-)
-
-// ListActionTasks list all the actions of a repository
-func ListActionTasks(ctx *context.APIContext) {
-	// swagger:operation GET /repos/{owner}/{repo}/actions/tasks repository ListActionTasks
-	// ---
-	// summary: List a repository's action tasks
-	// produces:
-	// - application/json
-	// parameters:
-	// - name: owner
-	//   in: path
-	//   description: owner of the repo
-	//   type: string
-	//   required: true
-	// - name: repo
-	//   in: path
-	//   description: name of the repo
-	//   type: string
-	//   required: true
-	// - name: page
-	//   in: query
-	//   description: page number of results to return (1-based)
-	//   type: integer
-	// - name: limit
-	//   in: query
-	//   description: page size of results, default maximum page size is 50
-	//   type: integer
-	// responses:
-	//   "200":
-	//     "$ref": "#/responses/TasksList"
-	//   "400":
-	//     "$ref": "#/responses/error"
-	//   "403":
-	//     "$ref": "#/responses/forbidden"
-	//   "404":
-	//     "$ref": "#/responses/notFound"
-	//   "409":
-	//     "$ref": "#/responses/conflict"
-	//   "422":
-	//     "$ref": "#/responses/validationError"
-
-	tasks, total, err := db.FindAndCount[actions_model.ActionTask](ctx, &actions_model.FindTaskOptions{
-		ListOptions: utils.GetListOptions(ctx),
-		RepoID:      ctx.Repo.Repository.ID,
-	})
-	if err != nil {
-		ctx.Error(http.StatusInternalServerError, "ListActionTasks", err)
-		return
-	}
-
-	res := new(api.ActionTaskResponse)
-	res.TotalCount = total
-
-	res.Entries = make([]*api.ActionTask, len(tasks))
-	for i := range tasks {
-		convertedTask, err := convert.ToActionTask(ctx, tasks[i])
-		if err != nil {
-			ctx.Error(http.StatusInternalServerError, "ToActionTask", err)
-			return
-		}
-		res.Entries[i] = convertedTask
-	}
-
-	ctx.JSON(http.StatusOK, &res)
-}

From c445a85528392a07357b060b19fe29f212cdde25 Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Thu, 2 May 2024 21:10:49 +0200
Subject: [PATCH 27/47] Improve repo button row layout (#30668)

Since there is now a second `<input>` in the repo buttons, we can make a
better-looking layout with no empty space, except on mobile.

Also I fixed one bug with focus border on clone panel.

## Large

<img width="1163" alt="Screenshot 2024-04-23 at 22 25 22"
src="https://github.com/go-gitea/gitea/assets/115237/8135a572-aa67-4672-ad49-b76b06890b52">

## Medium
<img width="870" alt="Screenshot 2024-04-23 at 22 25 34"
src="https://github.com/go-gitea/gitea/assets/115237/9e93f61c-3315-4a78-8328-8cefad5b50fa">

## Mobile
<img width="416" alt="Screenshot 2024-04-23 at 22 25 52"
src="https://github.com/go-gitea/gitea/assets/115237/859e341f-807a-48e6-8bcf-31715963216c">
---
 templates/repo/clone_buttons.tmpl |  2 +-
 templates/repo/home.tmpl          | 12 +++----
 web_src/css/modules/input.css     |  4 +--
 web_src/css/repo.css              | 53 +++++++++++++++++++++++++------
 4 files changed, 52 insertions(+), 19 deletions(-)

diff --git a/templates/repo/clone_buttons.tmpl b/templates/repo/clone_buttons.tmpl
index 89daba9dc9..91952c8a06 100644
--- a/templates/repo/clone_buttons.tmpl
+++ b/templates/repo/clone_buttons.tmpl
@@ -9,7 +9,7 @@
 		SSH
 	</button>
 {{end}}
-<input id="repo-clone-url" size="20" class="js-clone-url" value="{{$.CloneButtonOriginLink.HTTPS}}" readonly>
+<input id="repo-clone-url" size="10" class="js-clone-url" value="{{$.CloneButtonOriginLink.HTTPS}}" readonly>
 <button class="ui small icon button" id="clipboard-btn" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}" data-clipboard-target="#repo-clone-url" aria-label="{{ctx.Locale.Tr "copy_url"}}">
 	{{svg "octicon-copy" 14}}
 </button>
diff --git a/templates/repo/home.tmpl b/templates/repo/home.tmpl
index eb9eb9c149..6df9f7d72a 100644
--- a/templates/repo/home.tmpl
+++ b/templates/repo/home.tmpl
@@ -46,7 +46,7 @@
 		{{$l := Eval $n "-" 1}}
 		{{$isHomepage := (eq $n 0)}}
 		<div class="repo-button-row">
-			<div class="tw-flex tw-items-center tw-flex-wrap tw-gap-y-2">
+			<div class="repo-button-row-left">
 				{{template "repo/branch_dropdown" dict "root" . "ContainerClasses" "tw-mr-1"}}
 				{{if and .CanCompareOrPull .IsViewBranch (not .Repository.IsArchived)}}
 					{{$cmpBranch := ""}}
@@ -66,7 +66,7 @@
 				{{end}}
 
 				{{if and .CanWriteCode .IsViewBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}}
-					<button class="ui dropdown basic compact jump button tw-mr-1"{{if not .Repository.CanEnableEditor}} disabled{{end}}>
+					<button class="ui dropdown basic compact jump button"{{if not .Repository.CanEnableEditor}} disabled{{end}}>
 						{{ctx.Locale.Tr "repo.editor.add_file"}}
 						{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 						<div class="menu">
@@ -93,9 +93,9 @@
 				{{if $isHomepage}}
 					{{/* only show the "code search" on the repo home page, it only does global search,
 						so do not show it when viewing file or directory to avoid misleading users (it doesn't search in a directory) */}}
-					<form class="ignore-dirty" action="{{.RepoLink}}/search" method="get">
-						<div class="ui small action input">
-							<input name="q" placeholder="{{ctx.Locale.Tr "search.code_kind"}}">
+					<form class="ignore-dirty tw-flex tw-flex-1" action="{{.RepoLink}}/search" method="get">
+						<div class="ui small action input tw-flex-1">
+							<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}">
 							{{template "shared/search/button"}}
 						</div>
 					</form>
@@ -113,7 +113,7 @@
 					</span>
 				{{end}}
 			</div>
-			<div class="tw-flex tw-items-center">
+			<div class="repo-button-row-right">
 				<!-- Only show clone panel in repository home page -->
 				{{if $isHomepage}}
 					<div class="clone-panel ui action tiny input">
diff --git a/web_src/css/modules/input.css b/web_src/css/modules/input.css
index 18b785ac82..d39377b4e1 100644
--- a/web_src/css/modules/input.css
+++ b/web_src/css/modules/input.css
@@ -188,8 +188,8 @@
 .ui.action.input:not([class*="left action"]) > input:focus + .ui.dropdown.selection:hover,
 .ui.action.input:not([class*="left action"]) > input:focus + .button,
 .ui.action.input:not([class*="left action"]) > input:focus + .button:hover,
-.ui.action.input:not([class*="left action"]) > input:focus + .icon + .button,
-.ui.action.input:not([class*="left action"]) > input:focus + .icon + .button:hover {
+.ui.action.input:not([class*="left action"]) > input:focus + i.icon + .button,
+.ui.action.input:not([class*="left action"]) > input:focus + i.icon + .button:hover {
   border-left-color: var(--color-primary);
 }
 .ui.action.input:not([class*="left action"]) > input:focus {
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index a930e130f8..408b62ad3c 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -128,15 +128,22 @@
   margin-bottom: 12px;
 }
 
-.repository .clone-panel #repo-clone-url {
-  width: 320px;
-  border-radius: 0;
+.repository .clone-panel {
+  display: flex;
+  flex: 1;
 }
 
-@media (max-width: 991.98px) {
-  .repository .clone-panel #repo-clone-url {
-    width: 200px;
-  }
+.repository.wiki .clone-panel {
+  flex: 0;
+}
+
+.repository.wiki .clone-panel input {
+  width: 20ch;
+}
+
+.repository .clone-panel #repo-clone-url {
+  border-radius: 0;
+  flex: 1;
 }
 
 .repository .ui.action.input.clone-panel > button + button,
@@ -2229,17 +2236,37 @@ td .commit-summary {
 }
 
 .repo-button-row {
-  margin: 10px 0;
+  margin: 8px 0;
   display: flex;
   align-items: center;
-  gap: 0.5em;
-  flex-wrap: wrap;
+  gap: 8px;
   justify-content: space-between;
 }
 
+.repo-button-row-left,
+.repo-button-row-right {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.repo-button-row-right {
+  justify-content: flex-end;
+}
+
+@media (max-width: 991px) {
+  .repository:not(.wiki) .repo-button-row {
+    flex-direction: column;
+    align-items: stretch;
+  }
+}
+
 .repo-button-row .button {
   padding: 6px 10px !important;
   height: 30px;
+  flex-shrink: 0;
+  margin: 0;
 }
 
 .repo-button-row .button.dropdown:not(.icon) {
@@ -2250,6 +2277,12 @@ td .commit-summary {
   height: 30px;
 }
 
+@media (max-width: 600px) {
+  .repo-button-row-left {
+    flex-wrap: wrap;
+  }
+}
+
 tbody.commit-list {
   vertical-align: baseline;
 }

From b30b7df9f4b4e1ae7ec55750bca7577bf88abd0b Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Fri, 3 May 2024 03:48:24 +0200
Subject: [PATCH 28/47] Fix body margin shifting with modals, fix error on
 project column edit (#30831)

Fixes: https://github.com/go-gitea/gitea/issues/30816, regression from
https://github.com/go-gitea/gitea/pull/30723.
Fixes: https://github.com/go-gitea/gitea/pull/30815, regression from
https://github.com/go-gitea/gitea/pull/30723.

Fomantic [expects a
callback](https://github.com/fomantic/Fomantic-UI/blob/59d9b409879ad9413ea0a3efa4ab2e51017ad9b9/src/definitions/modules/modal.js#L530-L534)
to be called during `hide` which we did not do, so it could never remove
the margin it added to `body`.

I do observe the body content shifting to right by 1px when modal opens,
but this is a bug that existed on v1.21 as well, so not a regression.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 web_src/js/modules/fomantic/dimmer.js | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/web_src/js/modules/fomantic/dimmer.js b/web_src/js/modules/fomantic/dimmer.js
index f434e1ca59..e027838d4a 100644
--- a/web_src/js/modules/fomantic/dimmer.js
+++ b/web_src/js/modules/fomantic/dimmer.js
@@ -3,11 +3,12 @@ import {queryElemChildren} from '../../utils/dom.js';
 
 export function initFomanticDimmer() {
   // stand-in for removed dimmer module
-  $.fn.dimmer = function (arg0, $el) {
+  $.fn.dimmer = function (arg0, arg1) {
     if (arg0 === 'add content') {
+      const $el = arg1;
       const existingDimmer = document.querySelector('body > .ui.dimmer');
       if (existingDimmer) {
-        queryElemChildren(existingDimmer, '*', (el) => el.remove());
+        queryElemChildren(existingDimmer, '*', (el) => el.classList.add('hidden'));
         this._dimmer = existingDimmer;
       } else {
         this._dimmer = document.createElement('div');
@@ -21,8 +22,10 @@ export function initFomanticDimmer() {
       this._dimmer.classList.add('active');
       document.body.classList.add('tw-overflow-hidden');
     } else if (arg0 === 'hide') {
+      const cb = arg1;
       this._dimmer.classList.remove('active');
       document.body.classList.remove('tw-overflow-hidden');
+      cb();
     }
     return this;
   };

From c4e875402bd8e787c21bbd4ea07cbb69a8ceef27 Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Fri, 3 May 2024 04:12:10 +0200
Subject: [PATCH 29/47] Fix JS error on pull request page (#30838)

Fix this error seen on PR page, regression from
https://github.com/go-gitea/gitea/pull/30803:

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 web_src/js/features/repo-legacy.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js
index 670e60def0..b65938b045 100644
--- a/web_src/js/features/repo-legacy.js
+++ b/web_src/js/features/repo-legacy.js
@@ -57,6 +57,7 @@ export function initRepoCommentForm() {
 
   function initBranchSelector() {
     const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
+    if (!elSelectBranch) return;
     const isForNewIssue = elSelectBranch.getAttribute('data-for-new-issue') === 'true';
 
     const $selectBranch = $(elSelectBranch);

From 53b55223d167c3fc996dd0278a656f421408ace7 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Fri, 3 May 2024 10:39:36 +0800
Subject: [PATCH 30/47] Ignore useless error message "broken pipe" (#30801)

Fix #30792
---
 routers/api/packages/maven/maven.go  | 4 +---
 services/context/base.go             | 4 +---
 services/context/context_response.go | 3 ++-
 3 files changed, 4 insertions(+), 7 deletions(-)

diff --git a/routers/api/packages/maven/maven.go b/routers/api/packages/maven/maven.go
index 27f0578db7..cb15eae682 100644
--- a/routers/api/packages/maven/maven.go
+++ b/routers/api/packages/maven/maven.go
@@ -140,9 +140,7 @@ func serveMavenMetadata(ctx *context.Context, params parameters) {
 	ctx.Resp.Header().Set("Content-Length", strconv.Itoa(len(xmlMetadataWithHeader)))
 	ctx.Resp.Header().Set("Content-Type", contentTypeXML)
 
-	if _, err := ctx.Resp.Write(xmlMetadataWithHeader); err != nil {
-		log.Error("write bytes failed: %v", err)
-	}
+	_, _ = ctx.Resp.Write(xmlMetadataWithHeader)
 }
 
 func servePackageFile(ctx *context.Context, params parameters, serveContent bool) {
diff --git a/services/context/base.go b/services/context/base.go
index 62fb743714..05b8ab1b9b 100644
--- a/services/context/base.go
+++ b/services/context/base.go
@@ -234,9 +234,7 @@ func (b *Base) plainTextInternal(skip, status int, bs []byte) {
 	b.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8")
 	b.Resp.Header().Set("X-Content-Type-Options", "nosniff")
 	b.Resp.WriteHeader(status)
-	if _, err := b.Resp.Write(bs); err != nil {
-		log.ErrorWithSkip(skip, "plainTextInternal (status=%d): write bytes failed: %v", status, err)
-	}
+	_, _ = b.Resp.Write(bs)
 }
 
 // PlainTextBytes renders bytes as plain text
diff --git a/services/context/context_response.go b/services/context/context_response.go
index d7fd18acac..87c34c35ed 100644
--- a/services/context/context_response.go
+++ b/services/context/context_response.go
@@ -13,6 +13,7 @@ import (
 	"path"
 	"strconv"
 	"strings"
+	"syscall"
 	"time"
 
 	user_model "code.gitea.io/gitea/models/user"
@@ -77,7 +78,7 @@ func (ctx *Context) HTML(status int, name base.TplName) {
 	}
 
 	err := ctx.Render.HTML(ctx.Resp, status, string(name), ctx.Data, ctx.TemplateContext)
-	if err == nil {
+	if err == nil || errors.Is(err, syscall.EPIPE) {
 		return
 	}
 

From a50026e2f30897904704895362da0fb12c7e5b26 Mon Sep 17 00:00:00 2001
From: yp05327 <576951401@qq.com>
Date: Fri, 3 May 2024 15:11:51 +0900
Subject: [PATCH 31/47] Fix no edit history after editing issue's title and
 content (#30814)

Fix #30807

reuse functions in services
---
 models/issues/issue_update.go       | 56 -----------------------------
 modules/structs/pull.go             |  2 +-
 routers/api/v1/repo/issue.go        | 36 +++++++++----------
 routers/api/v1/repo/pull.go         | 37 +++++++++----------
 tests/integration/api_issue_test.go |  4 +++
 tests/integration/api_pull_test.go  | 26 +++++++++-----
 6 files changed, 56 insertions(+), 105 deletions(-)

diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go
index ef96e1ee50..147b7eb3b9 100644
--- a/models/issues/issue_update.go
+++ b/models/issues/issue_update.go
@@ -429,62 +429,6 @@ func UpdateIssueMentions(ctx context.Context, issueID int64, mentions []*user_mo
 	return nil
 }
 
-// UpdateIssueByAPI updates all allowed fields of given issue.
-// If the issue status is changed a statusChangeComment is returned
-// similarly if the title is changed the titleChanged bool is set to true
-func UpdateIssueByAPI(ctx context.Context, issue *Issue, doer *user_model.User) (statusChangeComment *Comment, titleChanged bool, err error) {
-	ctx, committer, err := db.TxContext(ctx)
-	if err != nil {
-		return nil, false, err
-	}
-	defer committer.Close()
-
-	if err := issue.LoadRepo(ctx); err != nil {
-		return nil, false, fmt.Errorf("loadRepo: %w", err)
-	}
-
-	// Reload the issue
-	currentIssue, err := GetIssueByID(ctx, issue.ID)
-	if err != nil {
-		return nil, false, err
-	}
-
-	if _, err := db.GetEngine(ctx).ID(issue.ID).Cols(
-		"name", "content", "milestone_id", "priority",
-		"deadline_unix", "updated_unix", "is_locked").
-		Update(issue); err != nil {
-		return nil, false, err
-	}
-
-	titleChanged = currentIssue.Title != issue.Title
-	if titleChanged {
-		opts := &CreateCommentOptions{
-			Type:     CommentTypeChangeTitle,
-			Doer:     doer,
-			Repo:     issue.Repo,
-			Issue:    issue,
-			OldTitle: currentIssue.Title,
-			NewTitle: issue.Title,
-		}
-		_, err := CreateComment(ctx, opts)
-		if err != nil {
-			return nil, false, fmt.Errorf("createComment: %w", err)
-		}
-	}
-
-	if currentIssue.IsClosed != issue.IsClosed {
-		statusChangeComment, err = doChangeIssueStatus(ctx, issue, doer, false)
-		if err != nil {
-			return nil, false, err
-		}
-	}
-
-	if err := issue.AddCrossReferences(ctx, doer, true); err != nil {
-		return nil, false, err
-	}
-	return statusChangeComment, titleChanged, committer.Commit()
-}
-
 // UpdateIssueDeadline updates an issue deadline and adds comments. Setting a deadline to 0 means deleting it.
 func UpdateIssueDeadline(ctx context.Context, issue *Issue, deadlineUnix timeutil.TimeStamp, doer *user_model.User) (err error) {
 	// if the deadline hasn't changed do nothing
diff --git a/modules/structs/pull.go b/modules/structs/pull.go
index 05a8d59633..b04def52b8 100644
--- a/modules/structs/pull.go
+++ b/modules/structs/pull.go
@@ -85,7 +85,7 @@ type CreatePullRequestOption struct {
 // EditPullRequestOption options when modify pull request
 type EditPullRequestOption struct {
 	Title     string   `json:"title"`
-	Body      string   `json:"body"`
+	Body      *string  `json:"body"`
 	Base      string   `json:"base"`
 	Assignee  string   `json:"assignee"`
 	Assignees []string `json:"assignees"`
diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go
index dfe6d31f74..b91fbc33bf 100644
--- a/routers/api/v1/repo/issue.go
+++ b/routers/api/v1/repo/issue.go
@@ -29,7 +29,6 @@ import (
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/convert"
 	issue_service "code.gitea.io/gitea/services/issue"
-	notify_service "code.gitea.io/gitea/services/notify"
 )
 
 // SearchIssues searches for issues across the repositories that the user has access to
@@ -803,12 +802,19 @@ func EditIssue(ctx *context.APIContext) {
 		return
 	}
 
-	oldTitle := issue.Title
 	if len(form.Title) > 0 {
-		issue.Title = form.Title
+		err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title)
+		if err != nil {
+			ctx.Error(http.StatusInternalServerError, "ChangeTitle", err)
+			return
+		}
 	}
 	if form.Body != nil {
-		issue.Content = *form.Body
+		err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body)
+		if err != nil {
+			ctx.Error(http.StatusInternalServerError, "ChangeContent", err)
+			return
+		}
 	}
 	if form.Ref != nil {
 		err = issue_service.ChangeIssueRef(ctx, issue, ctx.Doer, *form.Ref)
@@ -880,24 +886,14 @@ func EditIssue(ctx *context.APIContext) {
 				return
 			}
 		}
-		issue.IsClosed = api.StateClosed == api.StateType(*form.State)
-	}
-	statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(ctx, issue, ctx.Doer)
-	if err != nil {
-		if issues_model.IsErrDependenciesLeft(err) {
-			ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies")
+		if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", api.StateClosed == api.StateType(*form.State)); err != nil {
+			if issues_model.IsErrDependenciesLeft(err) {
+				ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies")
+				return
+			}
+			ctx.Error(http.StatusInternalServerError, "ChangeStatus", err)
 			return
 		}
-		ctx.Error(http.StatusInternalServerError, "UpdateIssueByAPI", err)
-		return
-	}
-
-	if titleChanged {
-		notify_service.IssueChangeTitle(ctx, ctx.Doer, issue, oldTitle)
-	}
-
-	if statusChangeComment != nil {
-		notify_service.IssueChangeStatus(ctx, ctx.Doer, "", issue, statusChangeComment, issue.IsClosed)
 	}
 
 	// Refetch from database to assign some automatic values
diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go
index 4129f94ac3..8bd4ddf64b 100644
--- a/routers/api/v1/repo/pull.go
+++ b/routers/api/v1/repo/pull.go
@@ -602,12 +602,19 @@ func EditPullRequest(ctx *context.APIContext) {
 		return
 	}
 
-	oldTitle := issue.Title
 	if len(form.Title) > 0 {
-		issue.Title = form.Title
+		err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title)
+		if err != nil {
+			ctx.Error(http.StatusInternalServerError, "ChangeTitle", err)
+			return
+		}
 	}
-	if len(form.Body) > 0 {
-		issue.Content = form.Body
+	if form.Body != nil {
+		err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body)
+		if err != nil {
+			ctx.Error(http.StatusInternalServerError, "ChangeContent", err)
+			return
+		}
 	}
 
 	// Update or remove deadline if set
@@ -686,24 +693,14 @@ func EditPullRequest(ctx *context.APIContext) {
 			ctx.Error(http.StatusPreconditionFailed, "MergedPRState", "cannot change state of this pull request, it was already merged")
 			return
 		}
-		issue.IsClosed = api.StateClosed == api.StateType(*form.State)
-	}
-	statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(ctx, issue, ctx.Doer)
-	if err != nil {
-		if issues_model.IsErrDependenciesLeft(err) {
-			ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this pull request because it still has open dependencies")
+		if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", api.StateClosed == api.StateType(*form.State)); err != nil {
+			if issues_model.IsErrDependenciesLeft(err) {
+				ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this pull request because it still has open dependencies")
+				return
+			}
+			ctx.Error(http.StatusInternalServerError, "ChangeStatus", err)
 			return
 		}
-		ctx.Error(http.StatusInternalServerError, "UpdateIssueByAPI", err)
-		return
-	}
-
-	if titleChanged {
-		notify_service.IssueChangeTitle(ctx, ctx.Doer, issue, oldTitle)
-	}
-
-	if statusChangeComment != nil {
-		notify_service.IssueChangeStatus(ctx, ctx.Doer, "", issue, statusChangeComment, issue.IsClosed)
 	}
 
 	// change pull target branch
diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go
index 17b4e5bd71..8bfb6fabe2 100644
--- a/tests/integration/api_issue_test.go
+++ b/tests/integration/api_issue_test.go
@@ -194,6 +194,10 @@ func TestAPIEditIssue(t *testing.T) {
 	issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10})
 	repoAfter := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
 
+	// check comment history
+	unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: issueAfter.ID, OldTitle: issueBefore.Title, NewTitle: title})
+	unittest.AssertExistsAndLoadBean(t, &issues_model.ContentHistory{IssueID: issueAfter.ID, ContentText: body, IsFirstCreated: false})
+
 	// check deleted user
 	assert.Equal(t, int64(500), issueAfter.PosterID)
 	assert.NoError(t, issueAfter.LoadAttributes(db.DefaultContext))
diff --git a/tests/integration/api_pull_test.go b/tests/integration/api_pull_test.go
index bb479caf89..9bf0d3d745 100644
--- a/tests/integration/api_pull_test.go
+++ b/tests/integration/api_pull_test.go
@@ -223,23 +223,33 @@ func TestAPIEditPull(t *testing.T) {
 
 	session := loginUser(t, owner10.Name)
 	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+	title := "create a success pr"
 	req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner10.Name, repo10.Name), &api.CreatePullRequestOption{
 		Head:  "develop",
 		Base:  "master",
-		Title: "create a success pr",
+		Title: title,
 	}).AddTokenAuth(token)
-	pull := new(api.PullRequest)
+	apiPull := new(api.PullRequest)
 	resp := MakeRequest(t, req, http.StatusCreated)
-	DecodeJSON(t, resp, pull)
-	assert.EqualValues(t, "master", pull.Base.Name)
+	DecodeJSON(t, resp, apiPull)
+	assert.EqualValues(t, "master", apiPull.Base.Name)
 
-	req = NewRequestWithJSON(t, http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner10.Name, repo10.Name, pull.Index), &api.EditPullRequestOption{
+	newTitle := "edit a this pr"
+	newBody := "edited body"
+	req = NewRequestWithJSON(t, http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner10.Name, repo10.Name, apiPull.Index), &api.EditPullRequestOption{
 		Base:  "feature/1",
-		Title: "edit a this pr",
+		Title: newTitle,
+		Body:  &newBody,
 	}).AddTokenAuth(token)
 	resp = MakeRequest(t, req, http.StatusCreated)
-	DecodeJSON(t, resp, pull)
-	assert.EqualValues(t, "feature/1", pull.Base.Name)
+	DecodeJSON(t, resp, apiPull)
+	assert.EqualValues(t, "feature/1", apiPull.Base.Name)
+	// check comment history
+	pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID})
+	err := pull.LoadIssue(db.DefaultContext)
+	assert.NoError(t, err)
+	unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: pull.Issue.ID, OldTitle: title, NewTitle: newTitle})
+	unittest.AssertExistsAndLoadBean(t, &issues_model.ContentHistory{IssueID: pull.Issue.ID, ContentText: newBody, IsFirstCreated: false})
 
 	req = NewRequestWithJSON(t, http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner10.Name, repo10.Name, pull.Index), &api.EditPullRequestOption{
 		Base: "not-exist",

From 9f0ef3621a3b63ccbe93f302a446b67dc54ad725 Mon Sep 17 00:00:00 2001
From: Kemal Zebari <60799661+kemzeb@users.noreply.github.com>
Date: Fri, 3 May 2024 00:58:31 -0700
Subject: [PATCH 32/47] Don't only list code-enabled repositories when using
 repository API (#30817)

We should be listing all repositories by default.

Fixes #28483.
---
 routers/api/v1/user/repo.go        |  4 +---
 tests/integration/api_repo_test.go | 34 ++++++++++++++++++++++++++++++
 2 files changed, 35 insertions(+), 3 deletions(-)

diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go
index 81f8e0f3fe..d0264d6b5a 100644
--- a/routers/api/v1/user/repo.go
+++ b/routers/api/v1/user/repo.go
@@ -6,10 +6,8 @@ package user
 import (
 	"net/http"
 
-	"code.gitea.io/gitea/models/perm"
 	access_model "code.gitea.io/gitea/models/perm/access"
 	repo_model "code.gitea.io/gitea/models/repo"
-	unit_model "code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/routers/api/v1/utils"
@@ -44,7 +42,7 @@ func listUserRepos(ctx *context.APIContext, u *user_model.User, private bool) {
 			ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err)
 			return
 		}
-		if ctx.IsSigned && ctx.Doer.IsAdmin || permission.UnitAccessMode(unit_model.TypeCode) >= perm.AccessModeRead {
+		if ctx.IsSigned && ctx.Doer.IsAdmin || permission.HasAnyUnitAccess() {
 			apiRepos = append(apiRepos, convert.ToRepo(ctx, repos[i], permission))
 		}
 	}
diff --git a/tests/integration/api_repo_test.go b/tests/integration/api_repo_test.go
index f33827e58b..716da762e5 100644
--- a/tests/integration/api_repo_test.go
+++ b/tests/integration/api_repo_test.go
@@ -13,6 +13,7 @@ import (
 	"code.gitea.io/gitea/models/db"
 	access_model "code.gitea.io/gitea/models/perm/access"
 	repo_model "code.gitea.io/gitea/models/repo"
+	unit_model "code.gitea.io/gitea/models/unit"
 	"code.gitea.io/gitea/models/unittest"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/setting"
@@ -326,6 +327,39 @@ func TestAPIOrgRepos(t *testing.T) {
 	}
 }
 
+// See issue #28483. Tests to make sure we consider more than just code unit-enabled repositories.
+func TestAPIOrgReposWithCodeUnitDisabled(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	repo21 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo21"})
+	org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo21.OwnerID})
+
+	// Disable code repository unit.
+	var units []unit_model.Type
+	units = append(units, unit_model.TypeCode)
+
+	if err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo21, nil, units); err != nil {
+		assert.Fail(t, "should have been able to delete code repository unit; failed to %v", err)
+	}
+	assert.False(t, repo21.UnitEnabled(db.DefaultContext, unit_model.TypeCode))
+
+	session := loginUser(t, "user2")
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
+
+	req := NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", org3.Name).
+		AddTokenAuth(token)
+
+	resp := MakeRequest(t, req, http.StatusOK)
+	var apiRepos []*api.Repository
+	DecodeJSON(t, resp, &apiRepos)
+
+	var repoNames []string
+	for _, r := range apiRepos {
+		repoNames = append(repoNames, r.Name)
+	}
+
+	assert.Contains(t, repoNames, repo21.Name)
+}
+
 func TestAPIGetRepoByIDUnauthorized(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})

From 0f3e717a1abb2b2161b87dac557beb6475224a2e Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Fri, 3 May 2024 17:13:48 +0800
Subject: [PATCH 33/47] Improve grep search (#30843)

Reduce the context line number to 1, make "git grep" search respect the
include/exclude patter, and fix #30785
---
 modules/git/grep.go             |  2 ++
 modules/git/grep_test.go        | 20 ++++++++++++++++++++
 modules/setting/glob.go         | 32 ++++++++++++++++++++++++++++++++
 modules/setting/indexer.go      | 12 +++++-------
 routers/web/repo/search.go      | 18 +++++++++++++++++-
 routers/web/repo/search_test.go | 19 +++++++++++++++++++
 6 files changed, 95 insertions(+), 8 deletions(-)
 create mode 100644 modules/setting/glob.go
 create mode 100644 routers/web/repo/search_test.go

diff --git a/modules/git/grep.go b/modules/git/grep.go
index e7d238e586..bf6b41a886 100644
--- a/modules/git/grep.go
+++ b/modules/git/grep.go
@@ -29,6 +29,7 @@ type GrepOptions struct {
 	ContextLineNumber int
 	IsFuzzy           bool
 	MaxLineLength     int // the maximum length of a line to parse, exceeding chars will be truncated
+	PathspecList      []string
 }
 
 func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) {
@@ -62,6 +63,7 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO
 		cmd.AddOptionValues("-e", strings.TrimLeft(search, "-"))
 	}
 	cmd.AddDynamicArguments(util.IfZero(opts.RefName, "HEAD"))
+	cmd.AddDashesAndList(opts.PathspecList...)
 	opts.MaxResultLimit = util.IfZero(opts.MaxResultLimit, 50)
 	stderr := bytes.Buffer{}
 	err = cmd.Run(&RunOpts{
diff --git a/modules/git/grep_test.go b/modules/git/grep_test.go
index 7f4ded478f..6a99f80407 100644
--- a/modules/git/grep_test.go
+++ b/modules/git/grep_test.go
@@ -31,6 +31,26 @@ func TestGrepSearch(t *testing.T) {
 		},
 	}, res)
 
+	res, err = GrepSearch(context.Background(), repo, "void", GrepOptions{PathspecList: []string{":(glob)java-hello/*"}})
+	assert.NoError(t, err)
+	assert.Equal(t, []*GrepResult{
+		{
+			Filename:    "java-hello/main.java",
+			LineNumbers: []int{3},
+			LineCodes:   []string{" public static void main(String[] args)"},
+		},
+	}, res)
+
+	res, err = GrepSearch(context.Background(), repo, "void", GrepOptions{PathspecList: []string{":(glob,exclude)java-hello/*"}})
+	assert.NoError(t, err)
+	assert.Equal(t, []*GrepResult{
+		{
+			Filename:    "main.vendor.java",
+			LineNumbers: []int{3},
+			LineCodes:   []string{" public static void main(String[] args)"},
+		},
+	}, res)
+
 	res, err = GrepSearch(context.Background(), repo, "void", GrepOptions{MaxResultLimit: 1})
 	assert.NoError(t, err)
 	assert.Equal(t, []*GrepResult{
diff --git a/modules/setting/glob.go b/modules/setting/glob.go
new file mode 100644
index 0000000000..8f1d24dea4
--- /dev/null
+++ b/modules/setting/glob.go
@@ -0,0 +1,32 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import "github.com/gobwas/glob"
+
+type GlobMatcher struct {
+	compiledGlob  glob.Glob
+	patternString string
+}
+
+var _ glob.Glob = (*GlobMatcher)(nil)
+
+func (g *GlobMatcher) Match(s string) bool {
+	return g.compiledGlob.Match(s)
+}
+
+func (g *GlobMatcher) PatternString() string {
+	return g.patternString
+}
+
+func GlobMatcherCompile(pattern string, separators ...rune) (*GlobMatcher, error) {
+	g, err := glob.Compile(pattern, separators...)
+	if err != nil {
+		return nil, err
+	}
+	return &GlobMatcher{
+		compiledGlob:  g,
+		patternString: pattern,
+	}, nil
+}
diff --git a/modules/setting/indexer.go b/modules/setting/indexer.go
index 6877d70e3c..18585602c3 100644
--- a/modules/setting/indexer.go
+++ b/modules/setting/indexer.go
@@ -10,8 +10,6 @@ import (
 	"time"
 
 	"code.gitea.io/gitea/modules/log"
-
-	"github.com/gobwas/glob"
 )
 
 // Indexer settings
@@ -30,8 +28,8 @@ var Indexer = struct {
 	RepoConnStr          string
 	RepoIndexerName      string
 	MaxIndexerFileSize   int64
-	IncludePatterns      []glob.Glob
-	ExcludePatterns      []glob.Glob
+	IncludePatterns      []*GlobMatcher
+	ExcludePatterns      []*GlobMatcher
 	ExcludeVendored      bool
 }{
 	IssueType:        "bleve",
@@ -93,12 +91,12 @@ func loadIndexerFrom(rootCfg ConfigProvider) {
 }
 
 // IndexerGlobFromString parses a comma separated list of patterns and returns a glob.Glob slice suited for repo indexing
-func IndexerGlobFromString(globstr string) []glob.Glob {
-	extarr := make([]glob.Glob, 0, 10)
+func IndexerGlobFromString(globstr string) []*GlobMatcher {
+	extarr := make([]*GlobMatcher, 0, 10)
 	for _, expr := range strings.Split(strings.ToLower(globstr), ",") {
 		expr = strings.TrimSpace(expr)
 		if expr != "" {
-			if g, err := glob.Compile(expr, '.', '/'); err != nil {
+			if g, err := GlobMatcherCompile(expr, '.', '/'); err != nil {
 				log.Info("Invalid glob expression '%s' (skipped): %v", expr, err)
 			} else {
 				extarr = append(extarr, g)
diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go
index d7854b2499..920a865555 100644
--- a/routers/web/repo/search.go
+++ b/routers/web/repo/search.go
@@ -17,6 +17,16 @@ import (
 
 const tplSearch base.TplName = "repo/search"
 
+func indexSettingToGitGrepPathspecList() (list []string) {
+	for _, expr := range setting.Indexer.IncludePatterns {
+		list = append(list, ":(glob)"+expr.PatternString())
+	}
+	for _, expr := range setting.Indexer.ExcludePatterns {
+		list = append(list, ":(glob,exclude)"+expr.PatternString())
+	}
+	return list
+}
+
 // Search render repository search page
 func Search(ctx *context.Context) {
 	language := ctx.FormTrim("l")
@@ -65,8 +75,14 @@ func Search(ctx *context.Context) {
 			ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx)
 		}
 	} else {
-		res, err := git.GrepSearch(ctx, ctx.Repo.GitRepo, keyword, git.GrepOptions{ContextLineNumber: 3, IsFuzzy: isFuzzy})
+		res, err := git.GrepSearch(ctx, ctx.Repo.GitRepo, keyword, git.GrepOptions{
+			ContextLineNumber: 1,
+			IsFuzzy:           isFuzzy,
+			RefName:           git.RefNameFromBranch(ctx.Repo.BranchName).String(), // BranchName should be default branch or the first existing branch
+			PathspecList:      indexSettingToGitGrepPathspecList(),
+		})
 		if err != nil {
+			// TODO: if no branch exists, it reports: exit status 128, fatal: this operation must be run in a work tree.
 			ctx.ServerError("GrepSearch", err)
 			return
 		}
diff --git a/routers/web/repo/search_test.go b/routers/web/repo/search_test.go
new file mode 100644
index 0000000000..33a1610384
--- /dev/null
+++ b/routers/web/repo/search_test.go
@@ -0,0 +1,19 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/test"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIndexSettingToGitGrepPathspecList(t *testing.T) {
+	defer test.MockVariableValue(&setting.Indexer.IncludePatterns, setting.IndexerGlobFromString("a"))()
+	defer test.MockVariableValue(&setting.Indexer.ExcludePatterns, setting.IndexerGlobFromString("b"))()
+	assert.Equal(t, []string{":(glob)a", ":(glob,exclude)b"}, indexSettingToGitGrepPathspecList())
+}

From c7bb3aa03436314e20d43e0ae44e791dd3e64909 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Sat, 4 May 2024 09:48:16 +0800
Subject: [PATCH 34/47] Fix markdown URL parsing for commit ID (#30812)

---
 modules/markup/html.go               | 122 +++++++++++++++------------
 modules/markup/html_codepreview.go   |   3 +-
 modules/markup/html_internal_test.go |  59 +++++++++----
 modules/markup/html_test.go          |   7 +-
 4 files changed, 116 insertions(+), 75 deletions(-)

diff --git a/modules/markup/html.go b/modules/markup/html.go
index 5ae0cc8755..2958dc9646 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -10,6 +10,7 @@ import (
 	"path"
 	"path/filepath"
 	"regexp"
+	"slices"
 	"strings"
 	"sync"
 
@@ -54,7 +55,7 @@ var (
 	shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`)
 
 	// anyHashPattern splits url containing SHA into parts
-	anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})(/[-+~_%.a-zA-Z0-9/]+)?(#[-+~_%.a-zA-Z0-9]+)?`)
+	anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`)
 
 	// comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash"
 	comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`)
@@ -591,7 +592,8 @@ func replaceContentList(node *html.Node, i, j int, newNodes []*html.Node) {
 
 func mentionProcessor(ctx *RenderContext, node *html.Node) {
 	start := 0
-	for node != nil {
+	nodeStop := node.NextSibling
+	for node != nodeStop {
 		found, loc := references.FindFirstMentionBytes(util.UnsafeStringToBytes(node.Data[start:]))
 		if !found {
 			node = node.NextSibling
@@ -962,57 +964,68 @@ func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
 	}
 }
 
+type anyHashPatternResult struct {
+	PosStart  int
+	PosEnd    int
+	FullURL   string
+	CommitID  string
+	SubPath   string
+	QueryHash string
+}
+
+func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) {
+	m := anyHashPattern.FindStringSubmatchIndex(s)
+	if m == nil {
+		return ret, false
+	}
+
+	ret.PosStart, ret.PosEnd = m[0], m[1]
+	ret.FullURL = s[ret.PosStart:ret.PosEnd]
+	if strings.HasSuffix(ret.FullURL, ".") {
+		// if url ends in '.', it's very likely that it is not part of the actual url but used to finish a sentence.
+		ret.PosEnd--
+		ret.FullURL = ret.FullURL[:len(ret.FullURL)-1]
+		for i := 0; i < len(m); i++ {
+			m[i] = min(m[i], ret.PosEnd)
+		}
+	}
+
+	ret.CommitID = s[m[2]:m[3]]
+	if m[5] > 0 {
+		ret.SubPath = s[m[4]:m[5]]
+	}
+
+	lastStart, lastEnd := m[len(m)-2], m[len(m)-1]
+	if lastEnd > 0 {
+		ret.QueryHash = s[lastStart:lastEnd][1:]
+	}
+	return ret, true
+}
+
 // fullHashPatternProcessor renders SHA containing URLs
 func fullHashPatternProcessor(ctx *RenderContext, node *html.Node) {
 	if ctx.Metas == nil {
 		return
 	}
-
-	next := node.NextSibling
-	for node != nil && node != next {
-		m := anyHashPattern.FindStringSubmatchIndex(node.Data)
-		if m == nil {
-			return
+	nodeStop := node.NextSibling
+	for node != nodeStop {
+		if node.Type != html.TextNode {
+			node = node.NextSibling
+			continue
 		}
-
-		urlFull := node.Data[m[0]:m[1]]
-		text := base.ShortSha(node.Data[m[2]:m[3]])
-
-		// 3rd capture group matches a optional path
-		subpath := ""
-		if m[5] > 0 {
-			subpath = node.Data[m[4]:m[5]]
+		ret, ok := anyHashPatternExtract(node.Data)
+		if !ok {
+			node = node.NextSibling
+			continue
 		}
-
-		// 4th capture group matches a optional url hash
-		hash := ""
-		if m[7] > 0 {
-			hash = node.Data[m[6]:m[7]][1:]
+		text := base.ShortSha(ret.CommitID)
+		if ret.SubPath != "" {
+			text += ret.SubPath
 		}
-
-		start := m[0]
-		end := m[1]
-
-		// If url ends in '.', it's very likely that it is not part of the
-		// actual url but used to finish a sentence.
-		if strings.HasSuffix(urlFull, ".") {
-			end--
-			urlFull = urlFull[:len(urlFull)-1]
-			if hash != "" {
-				hash = hash[:len(hash)-1]
-			} else if subpath != "" {
-				subpath = subpath[:len(subpath)-1]
-			}
+		if ret.QueryHash != "" {
+			text += " (" + ret.QueryHash + ")"
 		}
-
-		if subpath != "" {
-			text += subpath
-		}
-
-		if hash != "" {
-			text += " (" + hash + ")"
-		}
-		replaceContent(node, start, end, createCodeLink(urlFull, text, "commit"))
+		replaceContent(node, ret.PosStart, ret.PosEnd, createCodeLink(ret.FullURL, text, "commit"))
 		node = node.NextSibling.NextSibling
 	}
 }
@@ -1021,19 +1034,16 @@ func comparePatternProcessor(ctx *RenderContext, node *html.Node) {
 	if ctx.Metas == nil {
 		return
 	}
-
-	next := node.NextSibling
-	for node != nil && node != next {
-		m := comparePattern.FindStringSubmatchIndex(node.Data)
-		if m == nil {
-			return
+	nodeStop := node.NextSibling
+	for node != nodeStop {
+		if node.Type != html.TextNode {
+			node = node.NextSibling
+			continue
 		}
-
-		// Ensure that every group (m[0]...m[7]) has a match
-		for i := 0; i < 8; i++ {
-			if m[i] == -1 {
-				return
-			}
+		m := comparePattern.FindStringSubmatchIndex(node.Data)
+		if m == nil || slices.Contains(m[:8], -1) { // ensure that every group (m[0]...m[7]) has a match
+			node = node.NextSibling
+			continue
 		}
 
 		urlFull := node.Data[m[0]:m[1]]
diff --git a/modules/markup/html_codepreview.go b/modules/markup/html_codepreview.go
index d9da24ea34..5ef2217e3d 100644
--- a/modules/markup/html_codepreview.go
+++ b/modules/markup/html_codepreview.go
@@ -60,7 +60,8 @@ func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosSt
 }
 
 func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
-	for node != nil {
+	nodeStop := node.NextSibling
+	for node != nodeStop {
 		if node.Type != html.TextNode {
 			node = node.NextSibling
 			continue
diff --git a/modules/markup/html_internal_test.go b/modules/markup/html_internal_test.go
index e313be7040..3ff0597851 100644
--- a/modules/markup/html_internal_test.go
+++ b/modules/markup/html_internal_test.go
@@ -399,36 +399,61 @@ func TestRegExp_sha1CurrentPattern(t *testing.T) {
 }
 
 func TestRegExp_anySHA1Pattern(t *testing.T) {
-	testCases := map[string][]string{
+	testCases := map[string]anyHashPatternResult{
 		"https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js#L2703": {
-			"a644101ed04d0beacea864ce805e0c4f86ba1cd1",
-			"/test/unit/event.js",
-			"#L2703",
+			CommitID:  "a644101ed04d0beacea864ce805e0c4f86ba1cd1",
+			SubPath:   "/test/unit/event.js",
+			QueryHash: "L2703",
 		},
 		"https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js": {
-			"a644101ed04d0beacea864ce805e0c4f86ba1cd1",
-			"/test/unit/event.js",
-			"",
+			CommitID: "a644101ed04d0beacea864ce805e0c4f86ba1cd1",
+			SubPath:  "/test/unit/event.js",
 		},
 		"https://github.com/jquery/jquery/commit/0705be475092aede1eddae01319ec931fb9c65fc": {
-			"0705be475092aede1eddae01319ec931fb9c65fc",
-			"",
-			"",
+			CommitID: "0705be475092aede1eddae01319ec931fb9c65fc",
 		},
 		"https://github.com/jquery/jquery/tree/0705be475092aede1eddae01319ec931fb9c65fc/src": {
-			"0705be475092aede1eddae01319ec931fb9c65fc",
-			"/src",
-			"",
+			CommitID: "0705be475092aede1eddae01319ec931fb9c65fc",
+			SubPath:  "/src",
 		},
 		"https://try.gogs.io/gogs/gogs/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2": {
-			"d8a994ef243349f321568f9e36d5c3f444b99cae",
-			"",
-			"#diff-2",
+			CommitID:  "d8a994ef243349f321568f9e36d5c3f444b99cae",
+			QueryHash: "diff-2",
+		},
+		"non-url": {},
+		"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678?a=b#L1-L2": {
+			CommitID:  "1234567812345678123456781234567812345678123456781234567812345678",
+			QueryHash: "L1-L2",
+		},
+		"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678.": {
+			CommitID: "1234567812345678123456781234567812345678123456781234567812345678",
+		},
+		"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678/sub.": {
+			CommitID: "1234567812345678123456781234567812345678123456781234567812345678",
+			SubPath:  "/sub",
+		},
+		"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678?a=b.": {
+			CommitID: "1234567812345678123456781234567812345678123456781234567812345678",
+		},
+		"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678?a=b&c=d": {
+			CommitID: "1234567812345678123456781234567812345678123456781234567812345678",
+		},
+		"http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678#hash.": {
+			CommitID:  "1234567812345678123456781234567812345678123456781234567812345678",
+			QueryHash: "hash",
 		},
 	}
 
 	for k, v := range testCases {
-		assert.Equal(t, anyHashPattern.FindStringSubmatch(k)[1:], v)
+		ret, ok := anyHashPatternExtract(k)
+		if v.CommitID == "" {
+			assert.False(t, ok)
+		} else {
+			assert.EqualValues(t, strings.TrimSuffix(k, "."), ret.FullURL)
+			assert.EqualValues(t, v.CommitID, ret.CommitID)
+			assert.EqualValues(t, v.SubPath, ret.SubPath)
+			assert.EqualValues(t, v.QueryHash, ret.QueryHash)
+		}
 	}
 }
 
diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go
index 916e74fb62..a2ae18d777 100644
--- a/modules/markup/html_test.go
+++ b/modules/markup/html_test.go
@@ -124,6 +124,11 @@ func TestRender_CrossReferences(t *testing.T) {
 	test(
 		util.URLJoin(markup.TestAppURL, "gogitea", "some-repo-name", "issues", "12345"),
 		`<p><a href="`+util.URLJoin(markup.TestAppURL, "gogitea", "some-repo-name", "issues", "12345")+`" class="ref-issue" rel="nofollow">gogitea/some-repo-name#12345</a></p>`)
+
+	inputURL := "https://host/a/b/commit/0123456789012345678901234567890123456789/foo.txt?a=b#L2-L3"
+	test(
+		inputURL,
+		`<p><a href="`+inputURL+`" rel="nofollow"><code>0123456789/foo.txt (L2-L3)</code></a></p>`)
 }
 
 func TestMisc_IsSameDomain(t *testing.T) {
@@ -695,7 +700,7 @@ func TestIssue18471(t *testing.T) {
 	}, strings.NewReader(data), &res)
 
 	assert.NoError(t, err)
-	assert.Equal(t, "<a href=\"http://domain/org/repo/compare/783b039...da951ce\" class=\"compare\"><code class=\"nohighlight\">783b039...da951ce</code></a>", res.String())
+	assert.Equal(t, `<a href="http://domain/org/repo/compare/783b039...da951ce" class="compare"><code class="nohighlight">783b039...da951ce</code></a>`, res.String())
 }
 
 func TestIsFullURL(t *testing.T) {

From bb0e4ce581721afabbfcdf8d5a894ba124465577 Mon Sep 17 00:00:00 2001
From: Neal Caffery <bing.ecnu@gmail.com>
Date: Sat, 4 May 2024 11:53:18 +0800
Subject: [PATCH 35/47] Update README.md (#30856)

fix typo for the Docker README
---
 docker/README.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/docker/README.md b/docker/README.md
index a6d7c9a843..b014f42367 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,7 +1,7 @@
 # Gitea - Docker
 
-Dockerfile is found in root of repository.
+Dockerfile is found in the root of the repository.
 
-Docker image can be found on [docker hub](https://hub.docker.com/r/gitea/gitea)
+Docker image can be found on [docker hub](https://hub.docker.com/r/gitea/gitea).
 
-Documentation on using docker image can be found on [Gitea Docs site](https://docs.gitea.com/installation/install-with-docker-rootless)
+Documentation on using docker image can be found on [Gitea Docs site](https://docs.gitea.com/installation/install-with-docker-rootless).

From ecd1d96f494d2400f7659165ff9376354edda395 Mon Sep 17 00:00:00 2001
From: yp05327 <576951401@qq.com>
Date: Sun, 5 May 2024 11:10:20 +0900
Subject: [PATCH 36/47] Add result check in TestAPIEditUser (#29674)

Fix #29514
There are too many usage of `NewRequestWithValues`, so there's no need
to check all of them.
Just one is enough I think.
---
 tests/integration/api_admin_test.go | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go
index e8954f5b20..92da7ce041 100644
--- a/tests/integration/api_admin_test.go
+++ b/tests/integration/api_admin_test.go
@@ -195,14 +195,17 @@ func TestAPIEditUser(t *testing.T) {
 	token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
 	urlStr := fmt.Sprintf("/api/v1/admin/users/%s", "user2")
 
+	fullNameToChange := "Full Name User 2"
 	req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{
 		// required
 		"login_name": "user2",
 		"source_id":  "0",
 		// to change
-		"full_name": "Full Name User 2",
+		"full_name": fullNameToChange,
 	}).AddTokenAuth(token)
 	MakeRequest(t, req, http.StatusOK)
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"})
+	assert.Equal(t, fullNameToChange, user2.FullName)
 
 	empty := ""
 	req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
@@ -216,7 +219,7 @@ func TestAPIEditUser(t *testing.T) {
 	json.Unmarshal(resp.Body.Bytes(), &errMap)
 	assert.EqualValues(t, "e-mail invalid [email: ]", errMap["message"].(string))
 
-	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"})
+	user2 = unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"})
 	assert.False(t, user2.IsRestricted)
 	bTrue := true
 	req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{

From 5c236bd4c024dbe4a71516b10aa812893651983a Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Sun, 5 May 2024 21:09:41 +0800
Subject: [PATCH 37/47] Fix issue/PR title edit (#30858)

1. "enter" doesn't work (I think it is the last enter support for #14843)
2. if a branch name contains something like `&`, then the branch selector doesn't update
---
 templates/repo/issue/view_title.tmpl    |  43 ++++----
 tests/integration/issue_test.go         |   2 +-
 tests/integration/pull_create_test.go   |   2 +-
 web_src/css/repo.css                    |  75 +++++++-------
 web_src/js/features/common-global.js    |  16 ++-
 web_src/js/features/comp/QuickSubmit.js |  13 +--
 web_src/js/features/repo-issue.js       | 129 +++++++++++-------------
 7 files changed, 141 insertions(+), 139 deletions(-)

diff --git a/templates/repo/issue/view_title.tmpl b/templates/repo/issue/view_title.tmpl
index fccf8cca91..4415ad79f5 100644
--- a/templates/repo/issue/view_title.tmpl
+++ b/templates/repo/issue/view_title.tmpl
@@ -4,29 +4,36 @@
 	</div>
 {{end}}
 <div class="issue-title-header">
-	<div class="issue-title" id="issue-title-wrapper">
+	{{$canEditIssueTitle := and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .Repository.IsArchived)}}
+	<div class="issue-title" id="issue-title-display">
 		<h1 class="gt-word-break">
-			<span id="issue-title">{{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx) | RenderCodeBlock}} <span class="index">#{{.Issue.Index}}</span>
-</span>
-			<div id="edit-title-input" class="ui input tw-flex-1 tw-hidden">
-				<input value="{{.Issue.Title}}" maxlength="255" autocomplete="off">
-			</div>
+			{{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx) | RenderCodeBlock}}
+			<span class="index">#{{.Issue.Index}}</span>
 		</h1>
 		<div class="issue-title-buttons">
-			{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .Repository.IsArchived)}}
-				<button id="edit-title" class="ui small basic button edit-button not-in-edit{{if .Issue.IsPull}} tw-mr-0{{end}}">{{ctx.Locale.Tr "repo.issues.edit"}}</button>
+			{{if $canEditIssueTitle}}
+			<button id="issue-title-edit-show" class="ui small basic button">{{ctx.Locale.Tr "repo.issues.edit"}}</button>
 			{{end}}
 			{{if not .Issue.IsPull}}
-				<a role="button" class="ui small primary button new-issue-button tw-mr-0" href="{{.RepoLink}}/issues/new{{if .NewIssueChooseTemplate}}/choose{{end}}">{{ctx.Locale.Tr "repo.issues.new"}}</a>
+			<a role="button" class="ui small primary button" href="{{.RepoLink}}/issues/new{{if .NewIssueChooseTemplate}}/choose{{end}}">{{ctx.Locale.Tr "repo.issues.new"}}</a>
 			{{end}}
 		</div>
-		{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .Repository.IsArchived)}}
-			<div class="edit-buttons">
-				<button id="cancel-edit-title" class="ui small basic button in-edit tw-hidden">{{ctx.Locale.Tr "repo.issues.cancel"}}</button>
-				<button id="save-edit-title" class="ui small primary button in-edit tw-hidden tw-mr-0" data-update-url="{{$.RepoLink}}/issues/{{.Issue.Index}}/title" {{if .Issue.IsPull}}data-target-update-url="{{$.RepoLink}}/pull/{{.Issue.Index}}/target_branch"{{end}}>{{ctx.Locale.Tr "repo.issues.save"}}</button>
-			</div>
-		{{end}}
 	</div>
+	{{if $canEditIssueTitle}}
+	<div class="ui form issue-title tw-hidden" id="issue-title-editor">
+		<div class="ui input tw-flex-1">
+			<input value="{{.Issue.Title}}" data-old-title="{{.Issue.Title}}" maxlength="255" autocomplete="off">
+		</div>
+		<div class="issue-title-buttons">
+			<button class="ui small basic cancel button">{{ctx.Locale.Tr "repo.issues.cancel"}}</button>
+			<button class="ui small primary button"
+							data-update-url="{{$.RepoLink}}/issues/{{.Issue.Index}}/title"
+							{{if .Issue.IsPull}}data-target-update-url="{{$.RepoLink}}/pull/{{.Issue.Index}}/target_branch"{{end}}>
+				{{ctx.Locale.Tr "repo.issues.save"}}
+			</button>
+		</div>
+	</div>
+	{{end}}
 	<div class="issue-title-meta">
 		{{if .HasMerged}}
 			<div class="ui purple label issue-state-label">{{svg "octicon-git-merge" 16 "tw-mr-1"}} {{if eq .Issue.PullRequest.Status 3}}{{ctx.Locale.Tr "repo.pulls.manually_merged"}}{{else}}{{ctx.Locale.Tr "repo.pulls.merged"}}{{end}}</div>
@@ -63,14 +70,14 @@
 					{{end}}
 				{{else}}
 					{{if .Issue.OriginalAuthor}}
-						<span id="pull-desc" class="pull-desc">{{.Issue.OriginalAuthor}} {{ctx.Locale.Tr "repo.pulls.title_desc" .NumCommits $headHref $baseHref}}</span>
+						<span id="pull-desc-display" class="pull-desc">{{.Issue.OriginalAuthor}} {{ctx.Locale.Tr "repo.pulls.title_desc" .NumCommits $headHref $baseHref}}</span>
 					{{else}}
-						<span id="pull-desc" class="pull-desc">
+						<span id="pull-desc-display" class="pull-desc">
 							<a {{if gt .Issue.Poster.ID 0}}href="{{.Issue.Poster.HomeLink}}"{{end}}>{{.Issue.Poster.GetDisplayName}}</a>
 							{{ctx.Locale.Tr "repo.pulls.title_desc" .NumCommits $headHref $baseHref}}
 						</span>
 					{{end}}
-					<span id="pull-desc-edit" class="tw-hidden flex-text-block">
+					<span id="pull-desc-editor" class="tw-hidden flex-text-block">
 						<div class="ui floating filter dropdown">
 							<div class="ui basic small button tw-mr-0">
 								<span class="text">{{ctx.Locale.Tr "repo.pulls.compare_compare"}}: {{$.HeadTarget}}</span>
diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go
index b7952b0879..d74516d110 100644
--- a/tests/integration/issue_test.go
+++ b/tests/integration/issue_test.go
@@ -144,7 +144,7 @@ func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content
 	resp = session.MakeRequest(t, req, http.StatusOK)
 
 	htmlDoc = NewHTMLParser(t, resp.Body)
-	val := htmlDoc.doc.Find("#issue-title").Text()
+	val := htmlDoc.doc.Find("#issue-title-display").Text()
 	assert.Contains(t, val, title)
 	val = htmlDoc.doc.Find(".comment .render-content p").First().Text()
 	assert.Equal(t, content, val)
diff --git a/tests/integration/pull_create_test.go b/tests/integration/pull_create_test.go
index 609bd73fd5..7add8e1db6 100644
--- a/tests/integration/pull_create_test.go
+++ b/tests/integration/pull_create_test.go
@@ -125,7 +125,7 @@ func TestPullCreate_TitleEscape(t *testing.T) {
 		req := NewRequest(t, "GET", url)
 		resp = session.MakeRequest(t, req, http.StatusOK)
 		htmlDoc := NewHTMLParser(t, resp.Body)
-		editTestTitleURL, exists := htmlDoc.doc.Find("#save-edit-title").First().Attr("data-update-url")
+		editTestTitleURL, exists := htmlDoc.doc.Find(".issue-title-buttons button[data-update-url]").First().Attr("data-update-url")
 		assert.True(t, exists, "The template has changed")
 
 		req = NewRequestWithValues(t, "POST", editTestTitleURL, map[string]string{
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 408b62ad3c..7695b632b4 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -575,34 +575,7 @@ td .commit-summary {
   display: inline-block;
 }
 
-.issue-title-header {
-  width: 100%;
-  padding-bottom: 4px;
-  margin-bottom: 1rem;
-}
-
-.issue-title-meta {
-  display: flex;
-  align-items: center;
-}
-
-.repository.view.issue .issue-title-buttons,
-.repository.view.issue .edit-buttons {
-  display: flex;
-}
-
 @media (max-width: 767.98px) {
-  .repository.view.issue .issue-title {
-    flex-direction: column;
-  }
-  .repository.view.issue .issue-title-buttons,
-  .repository.view.issue .edit-buttons {
-    width: 100%;
-    justify-content: space-between;
-  }
-  .repository.view.issue .edit-buttons {
-    margin-top: .5rem;
-  }
   .comment.form .issue-content-left .avatar {
     display: none;
   }
@@ -617,15 +590,37 @@ td .commit-summary {
   }
 }
 
+/* issue title & meta & edit */
+.issue-title-header {
+  width: 100%;
+  padding-bottom: 4px;
+  margin-bottom: 1rem;
+}
+
+.issue-title-meta {
+  display: flex;
+  align-items: center;
+}
+
+.repository.view.issue .issue-title-buttons {
+  display: flex;
+  gap: 0.5em;
+}
+
+.repository.view.issue .issue-title-buttons > .ui.button {
+  margin: 0;
+  height: 35px;
+}
+
 .repository.view.issue .issue-title {
   display: flex;
   align-items: center;
+  gap: 0.5em;
   margin-bottom: 8px;
+  min-height: 40px; /* avoid layout shift on edit */
 }
 
 .repository.view.issue .issue-title h1 {
-  display: flex;
-  align-items: center;
   flex: 1;
   width: 100%;
   font-weight: var(--font-weight-normal);
@@ -633,14 +628,24 @@ td .commit-summary {
   line-height: 40px;
   margin: 0;
   padding-right: 0.25rem;
-  min-height: 41px; /* avoid layout shift on edit */
 }
 
-.repository.view.issue .issue-title h1 .ui.input {
-  font-size: 0.5em;
+@media (max-width: 767.98px) {
+  .repository.view.issue .issue-title {
+    flex-direction: column;
+  }
+  .repository.view.issue .issue-title-buttons {
+    width: 100%;
+    justify-content: space-between;
+  }
 }
 
-.repository.view.issue .issue-title h1 .ui.input input {
+.repository.view.issue .issue-title .ui.input {
+  width: 100%;
+  height: 35px;
+}
+
+.repository.view.issue .issue-title .ui.input input {
   font-size: 1.5em;
   padding: 2px .5rem;
 }
@@ -653,10 +658,6 @@ td .commit-summary {
   margin-right: 10px;
 }
 
-.issue-title .edit-zone {
-  margin-top: 10px;
-}
-
 .issue-state-label {
   display: flex !important;
   align-items: center !important;
diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js
index a821e1b921..5b8673105d 100644
--- a/web_src/js/features/common-global.js
+++ b/web_src/js/features/common-global.js
@@ -47,10 +47,18 @@ export function initFootLanguageMenu() {
 
 export function initGlobalEnterQuickSubmit() {
   document.addEventListener('keydown', (e) => {
-    const isQuickSubmitEnter = ((e.ctrlKey && !e.altKey) || e.metaKey) && (e.key === 'Enter');
-    if (isQuickSubmitEnter && e.target.matches('textarea')) {
-      e.preventDefault();
-      handleGlobalEnterQuickSubmit(e.target);
+    if (e.key !== 'Enter') return;
+    const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
+    if (hasCtrlOrMeta && e.target.matches('textarea')) {
+      if (handleGlobalEnterQuickSubmit(e.target)) {
+        e.preventDefault();
+      }
+    } else if (e.target.matches('input') && !e.target.closest('form')) {
+      // input in a normal form could handle Enter key by default, so we only handle the input outside a form
+      // eslint-disable-next-line unicorn/no-lonely-if
+      if (handleGlobalEnterQuickSubmit(e.target)) {
+        e.preventDefault();
+      }
     }
   });
 }
diff --git a/web_src/js/features/comp/QuickSubmit.js b/web_src/js/features/comp/QuickSubmit.js
index 6bd5f6644d..3ff29f4fac 100644
--- a/web_src/js/features/comp/QuickSubmit.js
+++ b/web_src/js/features/comp/QuickSubmit.js
@@ -3,16 +3,17 @@ export function handleGlobalEnterQuickSubmit(target) {
   if (form) {
     if (!form.checkValidity()) {
       form.reportValidity();
-      return;
+    } else {
+      // here use the event to trigger the submit event (instead of calling `submit()` method directly)
+      // otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog
+      form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
     }
-
-    // here use the event to trigger the submit event (instead of calling `submit()` method directly)
-    // otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog
-    form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
-    return;
+    return true;
   }
   form = target.closest('.ui.form');
   if (form) {
     form.querySelector('.ui.primary.button')?.click();
+    return true;
   }
+  return false;
 }
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index c4e14c62c4..39c364ca50 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -7,6 +7,7 @@ import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkd
 import {toAbsoluteUrl} from '../utils.js';
 import {initDropzone} from './common-global.js';
 import {POST, GET} from '../modules/fetch.js';
+import {showErrorToast} from '../modules/toast.js';
 
 const {appSubUrl} = window.config;
 
@@ -602,85 +603,69 @@ export function initRepoIssueWipToggle() {
   });
 }
 
-async function pullrequest_targetbranch_change(update_url) {
-  const targetBranch = $('#pull-target-branch').data('branch');
-  const $branchTarget = $('#branch_target');
-  if (targetBranch === $branchTarget.text()) {
-    window.location.reload();
-    return false;
-  }
-  try {
-    await POST(update_url, {data: new URLSearchParams({target_branch: targetBranch})});
-  } catch (error) {
-    console.error(error);
-  } finally {
-    window.location.reload();
-  }
-}
-
 export function initRepoIssueTitleEdit() {
-  // Edit issue title
-  const $issueTitle = $('#issue-title');
-  const $editInput = $('#edit-title-input input');
+  const issueTitleDisplay = document.querySelector('#issue-title-display');
+  const issueTitleEditor = document.querySelector('#issue-title-editor');
+  if (!issueTitleEditor) return;
 
-  const editTitleToggle = function () {
-    toggleElem($issueTitle);
-    toggleElem('.not-in-edit');
-    toggleElem('#edit-title-input');
-    toggleElem('#pull-desc');
-    toggleElem('#pull-desc-edit');
-    toggleElem('.in-edit');
-    toggleElem('.new-issue-button');
-    document.getElementById('issue-title-wrapper')?.classList.toggle('edit-active');
-    $editInput[0].focus();
-    $editInput[0].select();
-    return false;
-  };
-
-  $('#edit-title').on('click', editTitleToggle);
-  $('#cancel-edit-title').on('click', editTitleToggle);
-  $('#save-edit-title').on('click', editTitleToggle).on('click', async function () {
-    const pullrequest_target_update_url = this.getAttribute('data-target-update-url');
-    if (!$editInput.val().length || $editInput.val() === $issueTitle.text()) {
-      $editInput.val($issueTitle.text());
-      await pullrequest_targetbranch_change(pullrequest_target_update_url);
-    } else {
-      try {
-        const params = new URLSearchParams();
-        params.append('title', $editInput.val());
-        const response = await POST(this.getAttribute('data-update-url'), {data: params});
-        const data = await response.json();
-        $editInput.val(data.title);
-        $issueTitle.text(data.title);
-        if (pullrequest_target_update_url) {
-          await pullrequest_targetbranch_change(pullrequest_target_update_url); // it will reload the window
-        } else {
-          window.location.reload();
-        }
-      } catch (error) {
-        console.error(error);
-      }
+  const issueTitleInput = issueTitleEditor.querySelector('input');
+  const oldTitle = issueTitleInput.getAttribute('data-old-title');
+  issueTitleDisplay.querySelector('#issue-title-edit-show').addEventListener('click', () => {
+    hideElem(issueTitleDisplay);
+    hideElem('#pull-desc-display');
+    showElem(issueTitleEditor);
+    showElem('#pull-desc-editor');
+    if (!issueTitleInput.value.trim()) {
+      issueTitleInput.value = oldTitle;
+    }
+    issueTitleInput.focus();
+  });
+  issueTitleEditor.querySelector('.ui.cancel.button').addEventListener('click', () => {
+    hideElem(issueTitleEditor);
+    hideElem('#pull-desc-editor');
+    showElem(issueTitleDisplay);
+    showElem('#pull-desc-display');
+  });
+  const editSaveButton = issueTitleEditor.querySelector('.ui.primary.button');
+  editSaveButton.addEventListener('click', async () => {
+    const prTargetUpdateUrl = editSaveButton.getAttribute('data-target-update-url');
+    const newTitle = issueTitleInput.value.trim();
+    try {
+      if (newTitle && newTitle !== oldTitle) {
+        const resp = await POST(editSaveButton.getAttribute('data-update-url'), {data: new URLSearchParams({title: newTitle})});
+        if (!resp.ok) {
+          throw new Error(`Failed to update issue title: ${resp.statusText}`);
+        }
+      }
+      if (prTargetUpdateUrl) {
+        const newTargetBranch = document.querySelector('#pull-target-branch').getAttribute('data-branch');
+        const oldTargetBranch = document.querySelector('#branch_target').textContent;
+        if (newTargetBranch !== oldTargetBranch) {
+          const resp = await POST(prTargetUpdateUrl, {data: new URLSearchParams({target_branch: newTargetBranch})});
+          if (!resp.ok) {
+            throw new Error(`Failed to update PR target branch: ${resp.statusText}`);
+          }
+        }
+      }
+      window.location.reload();
+    } catch (error) {
+      console.error(error);
+      showErrorToast(error.message);
     }
-    return false;
   });
 }
 
 export function initRepoIssueBranchSelect() {
-  const changeBranchSelect = function () {
-    const $selectionTextField = $('#pull-target-branch');
-
-    const baseName = $selectionTextField.data('basename');
-    const branchNameNew = $(this).data('branch');
-    const branchNameOld = $selectionTextField.data('branch');
-
-    // Replace branch name to keep translation from HTML template
-    $selectionTextField.html($selectionTextField.html().replace(
-      `${baseName}:${branchNameOld}`,
-      `${baseName}:${branchNameNew}`,
-    ));
-    $selectionTextField.data('branch', branchNameNew); // update branch name in setting
-  };
-  $('#branch-select > .item').on('click', changeBranchSelect);
+  document.querySelector('#branch-select')?.addEventListener('click', (e) => {
+    const el = e.target.closest('.item[data-branch]');
+    if (!el) return;
+    const pullTargetBranch = document.querySelector('#pull-target-branch');
+    const baseName = pullTargetBranch.getAttribute('data-basename');
+    const branchNameNew = el.getAttribute('data-branch');
+    const branchNameOld = pullTargetBranch.getAttribute('data-branch');
+    pullTargetBranch.textContent = pullTargetBranch.textContent.replace(`${baseName}:${branchNameOld}`, `${baseName}:${branchNameNew}`);
+    pullTargetBranch.setAttribute('data-branch', branchNameNew);
+  });
 }
 
 export function initSingleCommentEditor($commentForm) {

From 982b20d259e5bcd94377820ca3559112add36a1b Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Mon, 6 May 2024 00:34:13 +0800
Subject: [PATCH 38/47] Do not show monaco JS errors (#30862)

Fix #30861
---
 web_src/js/bootstrap.js | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)

diff --git a/web_src/js/bootstrap.js b/web_src/js/bootstrap.js
index e466d0b169..8339b4bd82 100644
--- a/web_src/js/bootstrap.js
+++ b/web_src/js/bootstrap.js
@@ -6,13 +6,20 @@
 // This file must be imported before any lazy-loading is being attempted.
 __webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`;
 
-export function showGlobalErrorMessage(msg) {
-  const pageContent = document.querySelector('.page-content');
-  if (!pageContent) return;
+function shouldIgnoreError(err) {
+  const ignorePatterns = [
+    '/assets/js/monaco.', // https://github.com/go-gitea/gitea/issues/30861 , https://github.com/microsoft/monaco-editor/issues/4496
+  ];
+  for (const pattern of ignorePatterns) {
+    if (err.stack?.includes(pattern)) return true;
+  }
+  return false;
+}
 
-  // compact the message to a data attribute to avoid too many duplicated messages
-  const msgCompact = msg.replace(/\W/g, '').trim();
-  let msgDiv = pageContent.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
+export function showGlobalErrorMessage(msg) {
+  const msgContainer = document.querySelector('.page-content') ?? document.body;
+  const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
+  let msgDiv = msgContainer.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
   if (!msgDiv) {
     const el = document.createElement('div');
     el.innerHTML = `<div class="ui container negative message center aligned js-global-error tw-mt-[15px] tw-whitespace-pre-line"></div>`;
@@ -23,7 +30,7 @@ export function showGlobalErrorMessage(msg) {
   msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
   msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
   msgDiv.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
-  pageContent.prepend(msgDiv);
+  msgContainer.prepend(msgDiv);
 }
 
 /**
@@ -52,10 +59,12 @@ function processWindowErrorEvent({error, reason, message, type, filename, lineno
     if (runModeIsProd) return;
   }
 
-  // If the error stack trace does not include the base URL of our script assets, it likely came
-  // from a browser extension or inline script. Do not show such errors in production.
-  if (err instanceof Error && !err.stack?.includes(assetBaseUrl) && runModeIsProd) {
-    return;
+  if (err instanceof Error) {
+    // If the error stack trace does not include the base URL of our script assets, it likely came
+    // from a browser extension or inline script. Do not show such errors in production.
+    if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return;
+    // Ignore some known errors that are unable to fix
+    if (shouldIgnoreError(err)) return;
   }
 
   let msg = err?.message ?? message;

From 22c7b3a74459833b86783e84d4708c8934d34e58 Mon Sep 17 00:00:00 2001
From: Kemal Zebari <60799661+kemzeb@users.noreply.github.com>
Date: Sun, 5 May 2024 18:36:53 -0700
Subject: [PATCH 39/47] Have time.js use UTC-related getters/setters (#30857)

Before this patch, we were using `Date` getter/setter methods that
worked with local time to get a list of Sundays that are in the range of
some start date and end date. The problem with this was that the Sundays
are in Unix epoch time and when we changed the "startDate" argument that
was passed to make sure it is on a Sunday, this change would be
reflected when we convert it to Unix epoch time. More specifically, I
observed that we may get different Unix epochs depending on your
timezone when the returned list should rather be timezone-agnostic.

This led to issues in US timezones that caused the contributor, code
frequency, and recent commit charts to not show any chart data. This fix
resolves this by using getter/setter methods that work with UTC since it
isn't dependent on timezones.

Fixes #30851.

---------

Co-authored-by: Sam Fisher <fisher@3echelon.local>
---
 web_src/js/components/RepoCodeFrequency.vue |  2 +-
 web_src/js/components/RepoContributors.vue  |  2 +-
 web_src/js/components/RepoRecentCommits.vue |  2 +-
 web_src/js/utils/time.js                    | 37 ++++++++++++---------
 4 files changed, 24 insertions(+), 19 deletions(-)

diff --git a/web_src/js/components/RepoCodeFrequency.vue b/web_src/js/components/RepoCodeFrequency.vue
index adce431264..1d40d6d417 100644
--- a/web_src/js/components/RepoCodeFrequency.vue
+++ b/web_src/js/components/RepoCodeFrequency.vue
@@ -67,7 +67,7 @@ export default {
           const weekValues = Object.values(this.data);
           const start = weekValues[0].week;
           const end = firstStartDateAfterDate(new Date());
-          const startDays = startDaysBetween(new Date(start), new Date(end));
+          const startDays = startDaysBetween(start, end);
           this.data = fillEmptyStartDaysWithZeroes(startDays, this.data);
           this.errorText = '';
         } else {
diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue
index 2347c41ae4..f7b05831e0 100644
--- a/web_src/js/components/RepoContributors.vue
+++ b/web_src/js/components/RepoContributors.vue
@@ -114,7 +114,7 @@ export default {
           const weekValues = Object.values(total.weeks);
           this.xAxisStart = weekValues[0].week;
           this.xAxisEnd = firstStartDateAfterDate(new Date());
-          const startDays = startDaysBetween(new Date(this.xAxisStart), new Date(this.xAxisEnd));
+          const startDays = startDaysBetween(this.xAxisStart, this.xAxisEnd);
           total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
           this.xAxisMin = this.xAxisStart;
           this.xAxisMax = this.xAxisEnd;
diff --git a/web_src/js/components/RepoRecentCommits.vue b/web_src/js/components/RepoRecentCommits.vue
index 502af533da..8759978e78 100644
--- a/web_src/js/components/RepoRecentCommits.vue
+++ b/web_src/js/components/RepoRecentCommits.vue
@@ -62,7 +62,7 @@ export default {
           const data = await response.json();
           const start = Object.values(data)[0].week;
           const end = firstStartDateAfterDate(new Date());
-          const startDays = startDaysBetween(new Date(start), new Date(end));
+          const startDays = startDaysBetween(start, end);
           this.data = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
           this.errorText = '';
         } else {
diff --git a/web_src/js/utils/time.js b/web_src/js/utils/time.js
index 1848792c98..7c7eabd1a3 100644
--- a/web_src/js/utils/time.js
+++ b/web_src/js/utils/time.js
@@ -1,25 +1,30 @@
 import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc.js';
 import {getCurrentLocale} from '../utils.js';
 
-// Returns an array of millisecond-timestamps of start-of-week days (Sundays)
-export function startDaysBetween(startDate, endDate) {
-  // Ensure the start date is a Sunday
-  while (startDate.getDay() !== 0) {
-    startDate.setDate(startDate.getDate() + 1);
-  }
+dayjs.extend(utc);
 
-  const start = dayjs(startDate);
-  const end = dayjs(endDate);
-  const startDays = [];
+/**
+ * Returns an array of millisecond-timestamps of start-of-week days (Sundays)
+ *
+ * @param startConfig The start date. Can take any type that `Date` accepts.
+ * @param endConfig The end date. Can take any type that `Date` accepts.
+ */
+export function startDaysBetween(startDate, endDate) {
+  const start = dayjs.utc(startDate);
+  const end = dayjs.utc(endDate);
 
   let current = start;
+
+  // Ensure the start date is a Sunday
+  while (current.day() !== 0) {
+    current = current.add(1, 'day');
+  }
+
+  const startDays = [];
   while (current.isBefore(end)) {
     startDays.push(current.valueOf());
-    // we are adding 7 * 24 hours instead of 1 week because we don't want
-    // date library to use local time zone to calculate 1 week from now.
-    // local time zone is problematic because of daylight saving time (dst)
-    // used on some countries
-    current = current.add(7 * 24, 'hour');
+    current = current.add(1, 'week');
   }
 
   return startDays;
@@ -29,10 +34,10 @@ export function firstStartDateAfterDate(inputDate) {
   if (!(inputDate instanceof Date)) {
     throw new Error('Invalid date');
   }
-  const dayOfWeek = inputDate.getDay();
+  const dayOfWeek = inputDate.getUTCDay();
   const daysUntilSunday = 7 - dayOfWeek;
   const resultDate = new Date(inputDate.getTime());
-  resultDate.setDate(resultDate.getDate() + daysUntilSunday);
+  resultDate.setUTCDate(resultDate.getUTCDate() + daysUntilSunday);
   return resultDate.valueOf();
 }
 

From ce8b11ae131bef6cd7df0849ed39da7984953a4b Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Mon, 6 May 2024 14:32:05 +0800
Subject: [PATCH 40/47] Fix some UI problems (install/checkbox) (#30854)

Fix the space between the box and label for checkboxes, and fix incorrect usages in "repo-issue.js"
---
 templates/install.tmpl            | 314 +++++++++++++++---------------
 web_src/css/install.css           |   8 +-
 web_src/css/modules/checkbox.css  |   2 +-
 web_src/js/features/repo-issue.js |  14 +-
 4 files changed, 170 insertions(+), 168 deletions(-)

diff --git a/templates/install.tmpl b/templates/install.tmpl
index f3117af547..965e57f213 100644
--- a/templates/install.tmpl
+++ b/templates/install.tmpl
@@ -157,168 +157,171 @@
 
 					<!-- Optional Settings -->
 					<h4 class="ui dividing header">{{ctx.Locale.Tr "install.optional_title"}}</h4>
-
-					<!-- Email -->
-					<details class="optional field">
-						<summary class="right-content tw-py-2{{if .Err_SMTP}} text red{{end}}">
-							{{ctx.Locale.Tr "install.email_title"}}
-						</summary>
-						<div class="inline field">
-							<label for="smtp_addr">{{ctx.Locale.Tr "install.smtp_addr"}}</label>
-							<input id="smtp_addr" name="smtp_addr" value="{{.smtp_addr}}">
-						</div>
-						<div class="inline field">
-							<label for="smtp_port">{{ctx.Locale.Tr "install.smtp_port"}}</label>
-							<input id="smtp_port" name="smtp_port" value="{{.smtp_port}}">
-						</div>
-						<div class="inline field {{if .Err_SMTPFrom}}error{{end}}">
-							<label for="smtp_from">{{ctx.Locale.Tr "install.smtp_from"}}</label>
-							<input id="smtp_from" name="smtp_from" value="{{.smtp_from}}">
-							<span class="help">{{ctx.Locale.TrString "install.smtp_from_helper"}}{{/* it contains lt/gt chars*/}}</span>
-						</div>
-						<div class="inline field {{if .Err_SMTPUser}}error{{end}}">
-							<label for="smtp_user">{{ctx.Locale.Tr "install.mailer_user"}}</label>
-							<input id="smtp_user" name="smtp_user" value="{{.smtp_user}}">
-						</div>
-						<div class="inline field">
-							<label for="smtp_passwd">{{ctx.Locale.Tr "install.mailer_password"}}</label>
-							<input id="smtp_passwd" name="smtp_passwd" type="password" value="{{.smtp_passwd}}">
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox">
-								<label>{{ctx.Locale.Tr "install.register_confirm"}}</label>
-								<input name="register_confirm" type="checkbox" {{if .register_confirm}}checked{{end}}>
+					<div>
+						<!-- Email -->
+						<details class="optional field">
+							<summary class="right-content tw-py-2{{if .Err_SMTP}} text red{{end}}">
+								{{ctx.Locale.Tr "install.email_title"}}
+							</summary>
+							<div class="inline field">
+								<label for="smtp_addr">{{ctx.Locale.Tr "install.smtp_addr"}}</label>
+								<input id="smtp_addr" name="smtp_addr" value="{{.smtp_addr}}">
 							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox">
-								<label>{{ctx.Locale.Tr "install.mail_notify"}}</label>
-								<input name="mail_notify" type="checkbox" {{if .mail_notify}}checked{{end}}>
+							<div class="inline field">
+								<label for="smtp_port">{{ctx.Locale.Tr "install.smtp_port"}}</label>
+								<input id="smtp_port" name="smtp_port" value="{{.smtp_port}}">
 							</div>
-						</div>
-					</details>
-
-					<!-- Server and other services -->
-					<details class="optional field">
-						<summary class="right-content tw-py-2{{if .Err_Services}} text red{{end}}">
-							{{ctx.Locale.Tr "install.server_service_title"}}
-						</summary>
-						<div class="inline field">
-							<div class="ui checkbox" id="offline-mode">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.offline_mode_popup"}}">{{ctx.Locale.Tr "install.offline_mode"}}</label>
-								<input name="offline_mode" type="checkbox" {{if .offline_mode}}checked{{end}}>
+							<div class="inline field {{if .Err_SMTPFrom}}error{{end}}">
+								<label for="smtp_from">{{ctx.Locale.Tr "install.smtp_from"}}</label>
+								<input id="smtp_from" name="smtp_from" value="{{.smtp_from}}">
+								<span class="help">{{ctx.Locale.TrString "install.smtp_from_helper"}}{{/* it contains lt/gt chars*/}}</span>
 							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox" id="disable-gravatar">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.disable_gravatar_popup"}}">{{ctx.Locale.Tr "install.disable_gravatar"}}</label>
-								<input name="disable_gravatar" type="checkbox" {{if .disable_gravatar}}checked{{end}}>
+							<div class="inline field {{if .Err_SMTPUser}}error{{end}}">
+								<label for="smtp_user">{{ctx.Locale.Tr "install.mailer_user"}}</label>
+								<input id="smtp_user" name="smtp_user" value="{{.smtp_user}}">
 							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox" id="federated-avatar-lookup">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.federated_avatar_lookup_popup"}}">{{ctx.Locale.Tr "install.federated_avatar_lookup"}}</label>
-								<input name="enable_federated_avatar" type="checkbox" {{if .enable_federated_avatar}}checked{{end}}>
+							<div class="inline field">
+								<label for="smtp_passwd">{{ctx.Locale.Tr "install.mailer_password"}}</label>
+								<input id="smtp_passwd" name="smtp_passwd" type="password" value="{{.smtp_passwd}}">
 							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox" id="enable-openid-signin">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.openid_signin_popup"}}">{{ctx.Locale.Tr "install.openid_signin"}}</label>
-								<input name="enable_open_id_sign_in" type="checkbox" {{if .enable_open_id_sign_in}}checked{{end}}>
-							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox" id="disable-registration">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.disable_registration_popup"}}">{{ctx.Locale.Tr "install.disable_registration"}}</label>
-								<input name="disable_registration" type="checkbox" {{if .disable_registration}}checked{{end}}>
-							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox" id="allow-only-external-registration">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.allow_only_external_registration_popup"}}">{{ctx.Locale.Tr "install.allow_only_external_registration_popup"}}</label>
-								<input name="allow_only_external_registration" type="checkbox" {{if .allow_only_external_registration}}checked{{end}}>
-							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox" id="enable-openid-signup">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.openid_signup_popup"}}">{{ctx.Locale.Tr "install.openid_signup"}}</label>
-								<input name="enable_open_id_sign_up" type="checkbox" {{if .enable_open_id_sign_up}}checked{{end}}>
-							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox" id="enable-captcha">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.enable_captcha_popup"}}">{{ctx.Locale.Tr "install.enable_captcha"}}</label>
-								<input name="enable_captcha" type="checkbox" {{if .enable_captcha}}checked{{end}}>
-							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.require_sign_in_view_popup"}}">{{ctx.Locale.Tr "install.require_sign_in_view"}}</label>
-								<input name="require_sign_in_view" type="checkbox" {{if .require_sign_in_view}}checked{{end}}>
-							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.default_keep_email_private_popup"}}">{{ctx.Locale.Tr "install.default_keep_email_private"}}</label>
-								<input name="default_keep_email_private" type="checkbox" {{if .default_keep_email_private}}checked{{end}}>
-							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.default_allow_create_organization_popup"}}">{{ctx.Locale.Tr "install.default_allow_create_organization"}}</label>
-								<input name="default_allow_create_organization" type="checkbox" {{if .default_allow_create_organization}}checked{{end}}>
-							</div>
-						</div>
-						<div class="inline field">
-							<div class="ui checkbox">
-								<label data-tooltip-content="{{ctx.Locale.Tr "install.default_enable_timetracking_popup"}}">{{ctx.Locale.Tr "install.default_enable_timetracking"}}</label>
-								<input name="default_enable_timetracking" type="checkbox" {{if .default_enable_timetracking}}checked{{end}}>
-							</div>
-						</div>
-						<div class="inline field">
-							<label for="no_reply_address">{{ctx.Locale.Tr "install.no_reply_address"}}</label>
-							<input id="_no_reply_address" name="no_reply_address" value="{{.no_reply_address}}">
-							<span class="help">{{ctx.Locale.Tr "install.no_reply_address_helper"}}</span>
-						</div>
-						<div class="inline field">
-							<label for="password_algorithm">{{ctx.Locale.Tr "install.password_algorithm"}}</label>
-							<div class="ui selection dropdown">
-								<input id="password_algorithm" type="hidden" name="password_algorithm" value="{{.password_algorithm}}">
-								<div class="text">{{.password_algorithm}}</div>
-								{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-								<div class="menu">
-									{{range .PasswordHashAlgorithms}}
-										<div class="item" data-value="{{.}}">{{.}}</div>
-									{{end}}
+							<div class="inline field">
+								<div class="ui checkbox">
+									<label>{{ctx.Locale.Tr "install.register_confirm"}}</label>
+									<input name="register_confirm" type="checkbox" {{if .register_confirm}}checked{{end}}>
 								</div>
 							</div>
-							<span class="help">{{ctx.Locale.Tr "install.password_algorithm_helper"}}</span>
-						</div>
-					</details>
+							<div class="inline field">
+								<div class="ui checkbox">
+									<label>{{ctx.Locale.Tr "install.mail_notify"}}</label>
+									<input name="mail_notify" type="checkbox" {{if .mail_notify}}checked{{end}}>
+								</div>
+							</div>
+						</details>
 
-					<!-- Admin -->
-					<details class="optional field">
-						<summary class="right-content tw-py-2{{if .Err_Admin}} text red{{end}}">
-							{{ctx.Locale.Tr "install.admin_title"}}
-						</summary>
-						<p class="center">{{ctx.Locale.Tr "install.admin_setting_desc"}}</p>
-						<div class="inline field {{if .Err_AdminName}}error{{end}}">
-							<label for="admin_name">{{ctx.Locale.Tr "install.admin_name"}}</label>
-							<input id="admin_name" name="admin_name" value="{{.admin_name}}">
-						</div>
-						<div class="inline field {{if .Err_AdminEmail}}error{{end}}">
-							<label for="admin_email">{{ctx.Locale.Tr "install.admin_email"}}</label>
-							<input id="admin_email" name="admin_email" type="email" value="{{.admin_email}}">
-						</div>
-						<div class="inline field {{if .Err_AdminPasswd}}error{{end}}">
-							<label for="admin_passwd">{{ctx.Locale.Tr "install.admin_password"}}</label>
-							<input id="admin_passwd" name="admin_passwd" type="password" autocomplete="new-password" value="{{.admin_passwd}}">
-						</div>
-						<div class="inline field {{if .Err_AdminPasswd}}error{{end}}">
-							<label for="admin_confirm_passwd">{{ctx.Locale.Tr "install.confirm_password"}}</label>
-							<input id="admin_confirm_passwd" name="admin_confirm_passwd" autocomplete="new-password" type="password" value="{{.admin_confirm_passwd}}">
-						</div>
-					</details>
+						<!-- Server and other services -->
+						<details class="optional field">
+							<summary class="right-content tw-py-2{{if .Err_Services}} text red{{end}}">
+								{{ctx.Locale.Tr "install.server_service_title"}}
+							</summary>
+							<div class="inline field">
+								<div class="ui checkbox" id="offline-mode">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.offline_mode_popup"}}">{{ctx.Locale.Tr "install.offline_mode"}}</label>
+									<input name="offline_mode" type="checkbox" {{if .offline_mode}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox" id="disable-gravatar">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.disable_gravatar_popup"}}">{{ctx.Locale.Tr "install.disable_gravatar"}}</label>
+									<input name="disable_gravatar" type="checkbox" {{if .disable_gravatar}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox" id="federated-avatar-lookup">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.federated_avatar_lookup_popup"}}">{{ctx.Locale.Tr "install.federated_avatar_lookup"}}</label>
+									<input name="enable_federated_avatar" type="checkbox" {{if .enable_federated_avatar}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox" id="enable-openid-signin">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.openid_signin_popup"}}">{{ctx.Locale.Tr "install.openid_signin"}}</label>
+									<input name="enable_open_id_sign_in" type="checkbox" {{if .enable_open_id_sign_in}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox" id="disable-registration">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.disable_registration_popup"}}">{{ctx.Locale.Tr "install.disable_registration"}}</label>
+									<input name="disable_registration" type="checkbox" {{if .disable_registration}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox" id="allow-only-external-registration">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.allow_only_external_registration_popup"}}">{{ctx.Locale.Tr "install.allow_only_external_registration_popup"}}</label>
+									<input name="allow_only_external_registration" type="checkbox" {{if .allow_only_external_registration}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox" id="enable-openid-signup">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.openid_signup_popup"}}">{{ctx.Locale.Tr "install.openid_signup"}}</label>
+									<input name="enable_open_id_sign_up" type="checkbox" {{if .enable_open_id_sign_up}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox" id="enable-captcha">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.enable_captcha_popup"}}">{{ctx.Locale.Tr "install.enable_captcha"}}</label>
+									<input name="enable_captcha" type="checkbox" {{if .enable_captcha}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.require_sign_in_view_popup"}}">{{ctx.Locale.Tr "install.require_sign_in_view"}}</label>
+									<input name="require_sign_in_view" type="checkbox" {{if .require_sign_in_view}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.default_keep_email_private_popup"}}">{{ctx.Locale.Tr "install.default_keep_email_private"}}</label>
+									<input name="default_keep_email_private" type="checkbox" {{if .default_keep_email_private}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.default_allow_create_organization_popup"}}">{{ctx.Locale.Tr "install.default_allow_create_organization"}}</label>
+									<input name="default_allow_create_organization" type="checkbox" {{if .default_allow_create_organization}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<div class="ui checkbox">
+									<label data-tooltip-content="{{ctx.Locale.Tr "install.default_enable_timetracking_popup"}}">{{ctx.Locale.Tr "install.default_enable_timetracking"}}</label>
+									<input name="default_enable_timetracking" type="checkbox" {{if .default_enable_timetracking}}checked{{end}}>
+								</div>
+							</div>
+							<div class="inline field">
+								<label for="no_reply_address">{{ctx.Locale.Tr "install.no_reply_address"}}</label>
+								<input id="_no_reply_address" name="no_reply_address" value="{{.no_reply_address}}">
+								<span class="help">{{ctx.Locale.Tr "install.no_reply_address_helper"}}</span>
+							</div>
+							<div class="inline field">
+								<label for="password_algorithm">{{ctx.Locale.Tr "install.password_algorithm"}}</label>
+								<div class="ui selection dropdown">
+									<input id="password_algorithm" type="hidden" name="password_algorithm" value="{{.password_algorithm}}">
+									<div class="text">{{.password_algorithm}}</div>
+									{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+									<div class="menu">
+										{{range .PasswordHashAlgorithms}}
+											<div class="item" data-value="{{.}}">{{.}}</div>
+										{{end}}
+									</div>
+								</div>
+								<span class="help">{{ctx.Locale.Tr "install.password_algorithm_helper"}}</span>
+							</div>
+						</details>
+
+						<!-- Admin -->
+						<details class="optional field">
+							<summary class="right-content tw-py-2{{if .Err_Admin}} text red{{end}}">
+								{{ctx.Locale.Tr "install.admin_title"}}
+							</summary>
+							<p class="center">{{ctx.Locale.Tr "install.admin_setting_desc"}}</p>
+							<div class="inline field {{if .Err_AdminName}}error{{end}}">
+								<label for="admin_name">{{ctx.Locale.Tr "install.admin_name"}}</label>
+								<input id="admin_name" name="admin_name" value="{{.admin_name}}">
+							</div>
+							<div class="inline field {{if .Err_AdminEmail}}error{{end}}">
+								<label for="admin_email">{{ctx.Locale.Tr "install.admin_email"}}</label>
+								<input id="admin_email" name="admin_email" type="email" value="{{.admin_email}}">
+							</div>
+							<div class="inline field {{if .Err_AdminPasswd}}error{{end}}">
+								<label for="admin_passwd">{{ctx.Locale.Tr "install.admin_password"}}</label>
+								<input id="admin_passwd" name="admin_passwd" type="password" autocomplete="new-password" value="{{.admin_passwd}}">
+							</div>
+							<div class="inline field {{if .Err_AdminPasswd}}error{{end}}">
+								<label for="admin_confirm_passwd">{{ctx.Locale.Tr "install.confirm_password"}}</label>
+								<input id="admin_confirm_passwd" name="admin_confirm_passwd" autocomplete="new-password" type="password" value="{{.admin_confirm_passwd}}">
+							</div>
+						</details>
+					</div>
+
+					<div class="divider"></div>
 
 					{{if .EnvConfigKeys}}
 					<!-- Environment Config -->
@@ -333,12 +336,11 @@
 					</div>
 					{{end}}
 
-					<div class="divider"></div>
 					<div class="inline field">
 						<div class="right-content">
 							These configuration options will be written into: {{.CustomConfFile}}
 						</div>
-						<div class="right-content tw-mt-2">
+						<div class="tw-mt-4 tw-mb-2 tw-text-center">
 							<button class="ui primary button">{{ctx.Locale.Tr "install.install_btn_confirm"}}</button>
 						</div>
 					</div>
diff --git a/web_src/css/install.css b/web_src/css/install.css
index ee2395e6c5..7ab729405e 100644
--- a/web_src/css/install.css
+++ b/web_src/css/install.css
@@ -13,8 +13,7 @@
 .page-content.install .ui.form .field > .help,
 .page-content.install .ui.form .field > .ui.checkbox:first-child,
 .page-content.install .ui.form .field > .right-content {
-  margin-left: 30%;
-  padding-left: 5px;
+  margin-left: calc(30% + 5px);
   width: auto;
 }
 
@@ -24,10 +23,11 @@
 }
 
 .page-content.install form.ui.form details.optional.field[open] {
-  border-bottom: 1px dashed var(--color-secondary);
   padding-bottom: 10px;
 }
-
+.page-content.install form.ui.form details.optional.field[open]:not(:last-child) {
+  border-bottom: 1px dashed var(--color-secondary);
+}
 .page-content.install form.ui.form details.optional.field[open] summary {
   margin-bottom: 10px;
 }
diff --git a/web_src/css/modules/checkbox.css b/web_src/css/modules/checkbox.css
index 8d73573bfa..0a3a71acaa 100644
--- a/web_src/css/modules/checkbox.css
+++ b/web_src/css/modules/checkbox.css
@@ -41,7 +41,7 @@ input[type="radio"] {
 
 .ui.checkbox label,
 .ui.radio.checkbox label {
-  margin-left: 1.85714em;
+  margin-left: 20px;
 }
 
 .ui.checkbox + label {
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index 39c364ca50..d10d4dab8d 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -299,23 +299,23 @@ export function initRepoPullRequestMergeInstruction() {
 export function initRepoPullRequestAllowMaintainerEdit() {
   const wrapper = document.getElementById('allow-edits-from-maintainers');
   if (!wrapper) return;
-
-  wrapper.querySelector('input[type="checkbox"]')?.addEventListener('change', async (e) => {
-    const checked = e.target.checked;
+  const checkbox = wrapper.querySelector('input[type="checkbox"]');
+  checkbox.addEventListener('input', async () => {
     const url = `${wrapper.getAttribute('data-url')}/set_allow_maintainer_edit`;
     wrapper.classList.add('is-loading');
-    e.target.disabled = true;
     try {
-      const response = await POST(url, {data: {allow_maintainer_edit: checked}});
-      if (!response.ok) {
+      const resp = await POST(url, {data: new URLSearchParams({allow_maintainer_edit: checkbox.checked})});
+      if (!resp.ok) {
         throw new Error('Failed to update maintainer edit permission');
       }
+      const data = await resp.json();
+      checkbox.checked = data.allow_maintainer_edit;
     } catch (error) {
+      checkbox.checked = !checkbox.checked;
       console.error(error);
       showTemporaryTooltip(wrapper, wrapper.getAttribute('data-prompt-error'));
     } finally {
       wrapper.classList.remove('is-loading');
-      e.target.disabled = false;
     }
   });
 }

From eda10cc2bb229a6b13ace76caea118384b381429 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Mon, 6 May 2024 15:17:22 +0800
Subject: [PATCH 41/47] Fix some UI problems (dropdown/container) (#30849)

Follow #30345
Follow #30547

`ellipsis` / `white-space` shouldn't be put on the general dropdown components.
---
 templates/devtest/fomantic-dropdown.tmpl      | 109 +++++++++++
 templates/devtest/gitea-ui.tmpl               |  88 ---------
 templates/repo/branch_dropdown.tmpl           |   2 +-
 templates/repo/header.tmpl                    | 184 +++++++++---------
 .../repo/issue/branch_selector_field.tmpl     |   2 +-
 .../view_content/reference_issue_dialog.tmpl  |   2 +-
 templates/repo/settings/options.tmpl          |   2 +-
 web_src/css/base.css                          |  22 ++-
 web_src/css/form.css                          |   4 +
 web_src/css/modules/container.css             |  22 +--
 web_src/css/repo.css                          |   6 +
 .../js/components/RepoBranchTagSelector.vue   |   4 +-
 web_src/js/features/repo-issue.js             |   4 +-
 13 files changed, 246 insertions(+), 205 deletions(-)
 create mode 100644 templates/devtest/fomantic-dropdown.tmpl

diff --git a/templates/devtest/fomantic-dropdown.tmpl b/templates/devtest/fomantic-dropdown.tmpl
new file mode 100644
index 0000000000..57a7c1313e
--- /dev/null
+++ b/templates/devtest/fomantic-dropdown.tmpl
@@ -0,0 +1,109 @@
+{{template "base/head" .}}
+<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/devtest.css?v={{AssetVersion}}">
+<div class="page-content devtest ui container">
+	<div>
+		<h2>Dropdown</h2>
+		<div>
+			<div class="ui dropdown tw-border tw-border-red tw-border-dashed" data-tooltip-content="border for demo purpose only">
+				<span class="text">search-input &amp; flex-item in menu</span>
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+				<div class="menu flex-items-menu">
+					<div class="ui icon search input"><i class="icon">{{svg "octicon-search"}}</i><input type="text" value="search input in menu"></div>
+					<div class="item"><input type="radio">item</div>
+					<div class="item"><input type="radio">item</div>
+				</div>
+			</div>
+			<div class="ui search selection dropdown">
+				<span class="text">search ...</span>
+				<input name="value" class="search">
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+				{{svg "octicon-x" 14 "remove icon"}}
+				<div class="menu">
+					<div class="item">item</div>
+				</div>
+			</div>
+			<div class="ui multiple selection dropdown">
+				<input class="hidden" value="1">
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+				{{svg "octicon-x" 14 "remove icon"}}
+				<div class="default text">empty multiple dropdown</div>
+				<div class="menu">
+					<div class="item">item</div>
+				</div>
+			</div>
+			<div class="ui multiple clearable search selection dropdown">
+				<input type="hidden" value="1">
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+				{{svg "octicon-x" 14 "remove icon"}}
+				<div class="default text">clearable search dropdown</div>
+				<div class="menu">
+					<div class="item" data-value="1">item</div>
+				</div>
+			</div>
+			<div class="ui buttons">
+				<button class="ui button">Button with Dropdown</button>
+				<div class="ui dropdown button icon">
+					{{svg "octicon-triangle-down"}}
+					<div class="menu">
+						<div class="item">item</div>
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<h2>Selection</h2>
+		<div>
+			{{/* the "selection" class is optional, it will be added by JS automatically */}}
+			<select class="ui dropdown selection ellipsis-items-nowrap">
+				<option>a</option>
+				<option>abcdefuvwxyz</option>
+				<option>loooooooooooooooooooooooooooooooooooooooooooooooooooooooooong</option>
+			</select>
+			<select class="ui dropdown ellipsis-items-nowrap tw-max-w-[8em]">
+				<option>loooooooooooooooooooooooooooooooooooooooooooooooooooooooooong</option>
+				<option>abcdefuvwxyz</option>
+				<option>a</option>
+			</select>
+		</div>
+		<h2>Dropdown Button (demo only without menu)</h2>
+		<div>
+			<div class="ui dropdown mini button">
+				<span class="text">mini dropdown</span>
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+			</div>
+			<div class="ui dropdown tiny button">
+				<span class="text">tiny dropdown</span>
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+			</div>
+			<div class="ui button dropdown">
+				<span class="text">button dropdown</span>
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+			</div>
+		</div>
+
+		<div>
+			<div class="ui dropdown mini compact button">
+				<span class="text">mini compact</span>
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+			</div>
+			<div class="ui dropdown tiny compact button">
+				<span class="text">tiny compact</span>
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+			</div>
+			<div class="ui button compact dropdown">
+				<span class="text">button compact</span>
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+			</div>
+		</div>
+
+		<div>
+			<hr>
+			<div class="ui tiny button">Other button align with ...</div>
+			<div class="ui dropdown tiny button">
+				<span class="text">... Dropdown Button</span>
+				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+			</div>
+		</div>
+	</div>
+</div>
+{{template "base/footer" .}}
diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl
index 3b13c13be8..ea293fd3b4 100644
--- a/templates/devtest/gitea-ui.tmpl
+++ b/templates/devtest/gitea-ui.tmpl
@@ -180,94 +180,6 @@
 				<input type="text" placeholder="place holder">
 			</div>
 		</div>
-
-		<h2>Dropdown with SVG</h2>
-		<div>
-			<div class="ui dropdown tw-border tw-border-red tw-border-dashed" data-tooltip-content="border for demo purpose only">
-				<span class="text">search-input &amp; flex-item in menu</span>
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-				<div class="menu flex-items-menu">
-					<div class="ui icon search input"><i class="icon">{{svg "octicon-search"}}</i><input type="text" value="search input in menu"></div>
-					<div class="item"><input type="radio">item</div>
-					<div class="item"><input type="radio">item</div>
-				</div>
-			</div>
-			<div class="ui search selection dropdown">
-				<span class="text">search ...</span>
-				<input name="value" class="search">
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-				{{svg "octicon-x" 14 "remove icon"}}
-				<div class="menu">
-					<div class="item">item</div>
-				</div>
-			</div>
-			<div class="ui multiple selection dropdown">
-				<input class="hidden" value="1">
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-				{{svg "octicon-x" 14 "remove icon"}}
-				<div class="default text">empty multiple dropdown</div>
-				<div class="menu">
-					<div class="item">item</div>
-				</div>
-			</div>
-			<div class="ui multiple clearable search selection dropdown">
-				<input type="hidden" value="1">
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-				{{svg "octicon-x" 14 "remove icon"}}
-				<div class="default text">clearable search dropdown</div>
-				<div class="menu">
-					<div class="item" data-value="1">item</div>
-				</div>
-			</div>
-			<div class="ui buttons">
-				<button class="ui button">Button with Dropdown</button>
-				<div class="ui dropdown button icon">
-					{{svg "octicon-triangle-down"}}
-					<div class="menu">
-						<div class="item">item</div>
-					</div>
-				</div>
-			</div>
-		</div>
-
-		<div>
-			<div class="ui dropdown mini button">
-				<span class="text">mini dropdown</span>
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-			</div>
-			<div class="ui dropdown tiny button">
-				<span class="text">tiny dropdown</span>
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-			</div>
-			<div class="ui button dropdown">
-				<span class="text">button dropdown</span>
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-			</div>
-		</div>
-
-		<div>
-			<div class="ui dropdown mini compact button">
-				<span class="text">mini compact</span>
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-			</div>
-			<div class="ui dropdown tiny compact button">
-				<span class="text">tiny compact</span>
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-			</div>
-			<div class="ui button compact dropdown">
-				<span class="text">button compact</span>
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-			</div>
-		</div>
-
-		<div>
-			<hr>
-			<div class="ui tiny button">Button align with ...</div>
-			<div class="ui dropdown tiny button">
-				<span class="text">... Dropdown Button</span>
-				{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-			</div>
-		</div>
 	</div>
 
 	<div>
diff --git a/templates/repo/branch_dropdown.tmpl b/templates/repo/branch_dropdown.tmpl
index 8f58826c6a..c4f73875f2 100644
--- a/templates/repo/branch_dropdown.tmpl
+++ b/templates/repo/branch_dropdown.tmpl
@@ -69,7 +69,7 @@
 
 <div class="js-branch-tag-selector {{if .ContainerClasses}}{{.ContainerClasses}}{{end}}">
 	{{/* show dummy elements before Vue componment is mounted, this code must match the code in BranchTagSelector.vue */}}
-	<div class="ui dropdown custom branch-selector-dropdown">
+	<div class="ui dropdown custom branch-selector-dropdown ellipsis-items-nowrap">
 		<div class="ui button branch-dropdown-button">
 			<span class="flex-text-block gt-ellipsis">
 				{{if .release}}
diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl
index c0d833a187..34f47b7d89 100644
--- a/templates/repo/header.tmpl
+++ b/templates/repo/header.tmpl
@@ -128,107 +128,109 @@
 		{{if .IsGenerated}}<div class="fork-flag">{{ctx.Locale.Tr "repo.generated_from"}} <a href="{{(.TemplateRepo ctx).Link}}">{{(.TemplateRepo ctx).FullName}}</a></div>{{end}}
 	</div>
 {{end}}
-	<overflow-menu class="ui container secondary pointing tabular top attached borderless menu tw-pt-0 tw-my-0">
-		{{if not (or .Repository.IsBeingCreated .Repository.IsBroken)}}
-			<div class="overflow-menu-items">
-				{{if .Permission.CanRead ctx.Consts.RepoUnitTypeCode}}
-				<a class="{{if .PageIsViewCode}}active {{end}}item" href="{{.RepoLink}}{{if and (ne .BranchName .Repository.DefaultBranch) (not $.PageIsWiki)}}/src/{{.BranchNameSubURL}}{{end}}">
-					{{svg "octicon-code"}} {{ctx.Locale.Tr "repo.code"}}
-				</a>
-				{{end}}
-
-				{{if .Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
-					<a class="{{if .PageIsIssueList}}active {{end}}item" href="{{.RepoLink}}/issues">
-						{{svg "octicon-issue-opened"}} {{ctx.Locale.Tr "repo.issues"}}
-						{{if .Repository.NumOpenIssues}}
-							<span class="ui small label">{{CountFmt .Repository.NumOpenIssues}}</span>
-						{{end}}
+	<div class="ui container">
+		<overflow-menu class="ui secondary pointing menu">
+			{{if not (or .Repository.IsBeingCreated .Repository.IsBroken)}}
+				<div class="overflow-menu-items">
+					{{if .Permission.CanRead ctx.Consts.RepoUnitTypeCode}}
+					<a class="{{if .PageIsViewCode}}active {{end}}item" href="{{.RepoLink}}{{if and (ne .BranchName .Repository.DefaultBranch) (not $.PageIsWiki)}}/src/{{.BranchNameSubURL}}{{end}}">
+						{{svg "octicon-code"}} {{ctx.Locale.Tr "repo.code"}}
 					</a>
-				{{end}}
-
-				{{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalTracker}}
-					<a class="{{if .PageIsIssueList}}active {{end}}item" href="{{.RepoExternalIssuesLink}}" target="_blank" rel="noopener noreferrer">
-						{{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.issues"}}
-					</a>
-				{{end}}
-
-				{{if and .Repository.CanEnablePulls (.Permission.CanRead ctx.Consts.RepoUnitTypePullRequests)}}
-					<a class="{{if .PageIsPullList}}active {{end}}item" href="{{.RepoLink}}/pulls">
-						{{svg "octicon-git-pull-request"}} {{ctx.Locale.Tr "repo.pulls"}}
-						{{if .Repository.NumOpenPulls}}
-							<span class="ui small label">{{CountFmt .Repository.NumOpenPulls}}</span>
-						{{end}}
-					</a>
-				{{end}}
-
-				{{if and .EnableActions (not .UnitActionsGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}}
-					<a class="{{if .PageIsActions}}active {{end}}item" href="{{.RepoLink}}/actions">
-						{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}
-						{{if .Repository.NumOpenActionRuns}}
-							<span class="ui small label">{{CountFmt .Repository.NumOpenActionRuns}}</span>
-						{{end}}
-					</a>
-				{{end}}
-
-				{{if .Permission.CanRead ctx.Consts.RepoUnitTypePackages}}
-					<a href="{{.RepoLink}}/packages" class="{{if .IsPackagesPage}}active {{end}}item">
-						{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
-					</a>
-				{{end}}
-
-				{{$projectsUnit := .Repository.MustGetUnit $.Context ctx.Consts.RepoUnitTypeProjects}}
-				{{if and (not .UnitProjectsGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
-					<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item">
-						{{svg "octicon-project"}} {{ctx.Locale.Tr "repo.project_board"}}
-						{{if .Repository.NumOpenProjects}}
-							<span class="ui small label">{{CountFmt .Repository.NumOpenProjects}}</span>
-						{{end}}
-					</a>
-				{{end}}
-
-				{{if and (.Permission.CanRead ctx.Consts.RepoUnitTypeReleases) (not .IsEmptyRepo)}}
-				<a class="{{if or .PageIsReleaseList .PageIsTagList}}active {{end}}item" href="{{.RepoLink}}/releases">
-					{{svg "octicon-tag"}} {{ctx.Locale.Tr "repo.releases"}}
-					{{if .NumReleases}}
-						<span class="ui small label">{{CountFmt .NumReleases}}</span>
 					{{end}}
-				</a>
-				{{end}}
 
-				{{if .Permission.CanRead ctx.Consts.RepoUnitTypeWiki}}
-					<a class="{{if .PageIsWiki}}active {{end}}item" href="{{.RepoLink}}/wiki">
-						{{svg "octicon-book"}} {{ctx.Locale.Tr "repo.wiki"}}
+					{{if .Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
+						<a class="{{if .PageIsIssueList}}active {{end}}item" href="{{.RepoLink}}/issues">
+							{{svg "octicon-issue-opened"}} {{ctx.Locale.Tr "repo.issues"}}
+							{{if .Repository.NumOpenIssues}}
+								<span class="ui small label">{{CountFmt .Repository.NumOpenIssues}}</span>
+							{{end}}
+						</a>
+					{{end}}
+
+					{{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalTracker}}
+						<a class="{{if .PageIsIssueList}}active {{end}}item" href="{{.RepoExternalIssuesLink}}" target="_blank" rel="noopener noreferrer">
+							{{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.issues"}}
+						</a>
+					{{end}}
+
+					{{if and .Repository.CanEnablePulls (.Permission.CanRead ctx.Consts.RepoUnitTypePullRequests)}}
+						<a class="{{if .PageIsPullList}}active {{end}}item" href="{{.RepoLink}}/pulls">
+							{{svg "octicon-git-pull-request"}} {{ctx.Locale.Tr "repo.pulls"}}
+							{{if .Repository.NumOpenPulls}}
+								<span class="ui small label">{{CountFmt .Repository.NumOpenPulls}}</span>
+							{{end}}
+						</a>
+					{{end}}
+
+					{{if and .EnableActions (not .UnitActionsGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}}
+						<a class="{{if .PageIsActions}}active {{end}}item" href="{{.RepoLink}}/actions">
+							{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}
+							{{if .Repository.NumOpenActionRuns}}
+								<span class="ui small label">{{CountFmt .Repository.NumOpenActionRuns}}</span>
+							{{end}}
+						</a>
+					{{end}}
+
+					{{if .Permission.CanRead ctx.Consts.RepoUnitTypePackages}}
+						<a href="{{.RepoLink}}/packages" class="{{if .IsPackagesPage}}active {{end}}item">
+							{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
+						</a>
+					{{end}}
+
+					{{$projectsUnit := .Repository.MustGetUnit $.Context ctx.Consts.RepoUnitTypeProjects}}
+					{{if and (not .UnitProjectsGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
+						<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item">
+							{{svg "octicon-project"}} {{ctx.Locale.Tr "repo.project_board"}}
+							{{if .Repository.NumOpenProjects}}
+								<span class="ui small label">{{CountFmt .Repository.NumOpenProjects}}</span>
+							{{end}}
+						</a>
+					{{end}}
+
+					{{if and (.Permission.CanRead ctx.Consts.RepoUnitTypeReleases) (not .IsEmptyRepo)}}
+					<a class="{{if or .PageIsReleaseList .PageIsTagList}}active {{end}}item" href="{{.RepoLink}}/releases">
+						{{svg "octicon-tag"}} {{ctx.Locale.Tr "repo.releases"}}
+						{{if .NumReleases}}
+							<span class="ui small label">{{CountFmt .NumReleases}}</span>
+						{{end}}
 					</a>
-				{{end}}
+					{{end}}
 
-				{{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalWiki}}
-					<a class="item" href="{{(.Repository.MustGetUnit $.Context ctx.Consts.RepoUnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL}}" target="_blank" rel="noopener noreferrer">
-						{{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.wiki"}}
-					</a>
-				{{end}}
+					{{if .Permission.CanRead ctx.Consts.RepoUnitTypeWiki}}
+						<a class="{{if .PageIsWiki}}active {{end}}item" href="{{.RepoLink}}/wiki">
+							{{svg "octicon-book"}} {{ctx.Locale.Tr "repo.wiki"}}
+						</a>
+					{{end}}
 
-				{{if and (.Permission.CanReadAny ctx.Consts.RepoUnitTypePullRequests ctx.Consts.RepoUnitTypeIssues ctx.Consts.RepoUnitTypeReleases) (not .IsEmptyRepo)}}
-					<a class="{{if .PageIsActivity}}active {{end}}item" href="{{.RepoLink}}/activity">
-						{{svg "octicon-pulse"}} {{ctx.Locale.Tr "repo.activity"}}
-					</a>
-				{{end}}
+					{{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalWiki}}
+						<a class="item" href="{{(.Repository.MustGetUnit $.Context ctx.Consts.RepoUnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL}}" target="_blank" rel="noopener noreferrer">
+							{{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.wiki"}}
+						</a>
+					{{end}}
 
-				{{template "custom/extra_tabs" .}}
+					{{if and (.Permission.CanReadAny ctx.Consts.RepoUnitTypePullRequests ctx.Consts.RepoUnitTypeIssues ctx.Consts.RepoUnitTypeReleases) (not .IsEmptyRepo)}}
+						<a class="{{if .PageIsActivity}}active {{end}}item" href="{{.RepoLink}}/activity">
+							{{svg "octicon-pulse"}} {{ctx.Locale.Tr "repo.activity"}}
+						</a>
+					{{end}}
 
-				{{if .Permission.IsAdmin}}
-					<span class="item-flex-space"></span>
+					{{template "custom/extra_tabs" .}}
+
+					{{if .Permission.IsAdmin}}
+						<span class="item-flex-space"></span>
+						<a class="{{if .PageIsRepoSettings}}active {{end}} item" href="{{.RepoLink}}/settings">
+							{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
+						</a>
+					{{end}}
+				</div>
+			{{else if .Permission.IsAdmin}}
+				<div class="overflow-menu-items">
 					<a class="{{if .PageIsRepoSettings}}active {{end}} item" href="{{.RepoLink}}/settings">
 						{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
 					</a>
-				{{end}}
-			</div>
-		{{else if .Permission.IsAdmin}}
-			<div class="overflow-menu-items">
-				<a class="{{if .PageIsRepoSettings}}active {{end}} item" href="{{.RepoLink}}/settings">
-					{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
-				</a>
-			</div>
-		{{end}}
-	</overflow-menu>
+				</div>
+			{{end}}
+		</overflow-menu>
+	</div>
 	<div class="ui tabs divider"></div>
 </div>
diff --git a/templates/repo/issue/branch_selector_field.tmpl b/templates/repo/issue/branch_selector_field.tmpl
index e9e5574cd7..cbf7929fdb 100644
--- a/templates/repo/issue/branch_selector_field.tmpl
+++ b/templates/repo/issue/branch_selector_field.tmpl
@@ -4,7 +4,7 @@
 <form method="post" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/ref" id="update_issueref_form">
 	{{$.CsrfTokenHtml}}
 </form>
-<div class="ui dropdown select-branch branch-selector-dropdown {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}}"
+<div class="ui dropdown select-branch branch-selector-dropdown ellipsis-items-nowrap {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}}"
 	data-no-results="{{ctx.Locale.Tr "no_results_found"}}"
 	{{if not .Issue}}data-for-new-issue="true"{{end}}
 >
diff --git a/templates/repo/issue/view_content/reference_issue_dialog.tmpl b/templates/repo/issue/view_content/reference_issue_dialog.tmpl
index c7a471f55e..d6c9081001 100644
--- a/templates/repo/issue/view_content/reference_issue_dialog.tmpl
+++ b/templates/repo/issue/view_content/reference_issue_dialog.tmpl
@@ -7,7 +7,7 @@
 			{{.CsrfTokenHtml}}
 			<div class="field">
 				<label><strong>{{ctx.Locale.Tr "repository"}}</strong></label>
-				<div class="ui search selection dropdown issue_reference_repository_search">
+				<div class="ui search selection dropdown issue_reference_repository_search ellipsis-items-nowrap">
 					<div class="default text gt-ellipsis">{{.Repository.FullName}}</div>
 					<div class="menu"></div>
 				</div>
diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl
index 40617021d9..b94c202f16 100644
--- a/templates/repo/settings/options.tmpl
+++ b/templates/repo/settings/options.tmpl
@@ -345,7 +345,7 @@
 						<div class="inline field">
 							{{$unitInternalWiki := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki}}
 							<label>{{ctx.Locale.Tr "repo.settings.default_wiki_everyone_access"}}</label>
-							<select name="default_wiki_everyone_access" class="ui dropdown">
+							<select name="default_wiki_everyone_access" class="ui selection dropdown">
 								{{/* everyone access mode is different from others, none means it is unset and won't be applied */}}
 								<option value="none" {{Iif (eq $unitInternalWiki.EveryoneAccessMode 0) "selected"}}>{{ctx.Locale.Tr "settings.permission_not_set"}}</option>
 								<option value="read" {{Iif (eq $unitInternalWiki.EveryoneAccessMode 1) "selected"}}>{{ctx.Locale.Tr "settings.permission_read"}}</option>
diff --git a/web_src/css/base.css b/web_src/css/base.css
index c0ced2955c..412d9094e3 100644
--- a/web_src/css/base.css
+++ b/web_src/css/base.css
@@ -342,8 +342,6 @@ a.label,
 
 .ui.dropdown .menu > .item {
   color: var(--color-text);
-  overflow: hidden;
-  text-overflow: ellipsis;
 }
 
 .ui.dropdown .menu > .item:hover {
@@ -374,7 +372,6 @@ a.label,
 
 .ui.selection.dropdown .menu > .item {
   border-color: var(--color-secondary);
-  white-space: nowrap;
 }
 
 .ui.selection.visible.dropdown > .text:not(.default) {
@@ -1342,7 +1339,11 @@ table th[data-sortt-desc] .svg {
   align-items: center;
   gap: .25rem;
   vertical-align: middle;
-  min-width: 0;
+  min-width: 0; /* make ellipsis work */
+}
+
+.ui.ui.dropdown.selection {
+  min-width: 14em; /* match the default min width */
 }
 
 .ui.dropdown .ui.label .svg {
@@ -1369,3 +1370,16 @@ table th[data-sortt-desc] .svg {
   gap: .5rem;
   min-width: 0;
 }
+
+.ui.dropdown.ellipsis-items-nowrap > .text {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.ellipsis-items-nowrap > .item,
+.ui.dropdown.ellipsis-items-nowrap .menu > .item {
+  white-space: nowrap !important;
+  overflow: hidden !important;
+  text-overflow: ellipsis !important;
+}
diff --git a/web_src/css/form.css b/web_src/css/form.css
index 7479af0c4e..66ead32762 100644
--- a/web_src/css/form.css
+++ b/web_src/css/form.css
@@ -448,6 +448,10 @@ textarea:focus,
   }
 }
 
+.ui.form .field > .selection.dropdown {
+  min-width: 14em; /* matches the default min width */
+}
+
 .new.webhook form .help {
   margin-left: 25px;
 }
diff --git a/web_src/css/modules/container.css b/web_src/css/modules/container.css
index 9f67ceb8d5..c9df6ab3f5 100644
--- a/web_src/css/modules/container.css
+++ b/web_src/css/modules/container.css
@@ -2,26 +2,20 @@
    unused rules here after refactoring, please remove them. */
 
 .ui.container {
-  display: block;
-  max-width: 100%;
-}
-
-.ui.fluid.container {
-  width: 100%;
-}
-
-.ui[class*="center aligned"].container {
-  text-align: center;
-}
-
-/* overwrite width of containers inside the main page content div (div with class "page-content") */
-.page-content .ui.ui.ui.container:not(.fluid) {
   width: 1280px;
   max-width: calc(100% - calc(2 * var(--page-margin-x)));
   margin-left: auto;
   margin-right: auto;
 }
 
+.ui.fluid.container {
+  width: 100%;
+}
+
 .ui.container.fluid.padded {
   padding: 0 var(--page-margin-x);
 }
+
+.ui[class*="center aligned"].container {
+  text-align: center;
+}
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 7695b632b4..f02b2b7578 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -2860,6 +2860,10 @@ tbody.commit-list {
   margin-top: 4px;
 }
 
+.ui.dropdown.branch-selector-dropdown .scrolling.menu {
+  max-width: min(400px, 90vw);
+}
+
 .branch-selector-dropdown .branch-dropdown-button {
   margin: 0;
   max-width: 340px;
@@ -2909,6 +2913,8 @@ tbody.commit-list {
 }
 
 .branch-selector-dropdown .menu .item .rss-icon {
+  position: absolute;
+  right: 4px;
   visibility: hidden; /* only show RSS icon on hover */
 }
 
diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue
index 8a741b68da..87530225e3 100644
--- a/web_src/js/components/RepoBranchTagSelector.vue
+++ b/web_src/js/components/RepoBranchTagSelector.vue
@@ -246,7 +246,7 @@ export function initRepoBranchTagSelector(selector) {
 export default sfc; // activate IDE's Vue plugin
 </script>
 <template>
-  <div class="ui dropdown custom branch-selector-dropdown">
+  <div class="ui dropdown custom branch-selector-dropdown ellipsis-items-nowrap">
     <div class="ui button branch-dropdown-button" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
       <span class="flex-text-block gt-ellipsis">
         <template v-if="release">{{ textReleaseCompare }}</template>
@@ -280,7 +280,7 @@ export default sfc; // activate IDE's Vue plugin
           <div class="ui label" v-if="item.name===repoDefaultBranch && mode === 'branches'">
             {{ textDefaultBranchLabel }}
           </div>
-          <a v-show="enableFeed && mode === 'branches'" role="button" class="rss-icon tw-float-right" :href="rssURLPrefix + item.url" target="_blank" @click.stop>
+          <a v-show="enableFeed && mode === 'branches'" role="button" class="rss-icon" :href="rssURLPrefix + item.url" target="_blank" @click.stop>
             <!-- creating a lot of Vue component is pretty slow, so we use a static SVG here -->
             <svg width="14" height="14" class="svg octicon-rss"><use href="#svg-symbol-octicon-rss"/></svg>
           </a>
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index d10d4dab8d..8ee681aedc 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -124,8 +124,8 @@ export function initRepoIssueSidebarList() {
               return;
             }
             filteredResponse.results.push({
-              name: `#${issue.number} ${htmlEscape(issue.title)
-              }<div class="text small gt-word-break">${htmlEscape(issue.repository.full_name)}</div>`,
+              name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div>
+<div class="text small gt-word-break">${htmlEscape(issue.repository.full_name)}</div>`,
               value: issue.id,
             });
           });

From 8e8ca6c6530e49e39f970bdfa84716ffda8973d0 Mon Sep 17 00:00:00 2001
From: 6543 <6543@obermui.de>
Date: Mon, 6 May 2024 16:36:02 +0200
Subject: [PATCH 42/47] Get repo list with OrderBy alpha should respect owner
 too (#30784)

instead of:
- zowner/gcode
- awesome/nul
- zowner/nul
- zowner/zzz

we will get:
- awesome/nul
- zowner/gcode
- zowner/nul
- zowner/zzz
---
 models/repo/search.go | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/models/repo/search.go b/models/repo/search.go
index 4d64acf8cf..54d6dcfb44 100644
--- a/models/repo/search.go
+++ b/models/repo/search.go
@@ -8,14 +8,14 @@ import "code.gitea.io/gitea/models/db"
 // SearchOrderByMap represents all possible search order
 var SearchOrderByMap = map[string]map[string]db.SearchOrderBy{
 	"asc": {
-		"alpha":   db.SearchOrderByAlphabetically,
+		"alpha":   "owner_name ASC, name ASC",
 		"created": db.SearchOrderByOldest,
 		"updated": db.SearchOrderByLeastUpdated,
 		"size":    db.SearchOrderBySize,
 		"id":      db.SearchOrderByID,
 	},
 	"desc": {
-		"alpha":   db.SearchOrderByAlphabeticallyReverse,
+		"alpha":   "owner_name DESC, name DESC",
 		"created": db.SearchOrderByNewest,
 		"updated": db.SearchOrderByRecentUpdated,
 		"size":    db.SearchOrderBySizeReverse,

From 7c613f100e032f821df88a75954fc50b1cf2f926 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Tue, 7 May 2024 00:34:16 +0800
Subject: [PATCH 43/47] Make sure git version&feature are always prepared
 (#30877)

Otherwise there would be more similar issues like #29287
---
 cmd/hook.go                            |   7 +-
 cmd/serv.go                            |   2 +-
 modules/git/blame.go                   |   2 +-
 modules/git/commit.go                  |   2 +-
 modules/git/git.go                     | 187 +++++++++++--------------
 modules/git/object_format.go           |   6 +-
 modules/git/object_id.go               |   2 +-
 modules/git/remote.go                  |   2 +-
 modules/git/repo.go                    |   2 +-
 modules/git/repo_base.go               |   6 -
 modules/git/repo_base_gogit.go         |   4 +-
 modules/git/repo_base_nogogit.go       |   4 +-
 modules/git/repo_commit.go             |   2 +-
 modules/git/repo_commitgraph.go        |   2 +-
 modules/lfs/pointer_scanner_nogogit.go |   2 +-
 routers/init.go                        |   6 +-
 routers/private/hook_pre_receive.go    |   2 +-
 routers/private/hook_proc_receive.go   |   2 +-
 routers/private/serv.go                |   2 +-
 routers/web/admin/config.go            |   2 +-
 routers/web/misc/misc.go               |   2 +-
 routers/web/repo/githttp.go            |   2 +-
 routers/web/repo/repo.go               |   2 +-
 services/gitdiff/gitdiff.go            |   2 +-
 services/pull/patch.go                 |   2 +-
 services/pull/temp_repo.go             |   2 +-
 services/repository/files/patch.go     |   2 +-
 tests/integration/git_test.go          |   2 +-
 28 files changed, 116 insertions(+), 146 deletions(-)
 delete mode 100644 modules/git/repo_base.go

diff --git a/cmd/hook.go b/cmd/hook.go
index 2a9c25add5..9c1cb66f2a 100644
--- a/cmd/hook.go
+++ b/cmd/hook.go
@@ -220,10 +220,7 @@ Gitea or set your environment appropriately.`, "")
 		}
 	}
 
-	supportProcReceive := false
-	if git.CheckGitVersionAtLeast("2.29") == nil {
-		supportProcReceive = true
-	}
+	supportProcReceive := git.DefaultFeatures().SupportProcReceive
 
 	for scanner.Scan() {
 		// TODO: support news feeds for wiki
@@ -497,7 +494,7 @@ Gitea or set your environment appropriately.`, "")
 		return nil
 	}
 
-	if git.CheckGitVersionAtLeast("2.29") != nil {
+	if !git.DefaultFeatures().SupportProcReceive {
 		return fail(ctx, "No proc-receive support", "current git version doesn't support proc-receive.")
 	}
 
diff --git a/cmd/serv.go b/cmd/serv.go
index 90190a19db..2bfd111061 100644
--- a/cmd/serv.go
+++ b/cmd/serv.go
@@ -178,7 +178,7 @@ func runServ(c *cli.Context) error {
 	}
 
 	if len(words) < 2 {
-		if git.CheckGitVersionAtLeast("2.29") == nil {
+		if git.DefaultFeatures().SupportProcReceive {
 			// for AGit Flow
 			if cmd == "ssh_info" {
 				fmt.Print(`{"type":"gitea","version":1}`)
diff --git a/modules/git/blame.go b/modules/git/blame.go
index 69e1b08f93..a9b2706f21 100644
--- a/modules/git/blame.go
+++ b/modules/git/blame.go
@@ -132,7 +132,7 @@ func (r *BlameReader) Close() error {
 // CreateBlameReader creates reader for given repository, commit and file
 func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (*BlameReader, error) {
 	var ignoreRevsFile *string
-	if CheckGitVersionAtLeast("2.23") == nil && !bypassBlameIgnore {
+	if DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore {
 		ignoreRevsFile = tryCreateBlameIgnoreRevsFile(commit)
 	}
 
diff --git a/modules/git/commit.go b/modules/git/commit.go
index d96cef37c8..86adaa79a6 100644
--- a/modules/git/commit.go
+++ b/modules/git/commit.go
@@ -423,7 +423,7 @@ func (c *Commit) GetSubModule(entryname string) (*SubModule, error) {
 // GetBranchName gets the closest branch name (as returned by 'git name-rev --name-only')
 func (c *Commit) GetBranchName() (string, error) {
 	cmd := NewCommand(c.repo.Ctx, "name-rev")
-	if CheckGitVersionAtLeast("2.13.0") == nil {
+	if DefaultFeatures().CheckVersionAtLeast("2.13.0") {
 		cmd.AddArguments("--exclude", "refs/tags/*")
 	}
 	cmd.AddArguments("--name-only", "--no-undefined").AddDynamicArguments(c.ID.String())
diff --git a/modules/git/git.go b/modules/git/git.go
index e411269f7c..05ca260855 100644
--- a/modules/git/git.go
+++ b/modules/git/git.go
@@ -22,42 +22,63 @@ import (
 	"github.com/hashicorp/go-version"
 )
 
-// RequiredVersion is the minimum Git version required
-const RequiredVersion = "2.0.0"
+const RequiredVersion = "2.0.0" // the minimum Git version required
+
+type Features struct {
+	gitVersion *version.Version
+
+	UsingGogit             bool
+	SupportProcReceive     bool           // >= 2.29
+	SupportHashSha256      bool           // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’
+	SupportedObjectFormats []ObjectFormat // sha1, sha256
+}
 
 var (
-	// GitExecutable is the command name of git
-	// Could be updated to an absolute path while initialization
-	GitExecutable = "git"
-
-	// DefaultContext is the default context to run git commands in, must be initialized by git.InitXxx
-	DefaultContext context.Context
-
-	DefaultFeatures struct {
-		GitVersion *version.Version
-
-		SupportProcReceive bool // >= 2.29
-		SupportHashSha256  bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’
-	}
+	GitExecutable   = "git"         // the command name of git, will be updated to an absolute path during initialization
+	DefaultContext  context.Context // the default context to run git commands in, must be initialized by git.InitXxx
+	defaultFeatures *Features
 )
 
-// loadGitVersion tries to get the current git version and stores it into a global variable
-func loadGitVersion() error {
-	// doesn't need RWMutex because it's executed by Init()
-	if DefaultFeatures.GitVersion != nil {
-		return nil
-	}
+func (f *Features) CheckVersionAtLeast(atLeast string) bool {
+	return f.gitVersion.Compare(version.Must(version.NewVersion(atLeast))) >= 0
+}
 
+// VersionInfo returns git version information
+func (f *Features) VersionInfo() string {
+	return f.gitVersion.Original()
+}
+
+func DefaultFeatures() *Features {
+	if defaultFeatures == nil {
+		if !setting.IsProd || setting.IsInTesting {
+			log.Warn("git.DefaultFeatures is called before git.InitXxx, initializing with default values")
+		}
+		if err := InitSimple(context.Background()); err != nil {
+			log.Fatal("git.InitSimple failed: %v", err)
+		}
+	}
+	return defaultFeatures
+}
+
+func loadGitVersionFeatures() (*Features, error) {
 	stdout, _, runErr := NewCommand(DefaultContext, "version").RunStdString(nil)
 	if runErr != nil {
-		return runErr
+		return nil, runErr
 	}
 
 	ver, err := parseGitVersionLine(strings.TrimSpace(stdout))
-	if err == nil {
-		DefaultFeatures.GitVersion = ver
+	if err != nil {
+		return nil, err
 	}
-	return err
+
+	features := &Features{gitVersion: ver, UsingGogit: isGogit}
+	features.SupportProcReceive = features.CheckVersionAtLeast("2.29")
+	features.SupportHashSha256 = features.CheckVersionAtLeast("2.42") && !isGogit
+	features.SupportedObjectFormats = []ObjectFormat{Sha1ObjectFormat}
+	if features.SupportHashSha256 {
+		features.SupportedObjectFormats = append(features.SupportedObjectFormats, Sha256ObjectFormat)
+	}
+	return features, nil
 }
 
 func parseGitVersionLine(s string) (*version.Version, error) {
@@ -85,56 +106,24 @@ func SetExecutablePath(path string) error {
 		return fmt.Errorf("git not found: %w", err)
 	}
 	GitExecutable = absPath
+	return nil
+}
 
-	if err = loadGitVersion(); err != nil {
-		return fmt.Errorf("unable to load git version: %w", err)
-	}
-
-	versionRequired, err := version.NewVersion(RequiredVersion)
-	if err != nil {
-		return err
-	}
-
-	if DefaultFeatures.GitVersion.LessThan(versionRequired) {
+func ensureGitVersion() error {
+	if !DefaultFeatures().CheckVersionAtLeast(RequiredVersion) {
 		moreHint := "get git: https://git-scm.com/download/"
 		if runtime.GOOS == "linux" {
 			// there are a lot of CentOS/RHEL users using old git, so we add a special hint for them
-			if _, err = os.Stat("/etc/redhat-release"); err == nil {
+			if _, err := os.Stat("/etc/redhat-release"); err == nil {
 				// ius.io is the recommended official(git-scm.com) method to install git
 				moreHint = "get git: https://git-scm.com/download/linux and https://ius.io"
 			}
 		}
-		return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", DefaultFeatures.GitVersion.Original(), RequiredVersion, moreHint)
+		return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", DefaultFeatures().gitVersion.Original(), RequiredVersion, moreHint)
 	}
 
-	if err = checkGitVersionCompatibility(DefaultFeatures.GitVersion); err != nil {
-		return fmt.Errorf("installed git version %s has a known compatibility issue with Gitea: %w, please upgrade (or downgrade) git", DefaultFeatures.GitVersion.String(), err)
-	}
-	return nil
-}
-
-// VersionInfo returns git version information
-func VersionInfo() string {
-	if DefaultFeatures.GitVersion == nil {
-		return "(git not found)"
-	}
-	format := "%s"
-	args := []any{DefaultFeatures.GitVersion.Original()}
-	// Since git wire protocol has been released from git v2.18
-	if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil {
-		format += ", Wire Protocol %s Enabled"
-		args = append(args, "Version 2") // for focus color
-	}
-
-	return fmt.Sprintf(format, args...)
-}
-
-func checkInit() error {
-	if setting.Git.HomePath == "" {
-		return errors.New("unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
-	}
-	if DefaultContext != nil {
-		log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it")
+	if err := checkGitVersionCompatibility(DefaultFeatures().gitVersion); err != nil {
+		return fmt.Errorf("installed git version %s has a known compatibility issue with Gitea: %w, please upgrade (or downgrade) git", DefaultFeatures().gitVersion.String(), err)
 	}
 	return nil
 }
@@ -154,8 +143,12 @@ func HomeDir() string {
 // InitSimple initializes git module with a very simple step, no config changes, no global command arguments.
 // This method doesn't change anything to filesystem. At the moment, it is only used by some Gitea sub-commands.
 func InitSimple(ctx context.Context) error {
-	if err := checkInit(); err != nil {
-		return err
+	if setting.Git.HomePath == "" {
+		return errors.New("unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
+	}
+
+	if DefaultContext != nil && (!setting.IsProd || setting.IsInTesting) {
+		log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it")
 	}
 
 	DefaultContext = ctx
@@ -165,7 +158,24 @@ func InitSimple(ctx context.Context) error {
 		defaultCommandExecutionTimeout = time.Duration(setting.Git.Timeout.Default) * time.Second
 	}
 
-	return SetExecutablePath(setting.Git.Path)
+	if err := SetExecutablePath(setting.Git.Path); err != nil {
+		return err
+	}
+
+	var err error
+	defaultFeatures, err = loadGitVersionFeatures()
+	if err != nil {
+		return err
+	}
+	if err = ensureGitVersion(); err != nil {
+		return err
+	}
+
+	// when git works with gnupg (commit signing), there should be a stable home for gnupg commands
+	if _, ok := os.LookupEnv("GNUPGHOME"); !ok {
+		_ = os.Setenv("GNUPGHOME", filepath.Join(HomeDir(), ".gnupg"))
+	}
+	return nil
 }
 
 // InitFull initializes git module with version check and change global variables, sync gitconfig.
@@ -175,30 +185,18 @@ func InitFull(ctx context.Context) (err error) {
 		return err
 	}
 
-	// when git works with gnupg (commit signing), there should be a stable home for gnupg commands
-	if _, ok := os.LookupEnv("GNUPGHOME"); !ok {
-		_ = os.Setenv("GNUPGHOME", filepath.Join(HomeDir(), ".gnupg"))
-	}
-
 	// Since git wire protocol has been released from git v2.18
-	if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil {
+	if setting.Git.EnableAutoGitWireProtocol && DefaultFeatures().CheckVersionAtLeast("2.18") {
 		globalCommandArgs = append(globalCommandArgs, "-c", "protocol.version=2")
 	}
 
 	// Explicitly disable credential helper, otherwise Git credentials might leak
-	if CheckGitVersionAtLeast("2.9") == nil {
+	if DefaultFeatures().CheckVersionAtLeast("2.9") {
 		globalCommandArgs = append(globalCommandArgs, "-c", "credential.helper=")
 	}
-	DefaultFeatures.SupportProcReceive = CheckGitVersionAtLeast("2.29") == nil
-	DefaultFeatures.SupportHashSha256 = CheckGitVersionAtLeast("2.42") == nil && !isGogit
-	if DefaultFeatures.SupportHashSha256 {
-		SupportedObjectFormats = append(SupportedObjectFormats, Sha256ObjectFormat)
-	} else {
-		log.Warn("sha256 hash support is disabled - requires Git >= 2.42. Gogit is currently unsupported")
-	}
 
 	if setting.LFS.StartServer {
-		if CheckGitVersionAtLeast("2.1.2") != nil {
+		if !DefaultFeatures().CheckVersionAtLeast("2.1.2") {
 			return errors.New("LFS server support requires Git >= 2.1.2")
 		}
 		globalCommandArgs = append(globalCommandArgs, "-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=")
@@ -238,13 +236,13 @@ func syncGitConfig() (err error) {
 		return err
 	}
 
-	if CheckGitVersionAtLeast("2.10") == nil {
+	if DefaultFeatures().CheckVersionAtLeast("2.10") {
 		if err := configSet("receive.advertisePushOptions", "true"); err != nil {
 			return err
 		}
 	}
 
-	if CheckGitVersionAtLeast("2.18") == nil {
+	if DefaultFeatures().CheckVersionAtLeast("2.18") {
 		if err := configSet("core.commitGraph", "true"); err != nil {
 			return err
 		}
@@ -256,7 +254,7 @@ func syncGitConfig() (err error) {
 		}
 	}
 
-	if DefaultFeatures.SupportProcReceive {
+	if DefaultFeatures().SupportProcReceive {
 		// set support for AGit flow
 		if err := configAddNonExist("receive.procReceiveRefs", "refs/for"); err != nil {
 			return err
@@ -294,7 +292,7 @@ func syncGitConfig() (err error) {
 	}
 
 	// By default partial clones are disabled, enable them from git v2.22
-	if !setting.Git.DisablePartialClone && CheckGitVersionAtLeast("2.22") == nil {
+	if !setting.Git.DisablePartialClone && DefaultFeatures().CheckVersionAtLeast("2.22") {
 		if err = configSet("uploadpack.allowfilter", "true"); err != nil {
 			return err
 		}
@@ -309,21 +307,6 @@ func syncGitConfig() (err error) {
 	return err
 }
 
-// CheckGitVersionAtLeast check git version is at least the constraint version
-func CheckGitVersionAtLeast(atLeast string) error {
-	if DefaultFeatures.GitVersion == nil {
-		panic("git module is not initialized") // it shouldn't happen
-	}
-	atLeastVersion, err := version.NewVersion(atLeast)
-	if err != nil {
-		return err
-	}
-	if DefaultFeatures.GitVersion.Compare(atLeastVersion) < 0 {
-		return fmt.Errorf("installed git binary version %s is not at least %s", DefaultFeatures.GitVersion.Original(), atLeast)
-	}
-	return nil
-}
-
 func checkGitVersionCompatibility(gitVer *version.Version) error {
 	badVersions := []struct {
 		Version *version.Version
diff --git a/modules/git/object_format.go b/modules/git/object_format.go
index 3de9ff8cf4..242d782e17 100644
--- a/modules/git/object_format.go
+++ b/modules/git/object_format.go
@@ -120,12 +120,8 @@ var (
 	Sha256ObjectFormat ObjectFormat = Sha256ObjectFormatImpl{}
 )
 
-var SupportedObjectFormats = []ObjectFormat{
-	Sha1ObjectFormat,
-}
-
 func ObjectFormatFromName(name string) ObjectFormat {
-	for _, objectFormat := range SupportedObjectFormats {
+	for _, objectFormat := range DefaultFeatures().SupportedObjectFormats {
 		if name == objectFormat.Name() {
 			return objectFormat
 		}
diff --git a/modules/git/object_id.go b/modules/git/object_id.go
index 33e5085005..82d30184df 100644
--- a/modules/git/object_id.go
+++ b/modules/git/object_id.go
@@ -54,7 +54,7 @@ func (*Sha256Hash) Type() ObjectFormat { return Sha256ObjectFormat }
 
 func NewIDFromString(hexHash string) (ObjectID, error) {
 	var theObjectFormat ObjectFormat
-	for _, objectFormat := range SupportedObjectFormats {
+	for _, objectFormat := range DefaultFeatures().SupportedObjectFormats {
 		if len(hexHash) == objectFormat.FullLength() {
 			theObjectFormat = objectFormat
 			break
diff --git a/modules/git/remote.go b/modules/git/remote.go
index 3585313f6a..7b10e6b663 100644
--- a/modules/git/remote.go
+++ b/modules/git/remote.go
@@ -12,7 +12,7 @@ import (
 // GetRemoteAddress returns remote url of git repository in the repoPath with special remote name
 func GetRemoteAddress(ctx context.Context, repoPath, remoteName string) (string, error) {
 	var cmd *Command
-	if CheckGitVersionAtLeast("2.7") == nil {
+	if DefaultFeatures().CheckVersionAtLeast("2.7") {
 		cmd = NewCommand(ctx, "remote", "get-url").AddDynamicArguments(remoteName)
 	} else {
 		cmd = NewCommand(ctx, "config", "--get").AddDynamicArguments("remote." + remoteName + ".url")
diff --git a/modules/git/repo.go b/modules/git/repo.go
index 4511e900e0..d4e1669bec 100644
--- a/modules/git/repo.go
+++ b/modules/git/repo.go
@@ -101,7 +101,7 @@ func InitRepository(ctx context.Context, repoPath string, bare bool, objectForma
 	if !IsValidObjectFormat(objectFormatName) {
 		return fmt.Errorf("invalid object format: %s", objectFormatName)
 	}
-	if DefaultFeatures.SupportHashSha256 {
+	if DefaultFeatures().SupportHashSha256 {
 		cmd.AddOptionValues("--object-format", objectFormatName)
 	}
 
diff --git a/modules/git/repo_base.go b/modules/git/repo_base.go
deleted file mode 100644
index 6c148d9af5..0000000000
--- a/modules/git/repo_base.go
+++ /dev/null
@@ -1,6 +0,0 @@
-// Copyright 2021 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package git
-
-var isGogit bool
diff --git a/modules/git/repo_base_gogit.go b/modules/git/repo_base_gogit.go
index 0cd07dcdc8..a1127f4e6c 100644
--- a/modules/git/repo_base_gogit.go
+++ b/modules/git/repo_base_gogit.go
@@ -22,9 +22,7 @@ import (
 	"github.com/go-git/go-git/v5/storage/filesystem"
 )
 
-func init() {
-	isGogit = true
-}
+const isGogit = true
 
 // Repository represents a Git repository.
 type Repository struct {
diff --git a/modules/git/repo_base_nogogit.go b/modules/git/repo_base_nogogit.go
index 5511526e78..bc241cdd79 100644
--- a/modules/git/repo_base_nogogit.go
+++ b/modules/git/repo_base_nogogit.go
@@ -15,9 +15,7 @@ import (
 	"code.gitea.io/gitea/modules/util"
 )
 
-func init() {
-	isGogit = false
-}
+const isGogit = false
 
 // Repository represents a Git repository.
 type Repository struct {
diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go
index f9168bef7e..8c3285769e 100644
--- a/modules/git/repo_commit.go
+++ b/modules/git/repo_commit.go
@@ -438,7 +438,7 @@ func (repo *Repository) getCommitsBeforeLimit(id ObjectID, num int) ([]*Commit,
 }
 
 func (repo *Repository) getBranches(commit *Commit, limit int) ([]string, error) {
-	if CheckGitVersionAtLeast("2.7.0") == nil {
+	if DefaultFeatures().CheckVersionAtLeast("2.7.0") {
 		stdout, _, err := NewCommand(repo.Ctx, "for-each-ref", "--format=%(refname:strip=2)").
 			AddOptionFormat("--count=%d", limit).
 			AddOptionValues("--contains", commit.ID.String(), BranchPrefix).
diff --git a/modules/git/repo_commitgraph.go b/modules/git/repo_commitgraph.go
index 492438be37..087d5bcec4 100644
--- a/modules/git/repo_commitgraph.go
+++ b/modules/git/repo_commitgraph.go
@@ -11,7 +11,7 @@ import (
 // WriteCommitGraph write commit graph to speed up repo access
 // this requires git v2.18 to be installed
 func WriteCommitGraph(ctx context.Context, repoPath string) error {
-	if CheckGitVersionAtLeast("2.18") == nil {
+	if DefaultFeatures().CheckVersionAtLeast("2.18") {
 		if _, _, err := NewCommand(ctx, "commit-graph", "write").RunStdString(&RunOpts{Dir: repoPath}); err != nil {
 			return fmt.Errorf("unable to write commit-graph for '%s' : %w", repoPath, err)
 		}
diff --git a/modules/lfs/pointer_scanner_nogogit.go b/modules/lfs/pointer_scanner_nogogit.go
index 658b98feab..c37a93e73b 100644
--- a/modules/lfs/pointer_scanner_nogogit.go
+++ b/modules/lfs/pointer_scanner_nogogit.go
@@ -41,7 +41,7 @@ func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan c
 	go pipeline.BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg)
 
 	// 1. Run batch-check on all objects in the repository
-	if git.CheckGitVersionAtLeast("2.6.0") != nil {
+	if !git.DefaultFeatures().CheckVersionAtLeast("2.6.0") {
 		revListReader, revListWriter := io.Pipe()
 		shasToCheckReader, shasToCheckWriter := io.Pipe()
 		wg.Add(2)
diff --git a/routers/init.go b/routers/init.go
index 030ef3c740..56c95cd1ca 100644
--- a/routers/init.go
+++ b/routers/init.go
@@ -25,6 +25,7 @@ import (
 	"code.gitea.io/gitea/modules/system"
 	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/translation"
+	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/modules/web/routing"
 	actions_router "code.gitea.io/gitea/routers/api/actions"
@@ -112,7 +113,10 @@ func InitWebInstallPage(ctx context.Context) {
 // InitWebInstalled is for global installed configuration.
 func InitWebInstalled(ctx context.Context) {
 	mustInitCtx(ctx, git.InitFull)
-	log.Info("Git version: %s (home: %s)", git.VersionInfo(), git.HomeDir())
+	log.Info("Git version: %s (home: %s)", git.DefaultFeatures().VersionInfo(), git.HomeDir())
+	if !git.DefaultFeatures().SupportHashSha256 {
+		log.Warn("sha256 hash support is disabled - requires Git >= 2.42." + util.Iif(git.DefaultFeatures().UsingGogit, " Gogit is currently unsupported.", ""))
+	}
 
 	// Setup i18n
 	translation.InitLocales(ctx)
diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go
index 7189fd715c..0a3c8e2559 100644
--- a/routers/private/hook_pre_receive.go
+++ b/routers/private/hook_pre_receive.go
@@ -122,7 +122,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
 			preReceiveBranch(ourCtx, oldCommitID, newCommitID, refFullName)
 		case refFullName.IsTag():
 			preReceiveTag(ourCtx, refFullName)
-		case git.DefaultFeatures.SupportProcReceive && refFullName.IsFor():
+		case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
 			preReceiveFor(ourCtx, refFullName)
 		default:
 			ourCtx.AssertCanWriteCode()
diff --git a/routers/private/hook_proc_receive.go b/routers/private/hook_proc_receive.go
index cee3bbdd12..efb3f5831e 100644
--- a/routers/private/hook_proc_receive.go
+++ b/routers/private/hook_proc_receive.go
@@ -18,7 +18,7 @@ import (
 // HookProcReceive proc-receive hook - only handles agit Proc-Receive requests at present
 func HookProcReceive(ctx *gitea_context.PrivateContext) {
 	opts := web.GetForm(ctx).(*private.HookOptions)
-	if !git.DefaultFeatures.SupportProcReceive {
+	if !git.DefaultFeatures().SupportProcReceive {
 		ctx.Status(http.StatusNotFound)
 		return
 	}
diff --git a/routers/private/serv.go b/routers/private/serv.go
index 85368a0aed..1c309865d7 100644
--- a/routers/private/serv.go
+++ b/routers/private/serv.go
@@ -297,7 +297,7 @@ func ServCommand(ctx *context.PrivateContext) {
 			}
 		} else {
 			// Because of the special ref "refs/for" we will need to delay write permission check
-			if git.DefaultFeatures.SupportProcReceive && unitType == unit.TypeCode {
+			if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode {
 				mode = perm.AccessModeRead
 			}
 
diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go
index 48f80dbbf1..fd8c73b62d 100644
--- a/routers/web/admin/config.go
+++ b/routers/web/admin/config.go
@@ -112,7 +112,7 @@ func Config(ctx *context.Context) {
 	ctx.Data["OfflineMode"] = setting.OfflineMode
 	ctx.Data["RunUser"] = setting.RunUser
 	ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode)
-	ctx.Data["GitVersion"] = git.VersionInfo()
+	ctx.Data["GitVersion"] = git.DefaultFeatures().VersionInfo()
 
 	ctx.Data["AppDataPath"] = setting.AppDataPath
 	ctx.Data["RepoRootPath"] = setting.RepoRootPath
diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go
index ac5496ce91..caaca7f521 100644
--- a/routers/web/misc/misc.go
+++ b/routers/web/misc/misc.go
@@ -15,7 +15,7 @@ import (
 )
 
 func SSHInfo(rw http.ResponseWriter, req *http.Request) {
-	if !git.DefaultFeatures.SupportProcReceive {
+	if !git.DefaultFeatures().SupportProcReceive {
 		rw.WriteHeader(http.StatusNotFound)
 		return
 	}
diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go
index 8fb6d93068..f0579b56ea 100644
--- a/routers/web/repo/githttp.go
+++ b/routers/web/repo/githttp.go
@@ -183,7 +183,7 @@ func httpBase(ctx *context.Context) *serviceHandler {
 
 		if repoExist {
 			// Because of special ref "refs/for" .. , need delay write permission check
-			if git.DefaultFeatures.SupportProcReceive {
+			if git.DefaultFeatures().SupportProcReceive {
 				accessMode = perm.AccessModeRead
 			}
 
diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go
index 4e448933c7..48be1c2296 100644
--- a/routers/web/repo/repo.go
+++ b/routers/web/repo/repo.go
@@ -180,7 +180,7 @@ func Create(ctx *context.Context) {
 
 	ctx.Data["CanCreateRepo"] = ctx.Doer.CanCreateRepo()
 	ctx.Data["MaxCreationLimit"] = ctx.Doer.MaxCreationLimit()
-	ctx.Data["SupportedObjectFormats"] = git.SupportedObjectFormats
+	ctx.Data["SupportedObjectFormats"] = git.DefaultFeatures().SupportedObjectFormats
 	ctx.Data["DefaultObjectFormat"] = git.Sha1ObjectFormat
 
 	ctx.HTML(http.StatusOK, tplCreate)
diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go
index d115686491..3a35d24dff 100644
--- a/services/gitdiff/gitdiff.go
+++ b/services/gitdiff/gitdiff.go
@@ -1143,7 +1143,7 @@ func GetDiff(ctx context.Context, gitRepo *git.Repository, opts *DiffOptions, fi
 	// so if we are using at least this version of git we don't have to tell ParsePatch to do
 	// the skipping for us
 	parsePatchSkipToFile := opts.SkipTo
-	if opts.SkipTo != "" && git.CheckGitVersionAtLeast("2.31") == nil {
+	if opts.SkipTo != "" && git.DefaultFeatures().CheckVersionAtLeast("2.31") {
 		cmdDiff.AddOptionFormat("--skip-to=%s", opts.SkipTo)
 		parsePatchSkipToFile = ""
 	}
diff --git a/services/pull/patch.go b/services/pull/patch.go
index 12b79a0625..981bc989fc 100644
--- a/services/pull/patch.go
+++ b/services/pull/patch.go
@@ -383,7 +383,7 @@ func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *
 		cmdApply.AddArguments("--ignore-whitespace")
 	}
 	is3way := false
-	if git.CheckGitVersionAtLeast("2.32.0") == nil {
+	if git.DefaultFeatures().CheckVersionAtLeast("2.32.0") {
 		cmdApply.AddArguments("--3way")
 		is3way = true
 	}
diff --git a/services/pull/temp_repo.go b/services/pull/temp_repo.go
index 36bdbde55c..e5753178b8 100644
--- a/services/pull/temp_repo.go
+++ b/services/pull/temp_repo.go
@@ -104,7 +104,7 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest)
 	baseBranch := "base"
 
 	fetchArgs := git.TrustedCmdArgs{"--no-tags"}
-	if git.CheckGitVersionAtLeast("2.25.0") == nil {
+	if git.DefaultFeatures().CheckVersionAtLeast("2.25.0") {
 		// Writing the commit graph can be slow and is not needed here
 		fetchArgs = append(fetchArgs, "--no-write-commit-graph")
 	}
diff --git a/services/repository/files/patch.go b/services/repository/files/patch.go
index e5f7e2af96..ab0e7ffd36 100644
--- a/services/repository/files/patch.go
+++ b/services/repository/files/patch.go
@@ -148,7 +148,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
 	stderr := &strings.Builder{}
 
 	cmdApply := git.NewCommand(ctx, "apply", "--index", "--recount", "--cached", "--ignore-whitespace", "--whitespace=fix", "--binary")
-	if git.CheckGitVersionAtLeast("2.32") == nil {
+	if git.DefaultFeatures().CheckVersionAtLeast("2.32") {
 		cmdApply.AddArguments("-3")
 	}
 
diff --git a/tests/integration/git_test.go b/tests/integration/git_test.go
index 8a091ecab7..993a3d6799 100644
--- a/tests/integration/git_test.go
+++ b/tests/integration/git_test.go
@@ -695,7 +695,7 @@ func doCreateAgitFlowPull(dstPath string, ctx *APITestContext, headBranch string
 		defer tests.PrintCurrentTest(t)()
 
 		// skip this test if git version is low
-		if git.CheckGitVersionAtLeast("2.29") != nil {
+		if !git.DefaultFeatures().SupportProcReceive {
 			return
 		}
 

From 9c08637eae8c3a44d15e62d85144e07ae9dabbec Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Tue, 7 May 2024 01:02:30 +0800
Subject: [PATCH 44/47] Make "sync branch" also sync object format and add
 tests (#30878)

---
 modules/git/repo.go               | 27 ---------------------------
 modules/repository/branch.go      | 10 ++++++++++
 modules/repository/branch_test.go | 31 +++++++++++++++++++++++++++++++
 services/repository/adopt.go      |  4 ++++
 4 files changed, 45 insertions(+), 27 deletions(-)
 create mode 100644 modules/repository/branch_test.go

diff --git a/modules/git/repo.go b/modules/git/repo.go
index d4e1669bec..1c223018ad 100644
--- a/modules/git/repo.go
+++ b/modules/git/repo.go
@@ -7,7 +7,6 @@ package git
 import (
 	"bytes"
 	"context"
-	"errors"
 	"fmt"
 	"io"
 	"net/url"
@@ -63,32 +62,6 @@ func IsRepoURLAccessible(ctx context.Context, url string) bool {
 	return err == nil
 }
 
-// GetObjectFormatOfRepo returns the hash type of repository at a given path
-func GetObjectFormatOfRepo(ctx context.Context, repoPath string) (ObjectFormat, error) {
-	var stdout, stderr strings.Builder
-
-	err := NewCommand(ctx, "hash-object", "--stdin").Run(&RunOpts{
-		Dir:    repoPath,
-		Stdout: &stdout,
-		Stderr: &stderr,
-		Stdin:  &strings.Reader{},
-	})
-	if err != nil {
-		return nil, err
-	}
-
-	if stderr.Len() > 0 {
-		return nil, errors.New(stderr.String())
-	}
-
-	h, err := NewIDFromString(strings.TrimRight(stdout.String(), "\n"))
-	if err != nil {
-		return nil, err
-	}
-
-	return h.Type(), nil
-}
-
 // InitRepository initializes a new Git repository.
 func InitRepository(ctx context.Context, repoPath string, bare bool, objectFormatName string) error {
 	err := os.MkdirAll(repoPath, os.ModePerm)
diff --git a/modules/repository/branch.go b/modules/repository/branch.go
index e448490f4a..a3fca7c7ce 100644
--- a/modules/repository/branch.go
+++ b/modules/repository/branch.go
@@ -5,6 +5,7 @@ package repository
 
 import (
 	"context"
+	"fmt"
 
 	"code.gitea.io/gitea/models/db"
 	git_model "code.gitea.io/gitea/models/git"
@@ -36,6 +37,15 @@ func SyncRepoBranches(ctx context.Context, repoID, doerID int64) (int64, error)
 }
 
 func SyncRepoBranchesWithRepo(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doerID int64) (int64, error) {
+	objFmt, err := gitRepo.GetObjectFormat()
+	if err != nil {
+		return 0, fmt.Errorf("GetObjectFormat: %w", err)
+	}
+	_, err = db.GetEngine(ctx).ID(repo.ID).Update(&repo_model.Repository{ObjectFormatName: objFmt.Name()})
+	if err != nil {
+		return 0, fmt.Errorf("UpdateRepository: %w", err)
+	}
+
 	allBranches := container.Set[string]{}
 	{
 		branches, _, err := gitRepo.GetBranchNames(0, 0)
diff --git a/modules/repository/branch_test.go b/modules/repository/branch_test.go
new file mode 100644
index 0000000000..acf75a1ac0
--- /dev/null
+++ b/modules/repository/branch_test.go
@@ -0,0 +1,31 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repository
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	git_model "code.gitea.io/gitea/models/git"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSyncRepoBranches(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	_, err := db.GetEngine(db.DefaultContext).ID(1).Update(&repo_model.Repository{ObjectFormatName: "bad-fmt"})
+	assert.NoError(t, db.TruncateBeans(db.DefaultContext, &git_model.Branch{}))
+	assert.NoError(t, err)
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	assert.Equal(t, "bad-fmt", repo.ObjectFormatName)
+	_, err = SyncRepoBranches(db.DefaultContext, 1, 0)
+	assert.NoError(t, err)
+	repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	assert.Equal(t, "sha1", repo.ObjectFormatName)
+	branch, err := git_model.GetBranch(db.DefaultContext, 1, "master")
+	assert.NoError(t, err)
+	assert.EqualValues(t, "master", branch.Name)
+}
diff --git a/services/repository/adopt.go b/services/repository/adopt.go
index 914cd9047b..f4d0da67a5 100644
--- a/services/repository/adopt.go
+++ b/services/repository/adopt.go
@@ -195,6 +195,10 @@ func adoptRepository(ctx context.Context, repoPath string, repo *repo_model.Repo
 	}
 	defer gitRepo.Close()
 
+	if _, err = repo_module.SyncRepoBranchesWithRepo(ctx, repo, gitRepo, 0); err != nil {
+		return fmt.Errorf("SyncRepoBranches: %w", err)
+	}
+
 	if err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil {
 		return fmt.Errorf("SyncReleasesWithTags: %w", err)
 	}

From 6ad77125cabe53a943d46b50e8cb79cfcea5491f Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Tue, 7 May 2024 14:45:30 +0800
Subject: [PATCH 45/47] Fix missing migrate actions artifacts (#30874)

The actions artifacts should be able to be migrate to the new storage
place.
---
 cmd/migrate_storage.go | 24 ++++++++++++++++--------
 1 file changed, 16 insertions(+), 8 deletions(-)

diff --git a/cmd/migrate_storage.go b/cmd/migrate_storage.go
index aa49445a89..357416fc33 100644
--- a/cmd/migrate_storage.go
+++ b/cmd/migrate_storage.go
@@ -34,7 +34,7 @@ var CmdMigrateStorage = &cli.Command{
 			Name:    "type",
 			Aliases: []string{"t"},
 			Value:   "",
-			Usage:   "Type of stored files to copy.  Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log'",
+			Usage:   "Type of stored files to copy.  Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log', 'actions-artifacts",
 		},
 		&cli.StringFlag{
 			Name:    "storage",
@@ -160,6 +160,13 @@ func migrateActionsLog(ctx context.Context, dstStorage storage.ObjectStorage) er
 	})
 }
 
+func migrateActionsArtifacts(ctx context.Context, dstStorage storage.ObjectStorage) error {
+	return db.Iterate(ctx, nil, func(ctx context.Context, artifact *actions_model.ActionArtifact) error {
+		_, err := storage.Copy(dstStorage, artifact.ArtifactPath, storage.ActionsArtifacts, artifact.ArtifactPath)
+		return err
+	})
+}
+
 func runMigrateStorage(ctx *cli.Context) error {
 	stdCtx, cancel := installSignals()
 	defer cancel()
@@ -223,13 +230,14 @@ func runMigrateStorage(ctx *cli.Context) error {
 	}
 
 	migratedMethods := map[string]func(context.Context, storage.ObjectStorage) error{
-		"attachments":    migrateAttachments,
-		"lfs":            migrateLFS,
-		"avatars":        migrateAvatars,
-		"repo-avatars":   migrateRepoAvatars,
-		"repo-archivers": migrateRepoArchivers,
-		"packages":       migratePackages,
-		"actions-log":    migrateActionsLog,
+		"attachments":       migrateAttachments,
+		"lfs":               migrateLFS,
+		"avatars":           migrateAvatars,
+		"repo-avatars":      migrateRepoAvatars,
+		"repo-archivers":    migrateRepoArchivers,
+		"packages":          migratePackages,
+		"actions-log":       migrateActionsLog,
+		"actions-artifacts": migrateActionsArtifacts,
 	}
 
 	tp := strings.ToLower(ctx.String("type"))

From ebf0c969403d91ed80745ff5bd7dfbdb08174fc7 Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Tue, 7 May 2024 15:36:48 +0800
Subject: [PATCH 46/47] Move database operations of merging a pull request to
 post receive hook and add a transaction (#30805)

Merging PR may fail because of various problems. The pull request may
have a dirty state because there is no transaction when merging a pull
request. ref
https://github.com/go-gitea/gitea/pull/25741#issuecomment-2074126393

This PR moves all database update operations to post-receive handler for
merging a pull request and having a database transaction. That means if
database operations fail, then the git merging will fail, the git client
will get a fail result.

There are already many tests for pull request merging, so we don't need
to add a new one.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 cmd/hook.go                               |  3 ++
 modules/private/hook.go                   |  2 +
 modules/repository/env.go                 |  8 +++
 routers/private/hook_post_receive.go      | 64 ++++++++++++++++++++++-
 routers/private/hook_post_receive_test.go | 49 +++++++++++++++++
 services/contexttest/context_tests.go     | 13 +++++
 services/pull/merge.go                    | 27 ++++------
 services/pull/update.go                   |  3 +-
 8 files changed, 150 insertions(+), 19 deletions(-)
 create mode 100644 routers/private/hook_post_receive_test.go

diff --git a/cmd/hook.go b/cmd/hook.go
index 9c1cb66f2a..6e31710caf 100644
--- a/cmd/hook.go
+++ b/cmd/hook.go
@@ -338,6 +338,7 @@ Gitea or set your environment appropriately.`, "")
 	isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
 	repoName := os.Getenv(repo_module.EnvRepoName)
 	pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
+	prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
 	pusherName := os.Getenv(repo_module.EnvPusherName)
 
 	hookOptions := private.HookOptions{
@@ -347,6 +348,8 @@ Gitea or set your environment appropriately.`, "")
 		GitObjectDirectory:              os.Getenv(private.GitObjectDirectory),
 		GitQuarantinePath:               os.Getenv(private.GitQuarantinePath),
 		GitPushOptions:                  pushOptions(),
+		PullRequestID:                   prID,
+		PushTrigger:                     repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)),
 	}
 	oldCommitIDs := make([]string, hookBatchSize)
 	newCommitIDs := make([]string, hookBatchSize)
diff --git a/modules/private/hook.go b/modules/private/hook.go
index 79c3d48229..49d9298744 100644
--- a/modules/private/hook.go
+++ b/modules/private/hook.go
@@ -12,6 +12,7 @@ import (
 
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/optional"
+	"code.gitea.io/gitea/modules/repository"
 	"code.gitea.io/gitea/modules/setting"
 )
 
@@ -54,6 +55,7 @@ type HookOptions struct {
 	GitQuarantinePath               string
 	GitPushOptions                  GitPushOptions
 	PullRequestID                   int64
+	PushTrigger                     repository.PushTrigger
 	DeployKeyID                     int64 // if the pusher is a DeployKey, then UserID is the repo's org user.
 	IsWiki                          bool
 	ActionPerm                      int
diff --git a/modules/repository/env.go b/modules/repository/env.go
index 30edd1c9e3..e4f32092fc 100644
--- a/modules/repository/env.go
+++ b/modules/repository/env.go
@@ -25,11 +25,19 @@ const (
 	EnvKeyID        = "GITEA_KEY_ID" // public key ID
 	EnvDeployKeyID  = "GITEA_DEPLOY_KEY_ID"
 	EnvPRID         = "GITEA_PR_ID"
+	EnvPushTrigger  = "GITEA_PUSH_TRIGGER"
 	EnvIsInternal   = "GITEA_INTERNAL_PUSH"
 	EnvAppURL       = "GITEA_ROOT_URL"
 	EnvActionPerm   = "GITEA_ACTION_PERM"
 )
 
+type PushTrigger string
+
+const (
+	PushTriggerPRMergeToBase    PushTrigger = "pr-merge-to-base"
+	PushTriggerPRUpdateWithBase PushTrigger = "pr-update-with-base"
+)
+
 // InternalPushingEnvironment returns an os environment to switch off hooks on push
 // It is recommended to avoid using this unless you are pushing within a transaction
 // or if you absolutely are sure that post-receive and pre-receive will do nothing
diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go
index adc435b42c..0c2c1836ed 100644
--- a/routers/private/hook_post_receive.go
+++ b/routers/private/hook_post_receive.go
@@ -4,20 +4,25 @@
 package private
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 
+	"code.gitea.io/gitea/models/db"
 	git_model "code.gitea.io/gitea/models/git"
 	issues_model "code.gitea.io/gitea/models/issues"
 	access_model "code.gitea.io/gitea/models/perm/access"
+	pull_model "code.gitea.io/gitea/models/pull"
 	repo_model "code.gitea.io/gitea/models/repo"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/cache"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/private"
 	repo_module "code.gitea.io/gitea/modules/repository"
 	"code.gitea.io/gitea/modules/setting"
+	timeutil "code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
 	gitea_context "code.gitea.io/gitea/services/context"
@@ -158,6 +163,14 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
 		}
 	}
 
+	// handle pull request merging, a pull request action should push at least 1 commit
+	if opts.PushTrigger == repo_module.PushTriggerPRMergeToBase {
+		handlePullRequestMerging(ctx, opts, ownerName, repoName, updates)
+		if ctx.Written() {
+			return
+		}
+	}
+
 	isPrivate := opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate)
 	isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate)
 	// Handle Push Options
@@ -172,7 +185,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
 			wasEmpty = repo.IsEmpty
 		}
 
-		pusher, err := user_model.GetUserByID(ctx, opts.UserID)
+		pusher, err := loadContextCacheUser(ctx, opts.UserID)
 		if err != nil {
 			log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err)
 			ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
@@ -307,3 +320,52 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
 		RepoWasEmpty: wasEmpty,
 	})
 }
+
+func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) {
+	return cache.GetWithContextCache(ctx, "hook_post_receive_user", id, func() (*user_model.User, error) {
+		return user_model.GetUserByID(ctx, id)
+	})
+}
+
+// handlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit
+func handlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, ownerName, repoName string, updates []*repo_module.PushUpdateOptions) {
+	if len(updates) == 0 {
+		ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
+			Err: fmt.Sprintf("Pushing a merged PR (pr:%d) no commits pushed ", opts.PullRequestID),
+		})
+		return
+	}
+
+	pr, err := issues_model.GetPullRequestByID(ctx, opts.PullRequestID)
+	if err != nil {
+		log.Error("GetPullRequestByID[%d]: %v", opts.PullRequestID, err)
+		ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "GetPullRequestByID failed"})
+		return
+	}
+
+	pusher, err := loadContextCacheUser(ctx, opts.UserID)
+	if err != nil {
+		log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err)
+		ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Load pusher user failed"})
+		return
+	}
+
+	pr.MergedCommitID = updates[len(updates)-1].NewCommitID
+	pr.MergedUnix = timeutil.TimeStampNow()
+	pr.Merger = pusher
+	pr.MergerID = pusher.ID
+	err = db.WithTx(ctx, func(ctx context.Context) error {
+		// Removing an auto merge pull and ignore if not exist
+		if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) {
+			return fmt.Errorf("DeleteScheduledAutoMerge[%d]: %v", opts.PullRequestID, err)
+		}
+		if _, err := pr.SetMerged(ctx); err != nil {
+			return fmt.Errorf("SetMerged failed: %s/%s Error: %v", ownerName, repoName, err)
+		}
+		return nil
+	})
+	if err != nil {
+		log.Error("Failed to update PR to merged: %v", err)
+		ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to update PR to merged"})
+	}
+}
diff --git a/routers/private/hook_post_receive_test.go b/routers/private/hook_post_receive_test.go
new file mode 100644
index 0000000000..658557d3cf
--- /dev/null
+++ b/routers/private/hook_post_receive_test.go
@@ -0,0 +1,49 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	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/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/private"
+	repo_module "code.gitea.io/gitea/modules/repository"
+	"code.gitea.io/gitea/services/contexttest"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestHandlePullRequestMerging(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	pr, err := issues_model.GetUnmergedPullRequest(db.DefaultContext, 1, 1, "branch2", "master", issues_model.PullRequestFlowGithub)
+	assert.NoError(t, err)
+	assert.NoError(t, pr.LoadBaseRepo(db.DefaultContext))
+
+	user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+	err = pull_model.ScheduleAutoMerge(db.DefaultContext, user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr")
+	assert.NoError(t, err)
+
+	autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})
+
+	ctx, resp := contexttest.MockPrivateContext(t, "/")
+	handlePullRequestMerging(ctx, &private.HookOptions{
+		PullRequestID: pr.ID,
+		UserID:        2,
+	}, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, []*repo_module.PushUpdateOptions{
+		{NewCommitID: "01234567"},
+	})
+	assert.Equal(t, 0, len(resp.Body.String()))
+	pr, err = issues_model.GetPullRequestByID(db.DefaultContext, pr.ID)
+	assert.NoError(t, err)
+	assert.True(t, pr.HasMerged)
+	assert.EqualValues(t, "01234567", pr.MergedCommitID)
+
+	unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{ID: autoMerge.ID})
+}
diff --git a/services/contexttest/context_tests.go b/services/contexttest/context_tests.go
index 0c1e5ee54f..5624d24058 100644
--- a/services/contexttest/context_tests.go
+++ b/services/contexttest/context_tests.go
@@ -94,6 +94,19 @@ func MockAPIContext(t *testing.T, reqPath string) (*context.APIContext, *httptes
 	return ctx, resp
 }
 
+func MockPrivateContext(t *testing.T, reqPath string) (*context.PrivateContext, *httptest.ResponseRecorder) {
+	resp := httptest.NewRecorder()
+	req := mockRequest(t, reqPath)
+	base, baseCleanUp := context.NewBaseContext(resp, req)
+	base.Data = middleware.GetContextData(req.Context())
+	base.Locale = &translation.MockLocale{}
+	ctx := &context.PrivateContext{Base: base}
+	_ = baseCleanUp // during test, it doesn't need to do clean up. TODO: this can be improved later
+	chiCtx := chi.NewRouteContext()
+	ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx)
+	return ctx, resp
+}
+
 // LoadRepo load a repo into a test context.
 func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) {
 	var doer *user_model.User
diff --git a/services/pull/merge.go b/services/pull/merge.go
index 00f23e1e3a..20be7c5b5a 100644
--- a/services/pull/merge.go
+++ b/services/pull/merge.go
@@ -18,7 +18,6 @@ import (
 	git_model "code.gitea.io/gitea/models/git"
 	issues_model "code.gitea.io/gitea/models/issues"
 	access_model "code.gitea.io/gitea/models/perm/access"
-	pull_model "code.gitea.io/gitea/models/pull"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
@@ -162,12 +161,6 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U
 	pullWorkingPool.CheckIn(fmt.Sprint(pr.ID))
 	defer pullWorkingPool.CheckOut(fmt.Sprint(pr.ID))
 
-	// Removing an auto merge pull and ignore if not exist
-	// FIXME: is this the correct point to do this? Shouldn't this be after IsMergeStyleAllowed?
-	if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) {
-		return err
-	}
-
 	prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests)
 	if err != nil {
 		log.Error("pr.BaseRepo.GetUnit(unit.TypePullRequests): %v", err)
@@ -184,17 +177,15 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U
 		go AddTestPullRequestTask(doer, pr.BaseRepo.ID, pr.BaseBranch, false, "", "")
 	}()
 
-	pr.MergedCommitID, err = doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message)
+	_, err = doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message, repo_module.PushTriggerPRMergeToBase)
 	if err != nil {
 		return err
 	}
 
-	pr.MergedUnix = timeutil.TimeStampNow()
-	pr.Merger = doer
-	pr.MergerID = doer.ID
-
-	if _, err := pr.SetMerged(ctx); err != nil {
-		log.Error("SetMerged %-v: %v", pr, err)
+	// reload pull request because it has been updated by post receive hook
+	pr, err = issues_model.GetPullRequestByID(ctx, pr.ID)
+	if err != nil {
+		return err
 	}
 
 	if err := pr.LoadIssue(ctx); err != nil {
@@ -245,7 +236,7 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U
 }
 
 // doMergeAndPush performs the merge operation without changing any pull information in database and pushes it up to the base repository
-func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string) (string, error) {
+func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, pushTrigger repo_module.PushTrigger) (string, error) {
 	// Clone base repo.
 	mergeCtx, cancel, err := createTemporaryRepoForMerge(ctx, pr, doer, expectedHeadCommitID)
 	if err != nil {
@@ -318,11 +309,13 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use
 		pr.BaseRepo.Name,
 		pr.ID,
 	)
+
+	mergeCtx.env = append(mergeCtx.env, repo_module.EnvPushTrigger+"="+string(pushTrigger))
 	pushCmd := git.NewCommand(ctx, "push", "origin").AddDynamicArguments(baseBranch + ":" + git.BranchPrefix + pr.BaseBranch)
 
 	// Push back to upstream.
-	// TODO: this cause an api call to "/api/internal/hook/post-receive/...",
-	//       that prevents us from doint the whole merge in one db transaction
+	// This cause an api call to "/api/internal/hook/post-receive/...",
+	// If it's merge, all db transaction and operations should be there but not here to prevent deadlock.
 	if err := pushCmd.Run(mergeCtx.RunOpts()); err != nil {
 		if strings.Contains(mergeCtx.errbuf.String(), "non-fast-forward") {
 			return "", &git.ErrPushOutOfDate{
diff --git a/services/pull/update.go b/services/pull/update.go
index 9b676e13ef..d2c0c2df80 100644
--- a/services/pull/update.go
+++ b/services/pull/update.go
@@ -15,6 +15,7 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/repository"
 )
 
 // Update updates pull request with base branch.
@@ -72,7 +73,7 @@ func Update(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.
 		BaseBranch: pr.HeadBranch,
 	}
 
-	_, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message)
+	_, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message, repository.PushTriggerPRUpdateWithBase)
 
 	defer func() {
 		go AddTestPullRequestTask(doer, reversePR.HeadRepo.ID, reversePR.HeadBranch, false, "", "")

From 67c1a07285008cc00036a87cef966c3bd519a50c Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Tue, 7 May 2024 16:26:13 +0800
Subject: [PATCH 47/47] Refactor AppURL usage (#30885)

Fix #30883
Fix #29591

---------

Co-authored-by: KN4CK3R <admin@oldschoolhack.me>
---
 models/repo/avatar.go                       | 12 ++---
 models/user/avatar.go                       | 10 ++--
 modules/httplib/url.go                      | 60 ++++++++++++++++++++-
 modules/httplib/url_test.go                 | 59 +++++++++++++++++---
 modules/markup/html_codepreview.go          |  2 +-
 routers/api/actions/artifacts.go            | 11 ++--
 routers/api/actions/artifactsv4.go          |  9 ++--
 routers/api/packages/container/container.go |  3 +-
 routers/common/middleware.go                |  3 ++
 routers/common/redirect.go                  |  2 +-
 routers/web/auth/auth.go                    |  2 +-
 services/context/base.go                    |  2 +-
 services/context/context_response.go        |  2 +-
 13 files changed, 138 insertions(+), 39 deletions(-)

diff --git a/models/repo/avatar.go b/models/repo/avatar.go
index 72ee938ada..8395b8c2b7 100644
--- a/models/repo/avatar.go
+++ b/models/repo/avatar.go
@@ -9,10 +9,10 @@ import (
 	"image/png"
 	"io"
 	"net/url"
-	"strings"
 
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/modules/avatar"
+	"code.gitea.io/gitea/modules/httplib"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/storage"
@@ -84,13 +84,7 @@ func (repo *Repository) relAvatarLink(ctx context.Context) string {
 	return setting.AppSubURL + "/repo-avatars/" + url.PathEscape(repo.Avatar)
 }
 
-// AvatarLink returns a link to the repository's avatar.
+// AvatarLink returns the full avatar url with http host. TODO: refactor it to a relative URL, but it is still used in API response at the moment
 func (repo *Repository) AvatarLink(ctx context.Context) string {
-	link := repo.relAvatarLink(ctx)
-	// we only prepend our AppURL to our known (relative, internal) avatar link to get an absolute URL
-	if strings.HasPrefix(link, "/") && !strings.HasPrefix(link, "//") {
-		return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL)[1:]
-	}
-	// otherwise, return the link as it is
-	return link
+	return httplib.MakeAbsoluteURL(ctx, repo.relAvatarLink(ctx))
 }
diff --git a/models/user/avatar.go b/models/user/avatar.go
index c6937d7b51..921bc1b1a1 100644
--- a/models/user/avatar.go
+++ b/models/user/avatar.go
@@ -9,11 +9,11 @@ import (
 	"fmt"
 	"image/png"
 	"io"
-	"strings"
 
 	"code.gitea.io/gitea/models/avatars"
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/modules/avatar"
+	"code.gitea.io/gitea/modules/httplib"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/storage"
@@ -89,13 +89,9 @@ func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string {
 	return avatars.GenerateEmailAvatarFastLink(ctx, u.AvatarEmail, size)
 }
 
-// AvatarLink returns the full avatar link with http host
+// AvatarLink returns the full avatar url with http host. TODO: refactor it to a relative URL, but it is still used in API response at the moment
 func (u *User) AvatarLink(ctx context.Context) string {
-	link := u.AvatarLinkWithSize(ctx, 0)
-	if !strings.HasPrefix(link, "//") && !strings.Contains(link, "://") {
-		return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL+"/")
-	}
-	return link
+	return httplib.MakeAbsoluteURL(ctx, u.AvatarLinkWithSize(ctx, 0))
 }
 
 // IsUploadAvatarChanged returns true if the current user's avatar would be changed with the provided data
diff --git a/modules/httplib/url.go b/modules/httplib/url.go
index 903799cb68..541c4f325b 100644
--- a/modules/httplib/url.go
+++ b/modules/httplib/url.go
@@ -4,6 +4,8 @@
 package httplib
 
 import (
+	"context"
+	"net/http"
 	"net/url"
 	"strings"
 
@@ -11,6 +13,10 @@ import (
 	"code.gitea.io/gitea/modules/util"
 )
 
+type RequestContextKeyStruct struct{}
+
+var RequestContextKey = RequestContextKeyStruct{}
+
 func urlIsRelative(s string, u *url.URL) bool {
 	// Unfortunately browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH"
 	// Therefore we should ignore these redirect locations to prevent open redirects
@@ -26,7 +32,56 @@ func IsRelativeURL(s string) bool {
 	return err == nil && urlIsRelative(s, u)
 }
 
-func IsCurrentGiteaSiteURL(s string) bool {
+func guessRequestScheme(req *http.Request, def string) string {
+	// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
+	if s := req.Header.Get("X-Forwarded-Proto"); s != "" {
+		return s
+	}
+	if s := req.Header.Get("X-Forwarded-Protocol"); s != "" {
+		return s
+	}
+	if s := req.Header.Get("X-Url-Scheme"); s != "" {
+		return s
+	}
+	if s := req.Header.Get("Front-End-Https"); s != "" {
+		return util.Iif(s == "on", "https", "http")
+	}
+	if s := req.Header.Get("X-Forwarded-Ssl"); s != "" {
+		return util.Iif(s == "on", "https", "http")
+	}
+	return def
+}
+
+func guessForwardedHost(req *http.Request) string {
+	// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host
+	return req.Header.Get("X-Forwarded-Host")
+}
+
+// GuessCurrentAppURL tries to guess the current full URL by http headers. It always has a '/' suffix, exactly the same as setting.AppURL
+func GuessCurrentAppURL(ctx context.Context) string {
+	req, ok := ctx.Value(RequestContextKey).(*http.Request)
+	if !ok {
+		return setting.AppURL
+	}
+	if host := guessForwardedHost(req); host != "" {
+		// if it is behind a reverse proxy, use "https" as default scheme in case the site admin forgets to set the correct forwarded-protocol headers
+		return guessRequestScheme(req, "https") + "://" + host + setting.AppSubURL + "/"
+	} else if req.Host != "" {
+		// if it is not behind a reverse proxy, use the scheme from config options, meanwhile use "https" as much as possible
+		defaultScheme := util.Iif(setting.Protocol == "http", "http", "https")
+		return guessRequestScheme(req, defaultScheme) + "://" + req.Host + setting.AppSubURL + "/"
+	}
+	return setting.AppURL
+}
+
+func MakeAbsoluteURL(ctx context.Context, s string) string {
+	if IsRelativeURL(s) {
+		return GuessCurrentAppURL(ctx) + strings.TrimPrefix(s, "/")
+	}
+	return s
+}
+
+func IsCurrentGiteaSiteURL(ctx context.Context, s string) bool {
 	u, err := url.Parse(s)
 	if err != nil {
 		return false
@@ -45,5 +100,6 @@ func IsCurrentGiteaSiteURL(s string) bool {
 	if u.Path == "" {
 		u.Path = "/"
 	}
-	return strings.HasPrefix(strings.ToLower(u.String()), strings.ToLower(setting.AppURL))
+	urlLower := strings.ToLower(u.String())
+	return strings.HasPrefix(urlLower, strings.ToLower(setting.AppURL)) || strings.HasPrefix(urlLower, strings.ToLower(GuessCurrentAppURL(ctx)))
 }
diff --git a/modules/httplib/url_test.go b/modules/httplib/url_test.go
index 9bf09bcf2f..e021cd610d 100644
--- a/modules/httplib/url_test.go
+++ b/modules/httplib/url_test.go
@@ -4,6 +4,8 @@
 package httplib
 
 import (
+	"context"
+	"net/http"
 	"testing"
 
 	"code.gitea.io/gitea/modules/setting"
@@ -37,9 +39,44 @@ func TestIsRelativeURL(t *testing.T) {
 	}
 }
 
+func TestMakeAbsoluteURL(t *testing.T) {
+	defer test.MockVariableValue(&setting.Protocol, "http")()
+	defer test.MockVariableValue(&setting.AppURL, "http://the-host/sub/")()
+	defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
+
+	ctx := context.Background()
+	assert.Equal(t, "http://the-host/sub/", MakeAbsoluteURL(ctx, ""))
+	assert.Equal(t, "http://the-host/sub/foo", MakeAbsoluteURL(ctx, "foo"))
+	assert.Equal(t, "http://the-host/sub/foo", MakeAbsoluteURL(ctx, "/foo"))
+	assert.Equal(t, "http://other/foo", MakeAbsoluteURL(ctx, "http://other/foo"))
+
+	ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
+		Host: "user-host",
+	})
+	assert.Equal(t, "http://user-host/sub/foo", MakeAbsoluteURL(ctx, "/foo"))
+
+	ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
+		Host: "user-host",
+		Header: map[string][]string{
+			"X-Forwarded-Host": {"forwarded-host"},
+		},
+	})
+	assert.Equal(t, "https://forwarded-host/sub/foo", MakeAbsoluteURL(ctx, "/foo"))
+
+	ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
+		Host: "user-host",
+		Header: map[string][]string{
+			"X-Forwarded-Host":  {"forwarded-host"},
+			"X-Forwarded-Proto": {"https"},
+		},
+	})
+	assert.Equal(t, "https://forwarded-host/sub/foo", MakeAbsoluteURL(ctx, "/foo"))
+}
+
 func TestIsCurrentGiteaSiteURL(t *testing.T) {
 	defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")()
 	defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
+	ctx := context.Background()
 	good := []string{
 		"?key=val",
 		"/sub",
@@ -50,7 +87,7 @@ func TestIsCurrentGiteaSiteURL(t *testing.T) {
 		"http://localhost:3000/sub/",
 	}
 	for _, s := range good {
-		assert.True(t, IsCurrentGiteaSiteURL(s), "good = %q", s)
+		assert.True(t, IsCurrentGiteaSiteURL(ctx, s), "good = %q", s)
 	}
 	bad := []string{
 		".",
@@ -64,13 +101,23 @@ func TestIsCurrentGiteaSiteURL(t *testing.T) {
 		"http://other/",
 	}
 	for _, s := range bad {
-		assert.False(t, IsCurrentGiteaSiteURL(s), "bad = %q", s)
+		assert.False(t, IsCurrentGiteaSiteURL(ctx, s), "bad = %q", s)
 	}
 
 	setting.AppURL = "http://localhost:3000/"
 	setting.AppSubURL = ""
-	assert.False(t, IsCurrentGiteaSiteURL("//"))
-	assert.False(t, IsCurrentGiteaSiteURL("\\\\"))
-	assert.False(t, IsCurrentGiteaSiteURL("http://localhost"))
-	assert.True(t, IsCurrentGiteaSiteURL("http://localhost:3000?key=val"))
+	assert.False(t, IsCurrentGiteaSiteURL(ctx, "//"))
+	assert.False(t, IsCurrentGiteaSiteURL(ctx, "\\\\"))
+	assert.False(t, IsCurrentGiteaSiteURL(ctx, "http://localhost"))
+	assert.True(t, IsCurrentGiteaSiteURL(ctx, "http://localhost:3000?key=val"))
+
+	ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
+		Host: "user-host",
+		Header: map[string][]string{
+			"X-Forwarded-Host":  {"forwarded-host"},
+			"X-Forwarded-Proto": {"https"},
+		},
+	})
+	assert.True(t, IsCurrentGiteaSiteURL(ctx, "http://localhost:3000"))
+	assert.True(t, IsCurrentGiteaSiteURL(ctx, "https://forwarded-host"))
 }
diff --git a/modules/markup/html_codepreview.go b/modules/markup/html_codepreview.go
index 5ef2217e3d..5ab9290b3e 100644
--- a/modules/markup/html_codepreview.go
+++ b/modules/markup/html_codepreview.go
@@ -42,7 +42,7 @@ func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosSt
 		CommitID:  node.Data[m[6]:m[7]],
 		FilePath:  node.Data[m[8]:m[9]],
 	}
-	if !httplib.IsCurrentGiteaSiteURL(opts.FullURL) {
+	if !httplib.IsCurrentGiteaSiteURL(ctx.Ctx, opts.FullURL) {
 		return 0, 0, "", nil
 	}
 	u, err := url.Parse(opts.FilePath)
diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go
index 5bd004bd37..35e3ee6906 100644
--- a/routers/api/actions/artifacts.go
+++ b/routers/api/actions/artifacts.go
@@ -71,6 +71,7 @@ import (
 
 	"code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/httplib"
 	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
@@ -184,8 +185,8 @@ type artifactRoutes struct {
 	fs     storage.ObjectStorage
 }
 
-func (ar artifactRoutes) buildArtifactURL(runID int64, artifactHash, suffix string) string {
-	uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ar.prefix, "/") +
+func (ar artifactRoutes) buildArtifactURL(ctx *ArtifactContext, runID int64, artifactHash, suffix string) string {
+	uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.TrimSuffix(ar.prefix, "/") +
 		strings.ReplaceAll(artifactRouteBase, "{run_id}", strconv.FormatInt(runID, 10)) +
 		"/" + artifactHash + "/" + suffix
 	return uploadURL
@@ -224,7 +225,7 @@ func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) {
 	// use md5(artifact_name) to create upload url
 	artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name)))
 	resp := getUploadArtifactResponse{
-		FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"+retentionQuery),
+		FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "upload"+retentionQuery),
 	}
 	log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL)
 	ctx.JSON(http.StatusOK, resp)
@@ -365,7 +366,7 @@ func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
 		artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(art.ArtifactName)))
 		item := listArtifactsResponseItem{
 			Name:                     art.ArtifactName,
-			FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "download_url"),
+			FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "download_url"),
 		}
 		items = append(items, item)
 		values[art.ArtifactName] = true
@@ -437,7 +438,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
 			}
 		}
 		if downloadURL == "" {
-			downloadURL = ar.buildArtifactURL(runID, strconv.FormatInt(artifact.ID, 10), "download")
+			downloadURL = ar.buildArtifactURL(ctx, runID, strconv.FormatInt(artifact.ID, 10), "download")
 		}
 		item := downloadArtifactResponseItem{
 			Path:            util.PathJoinRel(itemPath, artifact.ArtifactPath),
diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go
index 8300989c75..dde9caf4f2 100644
--- a/routers/api/actions/artifactsv4.go
+++ b/routers/api/actions/artifactsv4.go
@@ -92,6 +92,7 @@ import (
 
 	"code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/httplib"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/storage"
@@ -160,9 +161,9 @@ func (r artifactV4Routes) buildSignature(endp, expires, artifactName string, tas
 	return mac.Sum(nil)
 }
 
-func (r artifactV4Routes) buildArtifactURL(endp, artifactName string, taskID int64) string {
+func (r artifactV4Routes) buildArtifactURL(ctx *ArtifactContext, endp, artifactName string, taskID int64) string {
 	expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST")
-	uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(r.prefix, "/") +
+	uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.TrimSuffix(r.prefix, "/") +
 		"/" + endp + "?sig=" + base64.URLEncoding.EncodeToString(r.buildSignature(endp, expires, artifactName, taskID)) + "&expires=" + url.QueryEscape(expires) + "&artifactName=" + url.QueryEscape(artifactName) + "&taskID=" + fmt.Sprint(taskID)
 	return uploadURL
 }
@@ -278,7 +279,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) {
 
 	respData := CreateArtifactResponse{
 		Ok:              true,
-		SignedUploadUrl: r.buildArtifactURL("UploadArtifact", artifactName, ctx.ActionTask.ID),
+		SignedUploadUrl: r.buildArtifactURL(ctx, "UploadArtifact", artifactName, ctx.ActionTask.ID),
 	}
 	r.sendProtbufBody(ctx, &respData)
 }
@@ -454,7 +455,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
 		}
 	}
 	if respData.SignedUrl == "" {
-		respData.SignedUrl = r.buildArtifactURL("DownloadArtifact", artifactName, ctx.ActionTask.ID)
+		respData.SignedUrl = r.buildArtifactURL(ctx, "DownloadArtifact", artifactName, ctx.ActionTask.ID)
 	}
 	r.sendProtbufBody(ctx, &respData)
 }
diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go
index 2cb16daebc..1efd166eb3 100644
--- a/routers/api/packages/container/container.go
+++ b/routers/api/packages/container/container.go
@@ -17,6 +17,7 @@ import (
 	packages_model "code.gitea.io/gitea/models/packages"
 	container_model "code.gitea.io/gitea/models/packages/container"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/httplib"
 	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/log"
 	packages_module "code.gitea.io/gitea/modules/packages"
@@ -115,7 +116,7 @@ func apiErrorDefined(ctx *context.Context, err *namedError) {
 }
 
 func apiUnauthorizedError(ctx *context.Context) {
-	ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+setting.AppURL+`v2/token",service="container_registry",scope="*"`)
+	ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+httplib.GuessCurrentAppURL(ctx)+`v2/token",service="container_registry",scope="*"`)
 	apiErrorDefined(ctx, errUnauthorized)
 }
 
diff --git a/routers/common/middleware.go b/routers/common/middleware.go
index c7c75fb099..8b661993bb 100644
--- a/routers/common/middleware.go
+++ b/routers/common/middleware.go
@@ -4,11 +4,13 @@
 package common
 
 import (
+	go_context "context"
 	"fmt"
 	"net/http"
 	"strings"
 
 	"code.gitea.io/gitea/modules/cache"
+	"code.gitea.io/gitea/modules/httplib"
 	"code.gitea.io/gitea/modules/process"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/web/middleware"
@@ -34,6 +36,7 @@ func ProtocolMiddlewares() (handlers []any) {
 				}
 			}()
 			req = req.WithContext(middleware.WithContextData(req.Context()))
+			req = req.WithContext(go_context.WithValue(req.Context(), httplib.RequestContextKey, req))
 			next.ServeHTTP(resp, req)
 		})
 	})
diff --git a/routers/common/redirect.go b/routers/common/redirect.go
index 34044e814b..d64f74ec82 100644
--- a/routers/common/redirect.go
+++ b/routers/common/redirect.go
@@ -17,7 +17,7 @@ func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) {
 	// The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2",
 	// then frontend needs this delegate to redirect to the new location with hash correctly.
 	redirect := req.PostFormValue("redirect")
-	if !httplib.IsCurrentGiteaSiteURL(redirect) {
+	if !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) {
 		resp.WriteHeader(http.StatusBadRequest)
 		return
 	}
diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go
index 7c873796fe..4083d64226 100644
--- a/routers/web/auth/auth.go
+++ b/routers/web/auth/auth.go
@@ -368,7 +368,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
 		return setting.AppSubURL + "/"
 	}
 
-	if redirectTo := ctx.GetSiteCookie("redirect_to"); redirectTo != "" && httplib.IsCurrentGiteaSiteURL(redirectTo) {
+	if redirectTo := ctx.GetSiteCookie("redirect_to"); redirectTo != "" && httplib.IsCurrentGiteaSiteURL(ctx, redirectTo) {
 		middleware.DeleteRedirectToCookie(ctx.Resp)
 		if obeyRedirect {
 			ctx.RedirectToCurrentSite(redirectTo)
diff --git a/services/context/base.go b/services/context/base.go
index 05b8ab1b9b..29e62ae389 100644
--- a/services/context/base.go
+++ b/services/context/base.go
@@ -254,7 +254,7 @@ func (b *Base) Redirect(location string, status ...int) {
 		code = status[0]
 	}
 
-	if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") || strings.HasPrefix(location, "//") {
+	if !httplib.IsRelativeURL(location) {
 		// Some browsers (Safari) have buggy behavior for Cookie + Cache + External Redirection, eg: /my-path => https://other/path
 		// 1. the first request to "/my-path" contains cookie
 		// 2. some time later, the request to "/my-path" doesn't contain cookie (caused by Prevent web tracking)
diff --git a/services/context/context_response.go b/services/context/context_response.go
index 87c34c35ed..c43a649b49 100644
--- a/services/context/context_response.go
+++ b/services/context/context_response.go
@@ -52,7 +52,7 @@ func (ctx *Context) RedirectToCurrentSite(location ...string) {
 			continue
 		}
 
-		if !httplib.IsCurrentGiteaSiteURL(loc) {
+		if !httplib.IsCurrentGiteaSiteURL(ctx, loc) {
 			continue
 		}